run402 4.59.0 → 4.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,867 @@
1
+ /**
2
+ * The HOST-AGNOSTIC remote-helper session (gitvault-persistent-helper D1).
3
+ *
4
+ * Extracted verbatim from `cli/git-remote-run402.mjs` so ONE implementation
5
+ * serves two hosts: the in-process fallback (the thin bin imports this and
6
+ * runs it with the real process's stdio) and the resident daemon
7
+ * (`cli/lib/gitvault-daemon.mjs`, which forwards a client's session into it).
8
+ * Per-invocation state — wallet resolution, repository resolution, env-
9
+ * derived config — is resolved fresh per `runHelperSession` call; the only
10
+ * thing that persists across sessions in a daemon is warm code and warm
11
+ * connections. stdout/stderr go through `process.stdout`/`process.stderr`
12
+ * writes, which the daemon host redirects for the session's duration; stdin
13
+ * is injected (`sessionStdin`) because a daemon session's input arrives over
14
+ * the socket.
15
+ *
16
+ * The original file-level doctrine follows, unchanged:
17
+ *
18
+ /**
19
+ * `git-remote-run402` — the git remote helper for gitvault, so plain
20
+ * `git clone|fetch|push run402::<org_id>/<project_id>` speaks to a host-blind
21
+ * encrypted vault with no run402-specific git ceremony.
22
+ *
23
+ * ARCHITECTURAL LAW (gitvault-client-surface, "All protocol logic lives in the
24
+ * SDK"): this file is a THIN ADAPTER over `r.gitvault`. It translates git's
25
+ * remote-helper wire protocol into SDK calls and back, and does nothing else.
26
+ * No crypto, no HTTP, no ref-policy decisions, no pack building — those live
27
+ * once in `@run402/sdk`. Even git itself is invoked only through the SDK's own
28
+ * `hardenedGit` (hooks, fsmonitor, replace-refs and filter autodetection are
29
+ * neutralized there), so this helper hand-rolls no git behaviour either.
30
+ *
31
+ * PROTOCOL SURFACE (gitremote-helpers(1)). Implemented:
32
+ * capabilities → advertises fetch, push, option
33
+ * list [for-push] → the vault's canonical ref map + the HEAD target
34
+ * fetch <sha1> <name> → restore the vault's object database into this repo
35
+ * push [+]<src>:<dst> → publish one atomic ref transaction
36
+ * option <name> <value> → ok / unsupported, never a silent lie
37
+ *
38
+ * LAZY ALLOCATION ON FIRST PUSH (design D2). `list` never creates anything —
39
+ * an unallocated vault reports as an empty ref set, exactly what a fresh
40
+ * repository looks like — but `push` does: `git push origin main` against a
41
+ * project whose vault does not exist yet runs the six-stage creation journal
42
+ * inline (`r.gitvault.openOrCreate`, the SDK-owned primitive), prints the
43
+ * one-shot recovery receipt and the keystore path to stderr, and then
44
+ * completes the push. One command, no prior `gitvault init`. `git ls-remote`
45
+ * / `fetch` stay pure reads and allocate nothing.
46
+ *
47
+ * NAMED ADDRESSING + PUSH-TO-CREATE (repo-first-onramp task 4, design D6).
48
+ * `run402::<org>/<name>` admits TWO forms in the same slot — id-form
49
+ * (`org_id`/`prj_...`, unchanged: resolved via `r.gitvault.openOrCreate`
50
+ * above) and slug-form (`run402::<org-slug>/<name>`, e.g.
51
+ * `run402::acme/my-notes`) — discriminated by
52
+ * `gitvaultRemoteAddressForm`. A slug-form remote resolves through
53
+ * `r.gitvault.resolveOrCreateAddress`, which ALSO drives push-to-create on a
54
+ * miss (`push` only; `list`/`fetch` pass `allow_create: false`, same "reads
55
+ * never allocate" discipline as the id-form path) and PINS the resolved
56
+ * `repo_id` in this checkout's local git config the first time it resolves
57
+ * (task 4.5) — every later invocation on THIS checkout goes straight to the
58
+ * pinned id, skipping the address resolution round-trip entirely and
59
+ * surviving a later rename of either half. `SLUG_RELEASED` is never
60
+ * auto-followed: it refuses, naming the successor slug.
61
+ *
62
+ * WHICH REPOSITORY (the fail-closed rule). `process.cwd()` is NOT the
63
+ * repository. git identifies the repository with `GIT_DIR`, and during
64
+ * `git clone` cwd is the directory clone was RUN FROM — routinely some other,
65
+ * unrelated repository. Discovering the repository from cwd therefore wrote a
66
+ * vault's DECRYPTED objects into a repository the user never named, silently,
67
+ * on every clone (dogfood #1). Every repository-touching command now resolves
68
+ * through the SDK's `resolveGitInvocationRepo`, which proves `GIT_DIR` names a
69
+ * real repository and refuses otherwise; a refusal writes nothing at all.
70
+ * `capabilities`, `option` and `list` need no repository and are unaffected,
71
+ * so a repository-free `git ls-remote run402::<org>/<project>` still works.
72
+ *
73
+ * NOT advertised, deliberately: `list` is a COMMAND in this protocol, not a
74
+ * capability keyword — git's capability vocabulary is fetch/push/import/export/
75
+ * connect/stateless-connect/option/refspec/check-connectivity/object-format/
76
+ * signed-tags/bidi-import/get. Advertising `list` would be a line git silently
77
+ * discards; the command itself is implemented above. `connect`,
78
+ * `stateless-connect`, `import`, and `export` are NOT implemented: the vault is
79
+ * not a git-protocol endpoint you can tunnel to, and pretending otherwise would
80
+ * hand git a transport that cannot answer.
81
+ *
82
+ * KNOWN LIMITS, stated rather than papered over:
83
+ * - `fetch` restores the vault's object database WHOLESALE. The SDK exposes
84
+ * no per-ref object selection, so one batch = one full restore. That is a
85
+ * superset of what git asked for (git writes the refs itself from `list`),
86
+ * never a subset — but it is not incremental.
87
+ * - `push` REPAIRS a DANGLING vault HEAD and otherwise never moves it
88
+ * (kychee-com/run402#568). A fresh vault defaults its HEAD symref to
89
+ * `refs/heads/main`; before this fix, a first push of any OTHER branch
90
+ * left that symref naming a ref that would never exist, and the first
91
+ * `git clone` warned "remote HEAD refers to nonexistent ref" and checked
92
+ * out an EMPTY tree — publishing landed, but nothing was reachable from
93
+ * it. Now: when the vault's current HEAD target is unset, or is a
94
+ * symref naming a ref this push's own batch does not leave present, the
95
+ * helper points it at one of the branches THIS push is publishing — the
96
+ * local repository's own HEAD branch when it is among them, else the
97
+ * first branch in the batch — and prints a one-line stderr note saying
98
+ * which and why (see `chooseGitvaultHeadTargetForPush`). A HEALTHY HEAD
99
+ * (one that already names a ref this push leaves present) is NEVER
100
+ * touched — push moving history never means push moving HEAD.
101
+ * - `option dry-run true` (kychee-com/run402#565) runs the REAL local
102
+ * pipeline — pack building, encryption sizing, via `vault.planPush` — and
103
+ * reports the per-ref `ok` lines a real push would, plus a stderr summary
104
+ * (objects, encrypted bytes, refs, the generation it would admit as,
105
+ * whether allocation would be needed). It never uploads or admits, and a
106
+ * push-to-create dry run never allocates. Still honestly refuses
107
+ * anything git's own dry-run negotiation would also refuse (e.g. a
108
+ * non-fast-forward update) — reporting a fake `ok` would be worse than
109
+ * refusing, which is why this was `unsupported` until it could be real.
110
+ * - `fetch` and `push` REQUIRE the `GIT_DIR` git sets when it drives a
111
+ * helper against a repository, so running this binary by hand from a shell
112
+ * is refused rather than silently pointed at the current directory. Only
113
+ * `capabilities`, `option` and `list` work without one, which is exactly
114
+ * the set `git ls-remote <url>` outside a checkout needs.
115
+ */
116
+
117
+ import { createInterface } from "node:readline";
118
+
119
+ // The heavy graphs load as ONE top-level await batch — in the in-process
120
+ // host this races the thin bin's already-fired prewarm; in the daemon host
121
+ // it happens once at daemon boot (gitvault-startup-amortization D1).
122
+ const sdkModP = import("./sdk.mjs");
123
+ const walletModP = import("./wallet-context.mjs");
124
+ const isoModP = import("#sdk");
125
+ const nodeModP = import("#sdk/node");
126
+ const { getSdk } = await sdkModP;
127
+ const { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } = await walletModP;
128
+ const { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } = await isoModP;
129
+ const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo } = await nodeModP;
130
+
131
+ /** The session's input stream — injected per session (daemon: the socket's forwarded stdin). */
132
+ let sessionStdin = process.stdin;
133
+
134
+ const out = (line) => process.stdout.write(`${line}\n`);
135
+ /** Every helper response block is terminated by a blank line. */
136
+ const endBlock = () => process.stdout.write("\n");
137
+ const note = (line) => process.stderr.write(`git-remote-run402: ${line}\n`);
138
+
139
+ /**
140
+ * Wallet selection (kychee-com/run402#558). Before this, this file called
141
+ * `getSdk()` directly and ran NO wallet selection at all — a `.run402.json`
142
+ * binding, and even the global `wallets use` default, silently never
143
+ * reached it; only the `RUN402_WALLET` env layer worked, so a bound
144
+ * checkout's very next `git push run402 main` after a correctly-bound
145
+ * `run402 repos create` used the WRONG wallet's (usually empty) allowance.
146
+ *
147
+ * Shares `resolveWalletCore`/`enforceWalletExistsCore` with the CLI
148
+ * (`cli/lib/wallet-context.mjs`) — ONE implementation, minus the CLI's
149
+ * `--wallet` flag layer (this binary parses no argv flags at all). Resolved
150
+ * and applied (`process.env.RUN402_WALLET`) once per invocation, right
151
+ * before the first credential-touching call — never for `capabilities` /
152
+ * `option`, which touch neither credentials nor the network.
153
+ *
154
+ * WHICH DIRECTORY the binding walk starts from is NOT uniform, for the same
155
+ * fail-closed reason this file's own header explains for repository
156
+ * resolution: `list` needs no repository (a repository-free `git ls-remote`
157
+ * outside any checkout must keep working), so it walks from `process.cwd()`.
158
+ * `fetch`/`push` DO have a resolved repository by the time wallet selection
159
+ * runs (`requireRepo()` already succeeded) — walking from ITS directory
160
+ * rather than cwd is what makes `git clone` (cwd = wherever clone was RUN
161
+ * FROM, not the target repo) pick up a binding committed in the target
162
+ * repository, not whatever checkout happened to be current.
163
+ */
164
+ let resolvedWallet = null;
165
+
166
+ function applyWalletForDir(dir) {
167
+ const resolved = resolveWalletCore({ env: process.env, cwd: dir });
168
+ enforceWalletExistsCore(resolved);
169
+ process.env.RUN402_WALLET = resolved.name;
170
+ resolvedWallet = resolved;
171
+ return resolved;
172
+ }
173
+
174
+ /** The resolved wallet's selection source, in the same words the CLI's own `--wallet` provenance line uses. `null` for the bare, unselected default. */
175
+ function walletSourceLabel(resolved) {
176
+ if (!resolved) return null;
177
+ if (resolved.source === "env") return "RUN402_WALLET";
178
+ if (resolved.source === "binding") return resolved.sourceDetail; // the .run402.json path
179
+ if (resolved.source === "config") return "wallets use";
180
+ return null; // "default" — nothing selected anything
181
+ }
182
+
183
+ /**
184
+ * The allowance-missing/malformed family (`core/src/allowance.ts`'s own
185
+ * throws) all end with "Back up the file and run 'run402 init' to recreate
186
+ * it." — a remedy that assumes the resolved wallet is the one you meant.
187
+ * That is only true when NOTHING selected a wallet (the bare default); when
188
+ * an env var or a binding DID name one, the remedy is actively harmful —
189
+ * `run402 init` recreates the DEFAULT wallet's allowance, a DIFFERENT
190
+ * wallet than the one that was actually resolved and whose allowance is
191
+ * actually missing/broken (kychee-com/run402#558's second defect). Replace
192
+ * it with the resolved wallet's name and how selection works, so the fix is
193
+ * "correct the selection" rather than "recreate the wrong wallet".
194
+ */
195
+ function enrichAllowanceError(message) {
196
+ if (!resolvedWallet || !/allowance\.json/.test(message)) return message;
197
+ const source = walletSourceLabel(resolvedWallet);
198
+ const stripped = message.replace(/\s*Back up the file and run 'run402 init' to recreate it\.?/, "").trim();
199
+ if (!source) {
200
+ // Genuinely the bare default wallet with no override anywhere — the
201
+ // original remedy already names the right target.
202
+ return `${stripped} Back up the file and run 'run402 init' to recreate it.`;
203
+ }
204
+ return (
205
+ `${stripped} Resolved wallet '${resolvedWallet.name}' via ${source} ` +
206
+ "(order: RUN402_WALLET env > .run402.json binding > 'wallets use' default > default). " +
207
+ `Wrong wallet? Fix selection instead. Right wallet, just no allowance yet? 'run402 wallets new ${resolvedWallet.name}'.`
208
+ );
209
+ }
210
+
211
+ function describeError(err) {
212
+ const code = err?.code ?? err?.body?.code ?? null;
213
+ const message = enrichAllowanceError(err?.message ?? err?.body?.message ?? String(err));
214
+ // SLUG_RELEASED is never auto-followed — but the successor slug (design D6)
215
+ // is exactly the fact a human/agent reading stderr needs to act on it.
216
+ const released = gitvaultSlugReleasedInfo(err);
217
+ const suffix = released?.successor_slug ? ` (renamed to "${released.successor_slug}" — update the remote and re-run)` : "";
218
+ return oneLine(code ? `${code}: ${message}${suffix}` : message);
219
+ }
220
+
221
+ /** Protocol lines are single-line: collapse anything that could break framing. */
222
+ function oneLine(value) {
223
+ return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
224
+ }
225
+
226
+ /**
227
+ * Resolve the vault address from git's argv.
228
+ *
229
+ * Git invokes `git-remote-<transport> <remote> <url>`, and for a
230
+ * `<transport>::<address>` URL it passes the BARE `<address>` as the second
231
+ * argument — the `run402::` prefix is already stripped. The SDK's parser is the
232
+ * only thing that understands the address grammar, so both spellings are handed
233
+ * to it rather than re-implemented here.
234
+ */
235
+ function resolveRemoteAddress(argv) {
236
+ for (const raw of [argv[1], argv[0]]) {
237
+ if (typeof raw !== "string" || raw.length === 0) continue;
238
+ const direct = parseGitvaultRemoteUrl(raw);
239
+ if (direct) return direct;
240
+ // Re-add the prefix ONLY for a bare, colon-free address. Without this
241
+ // guard `https://example.com/x` parses as org `https:` / project
242
+ // `/example.com/x`, and `git@github.com:x/y.git` as org `git@github.com:x`
243
+ // — a confidently wrong answer is worse than no answer here. The colon is
244
+ // git's own URL punctuation, never part of a bare `<org_id>/<project_id>`.
245
+ if (raw.includes(":")) continue;
246
+ const prefixed = parseGitvaultRemoteUrl(`run402::${raw}`);
247
+ if (prefixed) return prefixed;
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /** `[+]<src>:<dst>` — an empty `<src>` is a deletion. */
253
+ function parsePushSpec(spec) {
254
+ const forced = spec.startsWith("+");
255
+ const body = forced ? spec.slice(1) : spec;
256
+ const colon = body.indexOf(":");
257
+ if (colon === -1) return { src: body, dst: body, force: forced };
258
+ return { src: body.slice(0, colon), dst: body.slice(colon + 1), force: forced };
259
+ }
260
+
261
+ /** `R402_PROTECTED_REF_NAMESPACE`'s one-line reason, shared by the protocol `error` line and the stderr note. */
262
+ export const R402_PROTECTED_REF_NAMESPACE_REASON = `R402_PROTECTED_REF_NAMESPACE: ${GITVAULT_R402_REF_NAMESPACE}* is client-local bookkeeping maintained by fetch, not push — push branches instead.`;
263
+
264
+ /**
265
+ * D4: `refs/r402/*` is client-local (design D1/D2's local ref bookkeeping) —
266
+ * never advertised for push, never part of a `GitvaultRefTransaction`. Split
267
+ * one push batch into the specs this helper will actually publish and the
268
+ * ones it refuses, PURE and pre-repository (a refname prefix check needs no
269
+ * repo, no network, no wallet), so a batch made ENTIRELY of protected refs
270
+ * costs nothing beyond parsing, and a MIXED batch still lets its unrelated
271
+ * branch updates proceed (client-surface spec's own scenario) — this is why
272
+ * the split happens here, before `vault.push`'s one all-or-nothing
273
+ * transaction is ever built, rather than inside the SDK's existing (whole-
274
+ * transaction-refusing) `refs/run402/*` guard.
275
+ */
276
+ export function partitionProtectedRefPushes(specs) {
277
+ const refused = [];
278
+ const allowed = [];
279
+ for (const spec of specs) {
280
+ if (spec.dst.startsWith(GITVAULT_R402_REF_NAMESPACE)) refused.push(spec);
281
+ else allowed.push(spec);
282
+ }
283
+ return { refused, allowed };
284
+ }
285
+
286
+ /**
287
+ * Decide whether THIS push must repair a DANGLING vault HEAD, and to what
288
+ * (kychee-com/run402#568 — the first-clone empty-tree hazard). The rule:
289
+ *
290
+ * WHEN the vault's current materialized HEAD target is absent, OR is a
291
+ * symref naming a ref this push's own batch does not leave present, set
292
+ * `head_target` to one of the branches THIS push is publishing — this
293
+ * repository's own HEAD branch when it is among them, else the first
294
+ * branch in the batch (git's own order) — and say which, and why, in a
295
+ * one-line note. No silent magic.
296
+ *
297
+ * WHEN HEAD is already set and healthy (a symref naming a ref this push
298
+ * leaves present, or a detached target), it is NEVER touched — push
299
+ * moving history never means push moving HEAD. That stays the documented
300
+ * rule (`vault.push`'s own `head_target ?? base.head_target` carry-forward
301
+ * already guarantees this at the SDK layer; this function just decides
302
+ * WHEN to override that default).
303
+ *
304
+ * Pure — no I/O, no git, no network — so it is unit-testable directly.
305
+ * `updates` is this push's own ref-transaction updates (`{ ref, new_oid }`,
306
+ * `new_oid: null` for a deletion); `baseRefs`/`baseHeadTarget` are what the
307
+ * vault materialized BEFORE this push; `localHeadRef` is this repository's
308
+ * own HEAD branch (`refs/heads/<name>`), or `null` when detached/unknown.
309
+ *
310
+ * Returns `{ head_target: undefined }` (never publish an override — the SDK
311
+ * carries the base forward) when HEAD needs no repair, or when this batch
312
+ * has no branch update to repair it WITH (a tags-only or deletion-only
313
+ * batch cannot fix a dangling HEAD by itself).
314
+ */
315
+ export function chooseGitvaultHeadTargetForPush({ baseHeadTarget, baseRefs, updates, localHeadRef }) {
316
+ const postPushRefs = { ...(baseRefs ?? {}) };
317
+ for (const u of updates) {
318
+ if (u.new_oid === null) delete postPushRefs[u.ref];
319
+ else postPushRefs[u.ref] = u.new_oid;
320
+ }
321
+
322
+ const dangling =
323
+ !baseHeadTarget ||
324
+ (baseHeadTarget.kind === "symref" && !Object.prototype.hasOwnProperty.call(postPushRefs, baseHeadTarget.ref));
325
+ if (!dangling) return { head_target: undefined, note: null };
326
+
327
+ const pushedBranches = updates.filter((u) => u.new_oid !== null && u.ref.startsWith("refs/heads/")).map((u) => u.ref);
328
+ if (pushedBranches.length === 0) return { head_target: undefined, note: null };
329
+
330
+ const localIsPushed = Boolean(localHeadRef) && pushedBranches.includes(localHeadRef);
331
+ const chosen = localIsPushed ? localHeadRef : pushedBranches[0];
332
+ const why = localIsPushed
333
+ ? "this repository's own HEAD branch"
334
+ : pushedBranches.length > 1
335
+ ? `the first of ${pushedBranches.length} branches pushed in this batch`
336
+ : "the branch this push publishes";
337
+ const priorState = baseHeadTarget ? `dangling (named '${baseHeadTarget.ref}', which this push does not publish)` : "unset";
338
+ const note = `vault HEAD was ${priorState} — setting it to '${chosen}' (${why}). A healthy HEAD is never moved by push.`;
339
+ return { head_target: { kind: "symref", ref: chosen }, note };
340
+ }
341
+
342
+ async function main(argv) {
343
+ // gitvault-connection-amortization (bench P5) note: the prewarm now fires
344
+ // at the module TOP, before the SDK graph loads (gitvault-startup-
345
+ // amortization D1) — connection dial and signer warmup both race module
346
+ // evaluation instead of starting here.
347
+ const address = resolveRemoteAddress(argv);
348
+ if (!address) {
349
+ note(`could not read a run402 remote address from ${JSON.stringify(argv.join(" "))} — expected run402::<org_id>/<project_id>`);
350
+ return 1;
351
+ }
352
+
353
+ // `org_id` rides in the parsed address (`run402::<org_id>/<project_id>`),
354
+ // so it costs nothing extra to carry — it is exactly what D2's lazy
355
+ // creation needs to allocate an unresolved vault from `runPush` below, with
356
+ // no separate lookup. Only meaningful for an ID-FORM address; a slug-form
357
+ // one resolves through `resolveOrCreateAddress` instead (below), which
358
+ // needs no separate org_id at all — the gateway resolves the slug itself.
359
+ const addressForm = gitvaultRemoteAddressForm(address);
360
+ const target = { project_id: address.project_id, org_id: address.org_id };
361
+ let verbosity = 1;
362
+ // kychee-com/run402#565: `option dry-run true` used to be honestly
363
+ // `unsupported` (this helper could not rehearse a publication, and
364
+ // reporting a fake success would be worse than refusing). It now IS
365
+ // real — see `handleOption`'s `dry-run` case and `runPush` below.
366
+ let dryRun = false;
367
+
368
+ /**
369
+ * The repository git invoked us for, resolved once and PROVEN.
370
+ *
371
+ * Deliberately lazy: `list` needs no repository, so `git ls-remote` outside
372
+ * any checkout keeps working. Deliberately not cached across a failure
373
+ * either — a refusal is terminal for the command that asked, and there is
374
+ * nothing to retry.
375
+ */
376
+ let resolvedRepo = null;
377
+ async function requireRepo() {
378
+ if (!resolvedRepo) resolvedRepo = await resolveGitInvocationRepo(process.env, process.cwd());
379
+ return resolvedRepo.repo_dir;
380
+ }
381
+
382
+ /**
383
+ * One materialize per push session (gitvault-client-round-trips design
384
+ * D1). Git guarantees `list` precedes `push` in the same helper process,
385
+ * so a `list` that resolves an EXISTING vault against a real repository
386
+ * stashes its vault instance + materialized base here; `runPush` reuses
387
+ * BOTH — skipping its own `openOrCreateVault` + `materialize()` entirely
388
+ * — instead of materializing the same state a second and third time.
389
+ * `null` whenever there is nothing safe to share: `list` never ran, ran
390
+ * repo-free (a bare `git ls-remote`), or found an UNALLOCATED vault
391
+ * (first-push-allocates keeps its own unchanged flow — design D7's own
392
+ * "base-sharing subtlety" risk note). Reuse also requires the SAME
393
+ * resolved repository AND the same resolved wallet as `push` is about to
394
+ * use — in ordinary usage (`push` run from inside the repo it targets)
395
+ * these always match `list`'s own resolution; the check just makes a
396
+ * mismatch (an unusual `git -C otherdir push`) fail safe into `push`'s
397
+ * original, unshared flow rather than reuse a snapshot read under a
398
+ * different identity.
399
+ */
400
+ let sharedListSession = null;
401
+
402
+ /** This repository's own HEAD branch (`refs/heads/<name>`), or `null` when detached, unborn, or unreadable — never a failure by itself. */
403
+ async function localHeadBranchRef(repoDir) {
404
+ try {
405
+ const out = (await hardenedGit(repoDir, ["symbolic-ref", "--quiet", "HEAD"])).text().trim();
406
+ return out.length > 0 ? out : null;
407
+ } catch {
408
+ return null;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * What to tell a human when we refuse. `git clone` is the case that used to
414
+ * fail; naming the working alternative beats a bare error.
415
+ */
416
+ function repoRefusalNote(err) {
417
+ note(describeError(err));
418
+ note("refusing to touch a repository git did not name — nothing was read or written.");
419
+ note(`if you meant to restore this vault: git init --bare <dir> && git -C <dir> remote add run402 run402::${address.org_id}/${address.project_id} && git -C <dir> fetch run402 '+refs/heads/*:refs/heads/*'`);
420
+ }
421
+
422
+ /** A 404/absent-vault refusal — the "nothing here yet" shape, never a genuine failure to mask. */
423
+ function isVaultNotFound(err) {
424
+ return err?.status === 404 || err?.code === "RESOURCE_NOT_FOUND" || err?.code === "ROUTE_NOT_FOUND";
425
+ }
426
+
427
+ /**
428
+ * Open the vault lazily — `capabilities` and `option` must never touch the
429
+ * network. Both address forms resolve (and, on the first successful
430
+ * resolution, PIN `repo_id` in local git state — task 4.5 for slug-form,
431
+ * gitvault-client-round-trips design D4 widening the same mechanism to
432
+ * id-form) through `gitvault.resolveOrCreateAddress` with
433
+ * `allow_create: false` — a read never allocates, and `allow_create` is
434
+ * meaningless for id-form's own dispatch anyway (it never creates,
435
+ * pinned or not). A repo-free call (`repoDir` undefined — `list` outside
436
+ * any checkout) resolves exactly as it always has, just with nothing to
437
+ * pin.
438
+ */
439
+ const openVault = async (repoDir) => {
440
+ const result = await getSdk().gitvault.resolveOrCreateAddress({ address, allow_create: false, ...(repoDir ? { repo_dir: repoDir } : {}) });
441
+ return { vault: result.handle.vault, resolution: result.resolution };
442
+ };
443
+
444
+ /**
445
+ * Open the vault, allocating it first when it does not exist yet (D2), and
446
+ * — for a SLUG-form address whose name does not resolve yet —
447
+ * PUSH-TO-CREATE it (design D6, task 4.4/4.5). Used ONLY by `runPush` —
448
+ * `list`/`fetch` stay pure reads and never create anything (see
449
+ * `runList`'s own not-found handling below).
450
+ *
451
+ * Id-form (gitvault-client-round-trips design D4): a PINNED repo_id means
452
+ * this checkout has already resolved (or pushed to) this vault before, so
453
+ * there is nothing left to allocate — the read-only, pin-aware
454
+ * `resolveOrCreateAddress` path (same one `openVault` uses) is enough,
455
+ * and cheaper than re-running the allocation-capable flow on every push.
456
+ * With NO pin yet, this is unchanged: `gitvault.openOrCreate` runs its
457
+ * six-stage creation journal when the vault does not exist, exactly as
458
+ * before — and, on success, PINS the resolved id for every later push on
459
+ * this checkout (this is the "first successful resolution" the pin exists
460
+ * for; `resolveOrCreateAddress`'s own pin-on-resolve only covers
461
+ * slug-form, since id-form's OWN allocation path — this one — never
462
+ * routes through it).
463
+ *
464
+ * Prints the one-shot recovery receipt and the keystore path to stderr the
465
+ * moment allocation happens, per the client-surface spec: an agent reads
466
+ * stderr, and the receipt is worth exactly as many copies as get kept.
467
+ */
468
+ async function openOrCreateVault(repoDir) {
469
+ if (addressForm === "id" && repoDir) {
470
+ const pinned = await readPinnedGitvaultRepo(repoDir);
471
+ if (pinned) {
472
+ const result = await getSdk().gitvault.resolveOrCreateAddress({ address, repo_dir: repoDir, allow_create: false });
473
+ return result.handle.vault;
474
+ }
475
+ }
476
+ const result =
477
+ addressForm === "id"
478
+ ? await getSdk().gitvault.openOrCreate({ ...target, repo_dir: repoDir })
479
+ : await getSdk().gitvault.resolveOrCreateAddress({ address, repo_dir: repoDir, allow_create: true });
480
+ if (addressForm === "id" && repoDir) await pinGitvaultRepo(repoDir, result.handle.repo_id, undefined, { project_id: target.project_id, org_id: target.org_id });
481
+ if (!result.found && result.created) {
482
+ note("");
483
+ note(`vault ${result.handle.repo_id} allocated (genesis ${result.created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
484
+ note(JSON.stringify(result.created.recovery_receipt));
485
+ try {
486
+ const { getGitvaultKeystoreRoot } = await import("#sdk/node");
487
+ note(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship`);
488
+ } catch {
489
+ // Never let a diagnostic line fail a push that already allocated successfully.
490
+ }
491
+ note("");
492
+ }
493
+ return result.handle.vault;
494
+ }
495
+
496
+ async function runList() {
497
+ // `list` needs no repository (a repository-free `git ls-remote` outside
498
+ // any checkout must keep working) — the binding walk falls back to cwd,
499
+ // same as `capabilities`/`option`'s repository-free tier. Wallet
500
+ // resolution is UNCHANGED (still cwd-based, never repoDir-based) — only
501
+ // the vault-open call below additionally threads a repository when one
502
+ // resolves, purely so a later `push` in this same session can reuse the
503
+ // resulting vault instance (design D1); the repo-free ls-remote case is
504
+ // unaffected (`repoDir` just stays `null`).
505
+ applyWalletForDir(process.cwd());
506
+ let repoDir = null;
507
+ try {
508
+ repoDir = await requireRepo();
509
+ } catch {
510
+ // Not resolvable as a repository (e.g. `git ls-remote` outside any
511
+ // checkout) — `list` still works, and there is nothing for `push` to
512
+ // share later in that case.
513
+ }
514
+ let vault;
515
+ let state;
516
+ try {
517
+ const opened = await openVault(repoDir ?? undefined);
518
+ vault = opened.vault;
519
+ try {
520
+ state = await vault.materialize();
521
+ } catch (err) {
522
+ // An OFFLINE (id-carrying pin) resolution discovers a stale pin on
523
+ // its FIRST repo-scoped read (client-surface spec, id-pinning
524
+ // requirement): recover once — clear the pin, re-resolve — and retry
525
+ // only when re-resolution lands on a DIFFERENT vault; a same-id
526
+ // answer means the pin was fine and the refusal below is real. git
527
+ // always runs `list` first in a helper session, so this one site
528
+ // heals the pin for the `fetch`/`push` that follows it.
529
+ const recovered = repoDir && opened.resolution?.offline
530
+ ? await getSdk().gitvault.recoverStalePin({ address, repo_dir: repoDir, resolution: opened.resolution, error: err })
531
+ : null;
532
+ if (!recovered) throw err;
533
+ note(`pinned vault ${opened.resolution.repo_id} no longer resolves — re-resolved to ${recovered.resolution.repo_id}, retrying`);
534
+ vault = recovered.handle.vault;
535
+ state = await vault.materialize();
536
+ }
537
+ } catch (err) {
538
+ // An unallocated vault is not an error here: `list` is the read half of
539
+ // the protocol dance and must never create anything on its own (D2
540
+ // scopes lazy creation to `push`). Reporting it as an EMPTY ref set is
541
+ // exactly what a fresh repository looks like to git, and `push` still
542
+ // runs `list` first either way — this is what lets a first push land in
543
+ // one command instead of `list` failing the whole exchange before
544
+ // `push` ever gets a turn. Nothing to share with `push` either
545
+ // (design D7): an unallocated vault has no base to reuse.
546
+ if (isVaultNotFound(err)) {
547
+ endBlock();
548
+ return;
549
+ }
550
+ throw err;
551
+ }
552
+ if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, base: state };
553
+ const refs = state.refs ?? {};
554
+ for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
555
+ // A snapshot-only vault holds protocol refs but no branch heads, so a
556
+ // plain `git clone` prints "cloned an empty repository" with no hint the
557
+ // history exists (blind-acceptance finding, 2026-08-28). Say where it is.
558
+ const refNames = Object.keys(refs);
559
+ if (refNames.length > 0 && !refNames.some((r) => r.startsWith("refs/heads/"))) {
560
+ note(`this vault has no branch heads yet — its history lives on ${refNames.sort()[0]}`);
561
+ note(`fetch it with: git fetch <remote> '+${refNames.sort()[0]}:${refNames.sort()[0]}' && git checkout -b restored ${refNames.sort()[0]}`);
562
+ }
563
+ const head = state.head_target;
564
+ // A symref is only advertised when its target is actually present:
565
+ // pointing HEAD at a ref that does not exist is what an empty repository
566
+ // looks like, and git reads the empty list correctly on its own.
567
+ if (head?.kind === "symref" && Object.prototype.hasOwnProperty.call(refs, head.ref)) out(`@${head.ref} HEAD`);
568
+ else if (head?.kind === "detached") out(`${head.oid} HEAD`);
569
+ endBlock();
570
+ }
571
+
572
+ async function runFetch(batch) {
573
+ // Resolve the target repository BEFORE a single byte is decrypted: a
574
+ // refusal here must leave no objects anywhere. This is what makes `clone`
575
+ // work (git names the fresh repo in `GIT_DIR`) and what stops a clone run
576
+ // from inside an unrelated checkout from writing into that checkout.
577
+ let repoDir;
578
+ try {
579
+ repoDir = await requireRepo();
580
+ } catch (err) {
581
+ repoRefusalNote(err);
582
+ return 1;
583
+ }
584
+ // The repository is resolved — walk the binding from ITS directory, not
585
+ // cwd (the "WHICH REPOSITORY" note above: during `git clone` cwd is
586
+ // wherever clone was run FROM, unrelated to the target repository).
587
+ applyWalletForDir(repoDir);
588
+ if (verbosity >= 1) note(`restoring the vault object database for ${batch.length} ref(s) into ${repoDir}`);
589
+ const restored = await getSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
590
+ if (verbosity >= 1) note(`restored generation ${restored.generation}`);
591
+ // clone-installs-retained-refs D3: a bookkeeping failure here degrades to
592
+ // exactly today's (pre-change) behavior — one stderr note, fetch still
593
+ // completes. `restored.retained_refs` is never absent (the SDK always
594
+ // returns a result, never throws for this step).
595
+ if (restored.retained_refs?.warning) note(restored.retained_refs.warning);
596
+ else if (verbosity >= 1 && (restored.retained_refs?.written.length > 0 || restored.retained_refs?.deleted.length > 0)) {
597
+ note(`refs/r402/retain: +${restored.retained_refs.written.length} -${restored.retained_refs.deleted.length} (${restored.retained_refs.retained_count} retained tip(s) total)`);
598
+ }
599
+ endBlock();
600
+ return 0;
601
+ }
602
+
603
+ async function runPush(batch) {
604
+ const specs = batch.map(parsePushSpec);
605
+ // D4: `refs/r402/*` is client-local — refuse it per-ref, BEFORE any
606
+ // repository/wallet/network work, while unrelated branch updates in the
607
+ // SAME push proceed normally (client-surface spec's own scenario).
608
+ const { refused, allowed } = partitionProtectedRefPushes(specs);
609
+ for (const spec of refused) {
610
+ note(`refusing ${spec.dst}: ${R402_PROTECTED_REF_NAMESPACE_REASON}`);
611
+ out(`error ${spec.dst} ${R402_PROTECTED_REF_NAMESPACE_REASON}`);
612
+ }
613
+ if (allowed.length === 0) {
614
+ endBlock();
615
+ return 0;
616
+ }
617
+ try {
618
+ // Repository first, then every source revision, and only then the
619
+ // network: a push that names a ref this repository does not have must
620
+ // fail locally rather than after opening the vault.
621
+ const repoDir = await requireRepo();
622
+ // Same "walk from the resolved repository, not cwd" rule as `fetch`.
623
+ applyWalletForDir(repoDir);
624
+ const newOids = new Map();
625
+ for (const spec of allowed) {
626
+ // A deletion carries an empty <src>. Everything else is resolved by
627
+ // git itself; `--end-of-options` keeps a hostile refname from being
628
+ // read as a flag.
629
+ newOids.set(spec, spec.src === ""
630
+ ? null
631
+ : (await hardenedGit(repoDir, ["rev-parse", "--verify", "--end-of-options", spec.src])).text().trim());
632
+ }
633
+
634
+ if (dryRun) {
635
+ // kychee-com/run402#565: READ-ONLY resolution — `openVault`, never
636
+ // `openOrCreateVault` — so a push-to-create dry run allocates
637
+ // NOTHING. An unresolved vault means there is nothing to preview a
638
+ // push against yet (no repo_id ⇒ no encryption key ⇒ sizing is
639
+ // genuinely unknowable, not merely unreported); still report success
640
+ // per-ref, since a real push here WOULD succeed (it would allocate
641
+ // first) — only the sizing is unavailable.
642
+ let vault;
643
+ try {
644
+ vault = (await openVault(repoDir)).vault;
645
+ } catch (err) {
646
+ if (!isVaultNotFound(err)) throw err;
647
+ note("dry-run: no vault allocated for this project yet — a real push would allocate one (push-to-create) before publishing; object/byte sizing is not knowable until then");
648
+ for (const spec of allowed) out(`ok ${spec.dst}`);
649
+ endBlock();
650
+ return 0;
651
+ }
652
+ const base = await vault.materialize();
653
+ const updates = [];
654
+ for (const spec of allowed) {
655
+ const expectedOld = base.refs?.[spec.dst] ?? null;
656
+ updates.push({
657
+ ref: spec.dst,
658
+ expected_old_oid: expectedOld,
659
+ new_oid: newOids.get(spec),
660
+ force: spec.force && expectedOld !== null,
661
+ });
662
+ }
663
+ // Same evaluation, pack building, and sealing/encryption a real push
664
+ // runs — stops before the two network mutations (upload, admit). A
665
+ // refusal here (non-fast-forward, tag immutability, ...) throws the
666
+ // SAME way a real push's would, caught below and reported as
667
+ // `error`, never a fake `ok`.
668
+ const plan = await vault.planPush({ transaction: { updates } });
669
+ note(
670
+ `dry-run: would publish generation ${plan.would_admit_generation} (${plan.would_admit_generation_decimal}, ${plan.form}) — ` +
671
+ `${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw), ` +
672
+ `${Object.keys(plan.refs).length} ref(s); no allocation needed`,
673
+ );
674
+ for (const spec of allowed) out(`ok ${spec.dst}`);
675
+ endBlock();
676
+ return 0;
677
+ }
678
+
679
+ // Design D1: reuse `list`'s vault + materialized base for this push's
680
+ // FIRST admission attempt when it resolved the SAME repository under
681
+ // the SAME wallet — skips `openOrCreateVault` and `materialize()`
682
+ // entirely instead of resolving/materializing the vault a second and
683
+ // third time in one `list → push` exchange. A conflict retry inside
684
+ // `vault.push` re-materializes from storage exactly as it always has;
685
+ // only the FIRST attempt's base changes here. Any mismatch (no prior
686
+ // `list`, an unallocated vault `list` had nothing to share for, or a
687
+ // different repository/wallet) falls back to the original flow.
688
+ const shared = sharedListSession && sharedListSession.repoDir === repoDir && sharedListSession.walletName === (resolvedWallet?.name ?? null) ? sharedListSession : null;
689
+ const vault = shared ? shared.vault : await openOrCreateVault(repoDir);
690
+ const base = shared ? shared.base : await vault.materialize();
691
+ const updates = [];
692
+ for (const spec of allowed) {
693
+ const expectedOld = base.refs?.[spec.dst] ?? null;
694
+ updates.push({
695
+ ref: spec.dst,
696
+ expected_old_oid: expectedOld,
697
+ new_oid: newOids.get(spec),
698
+ // Force-with-lease still requires a lease, so a CREATE is never
699
+ // forced. The SDK owns what force actually permits.
700
+ force: spec.force && expectedOld !== null,
701
+ });
702
+ }
703
+ // Repair a DANGLING HEAD from this batch's own branches (#568) — see
704
+ // `chooseGitvaultHeadTargetForPush`'s own doc comment for the exact
705
+ // rule. `localHeadRef` is read from THIS repository (never cwd, same
706
+ // fail-closed resolution as everything else in this function).
707
+ const headFix = chooseGitvaultHeadTargetForPush({
708
+ baseHeadTarget: base.head_target,
709
+ baseRefs: base.refs,
710
+ updates,
711
+ localHeadRef: await localHeadBranchRef(repoDir),
712
+ });
713
+ if (headFix.note) note(headFix.note);
714
+ // ONE transaction for the whole batch: the SDK evaluates fast-forward,
715
+ // tag immutability, protocol-ref refusal and retention roots, builds the
716
+ // packs, and publishes — all or nothing. `head_target` is included ONLY
717
+ // when a repair is called for; omitted, `vault.push` carries the base
718
+ // forward unchanged — a healthy HEAD is never moved.
719
+ const published = await vault.push({
720
+ transaction: { updates },
721
+ base,
722
+ ...(headFix.head_target ? { head_target: headFix.head_target } : {}),
723
+ });
724
+ if (verbosity >= 1) note(`published generation ${published.generation} (${published.form})`);
725
+ // gitvault-clone-scaling (P3): advisory only — never blocks, never
726
+ // auto-runs compaction, and never fires on a failed push (this line is
727
+ // unreachable from the catch). Unknown coverage reads as not-advised.
728
+ if (published.checkpoint_staleness?.advised) {
729
+ note(`${published.checkpoint_staleness.generations_since_checkpoint} generations since the last checkpoint — cold clones re-verify each one; run402 repos gc compacts them`);
730
+ }
731
+ for (const spec of allowed) out(`ok ${spec.dst}`);
732
+ } catch (err) {
733
+ // The transaction is atomic, so a failure failed every ref in it. Report
734
+ // it against each one rather than letting some look like they landed.
735
+ if (err?.code === "GIT_INVOCATION_REPO_UNRESOLVED") repoRefusalNote(err);
736
+ // Force-spelling truth (gitvault-force-spelling-and-pin-fold): render
737
+ // the SDK's own `git push --force` next_action beside git's per-ref
738
+ // rejection — humans read stderr, agents read the structured error.
739
+ const forceHint = Array.isArray(err?.body?.next_actions) ? err.body.next_actions.find((a) => a?.action === "git push --force") : null;
740
+ if (forceHint?.why) note(`hint: ${forceHint.action} — ${forceHint.why}`);
741
+ const reason = describeError(err);
742
+ for (const spec of allowed) out(`error ${spec.dst} ${reason}`);
743
+ }
744
+ endBlock();
745
+ return 0;
746
+ }
747
+
748
+ function handleOption(name, value) {
749
+ switch (name) {
750
+ case "verbosity": {
751
+ const parsed = Number.parseInt(value, 10);
752
+ if (!Number.isFinite(parsed)) { out("error expected an integer verbosity"); return; }
753
+ verbosity = parsed;
754
+ out("ok");
755
+ return;
756
+ }
757
+ case "progress":
758
+ // Progress is stderr chatter, which `verbosity` already governs.
759
+ out("ok");
760
+ return;
761
+ case "atomic":
762
+ // Every push here is a single ref transaction, so the guarantee holds
763
+ // whichever way git asked for it.
764
+ out("ok");
765
+ return;
766
+ case "dry-run":
767
+ // kychee-com/run402#565: a REAL dry run — `runPush` runs the actual
768
+ // local pipeline (pack building, encryption sizing) and stops before
769
+ // the two network mutations. `value` is git's own boolean spelling
770
+ // ("true"/"false"); anything else is refused rather than guessed.
771
+ if (value === "true") { dryRun = true; out("ok"); return; }
772
+ if (value === "false") { dryRun = false; out("ok"); return; }
773
+ out("unsupported");
774
+ return;
775
+ default:
776
+ // Includes object-format, depth, cloning, check-connectivity,
777
+ // followtags, pushcert: honestly unsupported rather than acknowledged.
778
+ out("unsupported");
779
+ }
780
+ }
781
+
782
+ let fetchBatch = [];
783
+ let pushBatch = [];
784
+
785
+ /** Returns the process exit code the flushed batch demands (0 = keep going). */
786
+ async function flushBatches() {
787
+ if (fetchBatch.length > 0) {
788
+ const batch = fetchBatch;
789
+ fetchBatch = [];
790
+ return await runFetch(batch);
791
+ }
792
+ if (pushBatch.length > 0) {
793
+ const batch = pushBatch;
794
+ pushBatch = [];
795
+ return await runPush(batch);
796
+ }
797
+ return 0;
798
+ }
799
+
800
+ const rl = createInterface({ input: sessionStdin, crlfDelay: Infinity });
801
+ try {
802
+ for await (const raw of rl) {
803
+ const line = raw.replace(/\r$/, "");
804
+ if (line === "") {
805
+ const code = await flushBatches();
806
+ if (code !== 0) return code;
807
+ continue;
808
+ }
809
+ const space = line.indexOf(" ");
810
+ const command = space === -1 ? line : line.slice(0, space);
811
+ const rest = space === -1 ? "" : line.slice(space + 1);
812
+ switch (command) {
813
+ case "capabilities":
814
+ out("fetch");
815
+ out("push");
816
+ out("option");
817
+ endBlock();
818
+ break;
819
+ case "list":
820
+ await runList();
821
+ break;
822
+ case "option": {
823
+ const optSpace = rest.indexOf(" ");
824
+ handleOption(optSpace === -1 ? rest : rest.slice(0, optSpace), optSpace === -1 ? "" : rest.slice(optSpace + 1));
825
+ break;
826
+ }
827
+ case "fetch":
828
+ fetchBatch.push(rest);
829
+ break;
830
+ case "push":
831
+ pushBatch.push(rest);
832
+ break;
833
+ default:
834
+ note(`unknown command: ${oneLine(line)}`);
835
+ return 1;
836
+ }
837
+ }
838
+ // EOF. Git always terminates a batch with a blank line, but flushing here
839
+ // means a truncated stream still does the work it already asked for
840
+ // instead of silently dropping it.
841
+ return await flushBatches();
842
+ } finally {
843
+ rl.close();
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Run one remote-helper session to completion (gitvault-persistent-helper
849
+ * D1). `argv` is git's argv slice (`[remote, address]`); `stdin` is the
850
+ * session's input stream (defaults to the real process stdin for the
851
+ * in-process host). Per-session module state is reset here so a daemon
852
+ * serving sequential sessions never leaks one invocation's resolution into
853
+ * the next (D2). Never throws — the error path is the same
854
+ * note-and-exit-1 the standalone binary always had.
855
+ */
856
+ export async function runHelperSession(argv, { stdin } = {}) {
857
+ sessionStdin = stdin ?? process.stdin;
858
+ resolvedWallet = null;
859
+ try {
860
+ return await main(argv);
861
+ } catch (err) {
862
+ note(describeError(err));
863
+ return 1;
864
+ } finally {
865
+ sessionStdin = process.stdin;
866
+ }
867
+ }