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.

395 lines • 24.1 kB
/** * vault — vault-gated deploy (protocol rev 41 §6.5; task 5.6, lane supplied * by change `vault-deploy-lane`). * * Every deploy has a commit, and the artifacts it ships CORRESPOND to it: * 1. snapshot (§6.6) → the `vault_commit`, printed ALWAYS, never sent, * plus a digest over the captured file set; * 2. a fresh `capture_id` before BOTH lanes; * 3. the deploy lane collects artifacts from the work tree and produces the * canonical plan digest (`apply_plan_sha256`, null when the build fails * before a plan exists) — concurrently, the push lane prepares the * publication; * 4. the push's head carries `capture_binding {capture_id, apply_plan_sha256|null, * snapshot_oid_hmac}` — capture-the-attempt survives a failed build; no * token is mintable from a null digest; * 5. BEFORE any plan commits, the captured-set digest is RE-DERIVED. A * difference is `SNAPSHOT_MOVED_DURING_DEPLOY`: the deploy stops, naming * the changed paths, and commits nothing; * 6. with a digest and an admitted head, the capture receipt is exchanged * for an activation token, and the apply commit is submitted ONLY with it; * 7. the outcome is one of the CLOSED five: `DEPLOYED_AND_VAULTED`, * `DEPLOY_BLOCKED_PUSH_FAILED`, `DEPLOY_FAILED_VAULTED`, * `DEPLOY_FAILED_UNVAULTED`, `DEPLOYED_UNVAULTED_OVERRIDE`. * * WHAT CORRESPONDENCE MEANS, AND WHAT IT DOES NOT. Step 5 establishes that the * captured source did NOT change while the artifacts were produced. It does * NOT establish that the artifacts are a reproducible function of that source: * nothing here ties a gitignored `dist/` to the `src/` it came from, and the * captured set deliberately excludes gitignored paths so a project with a * build step stays deployable. The stronger property — build inside an * isolated materialization, making artifacts a function of the snapshot — is a * recorded forward TODO, not a thing this module quietly delivers. * * REFUSE, NEVER REPAIR. On a detected move the deploy stops rather than * re-capturing: a second capture publishes a generation whose relationship to * the artifacts already collected is exactly what is in doubt, and a * capture→collect→capture loop churns generations. The caller re-runs. * * The push is never gated on deploy success. The unvaulted override * (`allow_unvaulted`) is journaled crash-safely BEFORE the commit with the * full §6.5 contents; any later invocation drains the journal by pushing the * journaled snapshot and presenting the capture receipt to the * override-completion route — equality on EVERY field or no clear. */ import { existsSync, readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { LocalError, isRun402Error } from "../errors.js"; import { formatVaultTimestamp, hexToBytes, newHex32 } from "../namespaces/vault.crypto.js"; import { readFileNoFollow, writeFileAtomic0600 } from "./vault-keystore.js"; import { captureBinding, deployRefTransaction } from "./vault-publication.js"; import { VAULT_DEPLOY_REF, capturedSetDigest, capturedSetUnchanged, captureSnapshot, deriveCapturedSet, diffCapturedSets, vaultCommitLine, snapshotCommitment } from "./vault-snapshot.js"; // ─── Outcomes ──────────────────────────────────────────────────────────────── export const VAULT_DEPLOY_OUTCOMES = [ "DEPLOYED_AND_VAULTED", "DEPLOY_BLOCKED_PUSH_FAILED", "DEPLOY_FAILED_VAULTED", "DEPLOY_FAILED_UNVAULTED", "DEPLOYED_UNVAULTED_OVERRIDE", ]; export function overrideJournalDir(keystore) { return join(keystore.journalDir, "overrides"); } export function overrideJournalPath(keystore, operationId) { if (!/^[A-Za-z0-9_.-]{1,128}$/.test(operationId)) throw new LocalError("operation_id is not a safe file name", "resolving override journal path", { code: "VAULT_BAD_ID" }); return join(overrideJournalDir(keystore), `${operationId}.json`); } export function writeOverrideJournal(keystore, journal) { writeFileAtomic0600(overrideJournalPath(keystore, journal.operation_id), JSON.stringify(journal, null, 2)); keystore.audit("journal_stage", journal.repo_id, { override_operation_id: journal.operation_id, publication_state: journal.publication_state }); } export function readOverrideJournal(keystore, operationId) { const text = readFileNoFollow(overrideJournalPath(keystore, operationId)); return text ? JSON.parse(text) : null; } /** Every override journal not yet `completed` — the doctor-visible advisory list. */ export function listPendingOverrideJournals(keystore, repoId) { const dir = overrideJournalDir(keystore); if (!existsSync(dir)) return []; const out = []; for (const f of readdirSync(dir)) { if (!f.endsWith(".json")) continue; const text = readFileNoFollow(join(dir, f)); if (!text) continue; const j = JSON.parse(text); if (j.version !== 1 || j.publication_state === "completed") continue; if (repoId && j.repo_id !== repoId) continue; out.push(j); } return out; } /** Compare a capture receipt against an operation's journaled binding on EVERY field: repo, capture_id, plan digest, snapshot commitment, head hash. */ export function matchCaptureReceipt(receipt, expected) { const mismatched = []; if (receipt.repo_id !== expected.repo_id) mismatched.push("repo_id"); if (receipt.capture_id !== expected.capture_id) mismatched.push("capture_id"); if (receipt.apply_plan_sha256 !== expected.apply_plan_sha256) mismatched.push("apply_plan_sha256"); if (receipt.snapshot_oid_hmac !== expected.snapshot_oid_hmac) mismatched.push("snapshot_oid_hmac"); if (receipt.head_sha256 !== expected.head_sha256) mismatched.push("head_sha256"); return { equal: mismatched.length === 0, mismatched_fields: mismatched }; } /** * `authorization_epoch` is compared BYTEWISE against the epoch the client last * saw installed (a DR gate mints a fresh one). Any difference — one nibble is * enough — is `AUTHORIZATION_EPOCH_STALE`, never a retry under the old epoch. */ export function checkAuthorizationEpoch(tokenEpoch, installedEpoch) { if (tokenEpoch === installedEpoch) return; throw new LocalError(`authorization_epoch ${tokenEpoch} is not the installed epoch ${installedEpoch}; the authorization was issued under a superseded epoch`, "checking authorization epoch", { code: "AUTHORIZATION_EPOCH_STALE", details: { token_authorization_epoch: tokenEpoch, installed_authorization_epoch: installedEpoch }, next_actions: [{ action: "re-request authorization under the current epoch (redeploy)" }] }); } /** Verify a minted activation token binds exactly this deploy (before it is ever presented to a commit). */ export function checkActivationTokenBinding(token, expected) { const problems = []; if (token.object_kind !== "activation_token" || !/^ct_[0-9a-f]{32}$/.test(token.object_id)) problems.push("object_id"); for (const k of ["repo_id", "operation_id", "generation", "head_sha256", "capture_id", "apply_plan_sha256", "snapshot_oid_hmac"]) { if (token[k] !== expected[k]) problems.push(k); } if (!/^[0-9a-f]{32}$/.test(token.authorization_epoch)) problems.push("authorization_epoch"); return problems; } function summarize(e) { if (isRun402Error(e)) { const err = e; return { code: err.code ?? "UNKNOWN", message: err.message, retryable: Boolean(err.retryable), ...(err.details !== undefined ? { details: err.details } : {}) }; } return { code: "UNKNOWN", message: e instanceof Error ? e.message : String(e), retryable: false }; } function code(e) { return isRun402Error(e) ? e.code : undefined; } /** * D193's `EPOCH_ROTATION_REQUIRED` names WHICH of three independent causes * is outstanding via boolean flags (`migration_required` / * `revocation_outstanding` / `exposure_outstanding` — any subset, never * mutually exclusive); this decodes them into the exact `repos access` * command that remedies each one. Shared with the namespace-level `push()` * enrichment (`repos.ts#enrichEpochRotationRequired`) — same three * causes, same three commands, one place either could drift from. */ export function epochRotationRequiredNextActions(e) { const details = isRun402Error(e) ? (e.details ?? {}) : {}; const out = []; if (details.migration_required) out.push({ action: "run402 repos access repair", why: "this vault predates rev-42 epoch rotation and must complete one first-ever rotation (owner + step-up)" }); if (details.revocation_outstanding) out.push({ action: "run402 repos access revoke-key <principal_id>", why: "an org membership removal or key revocation is outstanding for this vault; a surviving writer's push completes a membership removal automatically (reason member_removed), so this push was refused because that rotation could not run here — an owner can rotate explicitly with this command (owner + step-up)" }); if (details.exposure_outstanding) out.push({ action: "run402 repos access declare-exposure", why: "this vault's own epoch secret has been declared exposed (owner + step-up)" }); return out; } /** * The correspondence refusal. CLIENT-LOCAL by design: the moved tree is * detected before any plan is * committed, so this code never crosses the wire and is deliberately absent * from the protocol's frozen §11 wire registry. The accepted cost is * discoverability — which is why it is named here, in the client's own error * surface, rather than left to be found by grep. */ export const SNAPSHOT_MOVED_DURING_DEPLOY = "SNAPSHOT_MOVED_DURING_DEPLOY"; function driftSummary(drift) { const parts = []; const name = (label, paths) => { if (paths.length === 0) return; const shown = paths.slice(0, 5).join(", "); parts.push(`${label} ${paths.length > 5 ? `${shown}, …and ${paths.length - 5} more` : shown}`); }; name("modified", drift.modified); name("added", drift.added); name("removed", drift.removed); return parts.join("; "); } /** * Re-derive the captured-set digest and refuse if it moved. Called before ANY * plan commit — including the override path, and before its journal is written, * so a refusal never leaves an advisory for an operation that never committed. */ async function assertCapturedSetUnchanged(snapshot, context) { const now = await deriveCapturedSet({ top_level: snapshot.top_level, global_excludes_path: snapshot.global_excludes_path }); const drift = diffCapturedSets(snapshot.captured, now); if (capturedSetUnchanged(drift)) return; const details = { ...drift, vault_commit: snapshot.oid, capture_id: context.capture_id, captured_digest: snapshot.captured_digest, observed_digest: capturedSetDigest(now), ...(context.generation !== undefined ? { generation: context.generation } : {}), }; throw new LocalError(`the captured source changed between capture and commit (${driftSummary(drift)}); refusing to activate artifacts the vaulted snapshot ${snapshot.oid} does not describe`, "verifying snapshot correspondence", { code: SNAPSHOT_MOVED_DURING_DEPLOY, details, next_actions: [ { action: "redeploy — the next run captures the tree as it is now" }, { action: "if the change was not yours, inspect the named paths before redeploying" }, ], }); } /** * Run the vault-gated deploy. Throws ONLY for refusals that precede any lane * (snapshot refusals such as `SNAPSHOT_CONFLICTED_INDEX`, unsupported * repositories), for `SNAPSHOT_MOVED_DURING_DEPLOY` (the tree moved under the * capture; nothing is committed), and for `OVERRIDE_NOT_AUTHORIZED`; every * other path resolves to one of the five outcomes. */ export async function runVaultDeploy(options) { const { vault, lane } = options; const now = options.now ?? (() => new Date()); const repo = vault.repoFile(); const kRepo = hexToBytes(repo.k_repo_hex); // 1. snapshot — refusals propagate; nothing has been sent anywhere. const snapshot = await captureSnapshot({ dir: options.repo_dir, ...(options.snapshot ?? {}) }); const commitLine = vaultCommitLine(snapshot); options.onCommitLine?.(commitLine); // 2. fresh capture id before both lanes; the keyed snapshot commitment (the platform never sees the oid). const captureId = options.capture_id ?? newHex32(); const snapshotOidHmac = snapshotCommitment(kRepo, vault.repoId, repo.epoch, snapshot.oid); const base = (fields) => ({ outcome: "DEPLOY_FAILED_UNVAULTED", vault_commit: snapshot.oid, vault_commit_line: commitLine, snapshot, capture_id: captureId, snapshot_oid_hmac: snapshotOidHmac, apply_plan_sha256: null, operation_id: null, next_actions: [], ...fields, }); // 3. the deploy lane collects artifacts from the work tree; step 5 below // verifies the captured set did not move while it did so. let plan = null; let planError = null; const planPromise = (async () => { try { plan = await lane.plan({ source_dir: snapshot.top_level, capture_id: captureId, snapshot_oid_hmac: snapshotOidHmac }); } catch (e) { planError = e; } })(); // 4. push lane — concurrently; the head's binding is resolved at sign time from whatever the plan lane produced. let published = null; let pushError = null; const pushPromise = (async () => { try { const current = await vault.materialize(); published = await vault.push({ transaction: deployRefTransaction(current.refs, snapshot.oid), head_target: snapshot.head, protocol_refs: "allow", capture_binding: async () => { await planPromise; // the digest (or its absence) must be known before the head is signed return captureBinding(captureId, plan ? plan.apply_plan_sha256 : null, snapshotOidHmac); }, }); } catch (e) { pushError = e; } })(); await Promise.all([planPromise, pushPromise]); const planned = plan; const pub = published; const common = base({ apply_plan_sha256: planned?.apply_plan_sha256 ?? null, operation_id: planned?.operation_id ?? null }); // 5. outcomes if (!pub) { const push_error = summarize(pushError); if (!planned) { return { ...common, outcome: "DEPLOY_FAILED_UNVAULTED", push_error, deploy_error: summarize(planError), next_actions: [{ action: "fix the build, then redeploy" }, { action: "retry the push (run402 repos capture)" }] }; } if (options.allow_unvaulted) { // Correspondence is checked BEFORE the journal is written: an override // still activates artifacts, and a refusal must not leave an advisory // for an operation that never committed. await assertCapturedSetUnchanged(snapshot, { capture_id: captureId }); // journal BEFORE the commit so a crash leaves the advisory + the exact snapshot to resume const journal = { version: 1, repo_id: vault.repoId, operation_id: planned.operation_id, capture_id: captureId, apply_plan_sha256: planned.apply_plan_sha256, snapshot: { ref: VAULT_DEPLOY_REF, oid: snapshot.oid, head_target: snapshot.head }, snapshot_oid_hmac: snapshotOidHmac, publication_state: "pending", generation: null, head_sha256: null, capture_receipt: null, reason: options.allow_unvaulted.reason, created_at: formatVaultTimestamp(now()), updated_at: formatVaultTimestamp(now()), }; writeOverrideJournal(vault.keystore, journal); let commit; try { commit = await lane.commit({ plan_id: planned.plan_id, operation_id: planned.operation_id, allow_unvaulted: options.allow_unvaulted }); } catch (e) { if (code(e) === "OVERRIDE_NOT_AUTHORIZED") { rmSync(overrideJournalPath(vault.keystore, planned.operation_id), { force: true }); // nothing activated; no advisory to drain throw e; } return { ...common, outcome: "DEPLOY_FAILED_UNVAULTED", push_error, deploy_error: summarize(e), next_actions: [{ action: "retry the push (run402 repos capture) — the override journal is kept and will drain" }, { action: "retry the deploy" }] }; } return { ...common, outcome: "DEPLOYED_UNVAULTED_OVERRIDE", push_error, override_journal: journal, commit, next_actions: [{ action: "run any run402 CLI command later: the override journal drains by pushing this exact snapshot and presenting its capture receipt for completion" }] }; } return { ...common, outcome: "DEPLOY_BLOCKED_PUSH_FAILED", push_error, previous_release_keeps_serving: true, next_actions: [ { action: "retry the deploy (the vault push is retried; the previous release keeps serving)" }, { action: "deploy with allow_unvaulted to activate without a vaulted capture (journaled, completed later)", requires: "gitvault.override_unvaulted" }, // D193, rev 42: the push refused because this vault requires a // rotate_epoch admission first — name the exact owner-driven remedy // (never attempted automatically here: the two D194 counters a // rotation must be fenced against have no client-visible read for // ANY of the three causes below, so a blind retry inside the deploy // lane would only ever fail — see Vault.rotateEpoch's doc // comment for the confirmed gateway gap). ...(code(pushError) === "EPOCH_ROTATION_REQUIRED" ? epochRotationRequiredNextActions(pushError) : []), ], }; } const vaulted = { generation: pub.generation, head_sha256: pub.head_sha256, capture_receipt: pub.capture_receipt }; if (!planned) { return { ...common, outcome: "DEPLOY_FAILED_VAULTED", ...vaulted, deploy_error: summarize(planError), next_actions: [{ action: "fix the build, then redeploy (the attempted tree is in the vault at generation " + pub.generation + ")" }] }; } if (!pub.capture_receipt) { return { ...common, outcome: "DEPLOY_FAILED_VAULTED", ...vaulted, deploy_error: { code: "VAULT_CAPTURE_RECEIPT_MISSING", message: "admission returned no capture receipt for a head carrying a capture binding; no activation token can be exchanged", retryable: true }, next_actions: [{ action: "retry the deploy" }] }; } // Correspondence, before the token is even minted: an unspent token is // harmless, but refusing after the mint would leave one bound to a deploy // that can never legitimately present it. await assertCapturedSetUnchanged(snapshot, { capture_id: captureId, generation: pub.generation }); let token; try { token = await vault.transport.exchangeActivationToken({ repo_id: vault.repoId, operation_id: planned.operation_id, capture_receipt: pub.capture_receipt }); const problems = checkActivationTokenBinding(token, { repo_id: vault.repoId, operation_id: planned.operation_id, generation: pub.generation, head_sha256: pub.head_sha256, capture_id: captureId, apply_plan_sha256: planned.apply_plan_sha256, snapshot_oid_hmac: snapshotOidHmac }); if (problems.length > 0) throw new LocalError(`the minted activation token does not bind this deploy (${problems.join(", ")}); refusing to present it`, "checking activation token", { code: "VAULT_TOKEN_BINDING_MISMATCH", details: { problems } }); // the mint validates the receipt's epoch against the installed one, so a minted token's epoch // must equal the receipt's — a difference means the epoch moved under us. checkAuthorizationEpoch(token.authorization_epoch, pub.capture_receipt.authorization_epoch); } catch (e) { return { ...common, outcome: "DEPLOY_FAILED_VAULTED", ...vaulted, deploy_error: summarize(e), next_actions: [{ action: code(e) === "AUTHORIZATION_EPOCH_STALE" ? "re-request authorization under the current epoch (redeploy)" : "retry the deploy" }] }; } let commit; try { commit = await lane.commit({ plan_id: planned.plan_id, operation_id: planned.operation_id, activation_token: token }); } catch (e) { return { ...common, outcome: "DEPLOY_FAILED_VAULTED", ...vaulted, deploy_error: summarize(e), next_actions: [{ action: "inspect the apply operation, fix, redeploy (the attempted tree is vaulted)" }] }; } return { ...common, outcome: "DEPLOYED_AND_VAULTED", ...vaulted, capture_receipt: pub.capture_receipt, activation_token: token, commit, next_actions: [] }; } /** * Drain every pending override journal for this vault: push the journaled * snapshot (same capture binding), verify the receipt against EVERY journaled * field + the read-back head hash, present it for completion. Partial matches * never clear; the advisory persists until equality. */ export async function drainOverrideJournals(vault, options = {}) { const now = options.now ?? (() => new Date()); const report = { completed: [], remaining: [] }; for (const journal of listPendingOverrideJournals(vault.keystore, vault.repoId)) { try { let j = journal; if (j.publication_state === "pending") { const current = await vault.materialize(); const pub = await vault.push({ transaction: deployRefTransaction(current.refs, j.snapshot.oid), head_target: j.snapshot.head_target, protocol_refs: "allow", capture_binding: captureBinding(j.capture_id, j.apply_plan_sha256, j.snapshot_oid_hmac), }); j = { ...j, publication_state: "published", generation: pub.generation, head_sha256: pub.head_sha256, capture_receipt: pub.capture_receipt, updated_at: formatVaultTimestamp(now()) }; writeOverrideJournal(vault.keystore, j); } if (!j.capture_receipt || !j.head_sha256) throw new LocalError("the journaled publication has no capture receipt to present", "draining override journal", { code: "VAULT_CAPTURE_RECEIPT_MISSING" }); const match = matchCaptureReceipt(j.capture_receipt, { repo_id: j.repo_id, capture_id: j.capture_id, apply_plan_sha256: j.apply_plan_sha256, snapshot_oid_hmac: j.snapshot_oid_hmac, head_sha256: j.head_sha256 }); if (!match.equal) throw new LocalError(`the capture receipt does not match the journaled operation on ${match.mismatched_fields.join(", ")}; the advisory persists`, "draining override journal", { code: "VAULT_OVERRIDE_COMPLETION_MISMATCH", details: match }); const res = await vault.transport.submitOverrideCompletion({ repo_id: j.repo_id, operation_id: j.operation_id, capture_receipt: j.capture_receipt }); if (!res.cleared) throw new LocalError("the platform did not clear the override advisory", "draining override journal", { code: "VAULT_OVERRIDE_COMPLETION_REFUSED" }); j = { ...j, publication_state: "completed", updated_at: formatVaultTimestamp(now()) }; writeOverrideJournal(vault.keystore, j); report.completed.push(j); } catch (e) { report.remaining.push({ journal, error: summarize(e) }); } } return report; } //# sourceMappingURL=vault-deploy.js.map