run402 4.25.0 → 4.26.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.
package/cli.mjs CHANGED
@@ -49,7 +49,7 @@ Commands:
49
49
  rooms Coordinate with the other agents on your project (who/send/ack)
50
50
  escalations Page a human when you judge you need one (raise/list/ack)
51
51
  claims Say what you're working on before you collide (advisory)
52
- gitvault Host-blind encrypted Git remote (status/push/compact/prune/verify)
52
+ gitvault Host-blind encrypted Git remote (init/status/push/policy/compact/prune/verify)
53
53
  errors Grouped error fingerprints + a promote/revert verdict (release-baselined)
54
54
  jobs Submit and inspect platform-managed jobs
55
55
  functions Manage serverless functions (deploy, invoke, logs, list, delete)
@@ -19,6 +19,17 @@
19
19
  * push [+]<src>:<dst> → publish one atomic ref transaction
20
20
  * option <name> <value> → ok / unsupported, never a silent lie
21
21
  *
22
+ * WHICH REPOSITORY (the fail-closed rule). `process.cwd()` is NOT the
23
+ * repository. git identifies the repository with `GIT_DIR`, and during
24
+ * `git clone` cwd is the directory clone was RUN FROM — routinely some other,
25
+ * unrelated repository. Discovering the repository from cwd therefore wrote a
26
+ * vault's DECRYPTED objects into a repository the user never named, silently,
27
+ * on every clone (dogfood #1). Every repository-touching command now resolves
28
+ * through the SDK's `resolveGitInvocationRepo`, which proves `GIT_DIR` names a
29
+ * real repository and refuses otherwise; a refusal writes nothing at all.
30
+ * `capabilities`, `option` and `list` need no repository and are unaffected,
31
+ * so a repository-free `git ls-remote run402::<org>/<project>` still works.
32
+ *
22
33
  * NOT advertised, deliberately: `list` is a COMMAND in this protocol, not a
23
34
  * capability keyword — git's capability vocabulary is fetch/push/import/export/
24
35
  * connect/stateless-connect/option/refspec/check-connectivity/object-format/
@@ -39,15 +50,17 @@
39
50
  * `run402 gitvault push`, which sets the HEAD target from the local HEAD.
40
51
  * - `option dry-run` is `unsupported`: this helper cannot rehearse a
41
52
  * publication, and reporting a fake success would be worse than refusing.
42
- * - The repository is discovered from `process.cwd()` (git's own upward
43
- * discovery). `hardenedGit` scrubs `GIT_DIR`/`GIT_WORK_TREE` on purpose, so
44
- * an invocation from outside the work tree is not supported.
53
+ * - `fetch` and `push` REQUIRE the `GIT_DIR` git sets when it drives a
54
+ * helper against a repository, so running this binary by hand from a shell
55
+ * is refused rather than silently pointed at the current directory. Only
56
+ * `capabilities`, `option` and `list` work without one, which is exactly
57
+ * the set `git ls-remote <url>` outside a checkout needs.
45
58
  */
46
59
 
47
60
  import { createInterface } from "node:readline";
48
61
  import { getSdk } from "./lib/sdk.mjs";
49
62
  import { parseGitvaultRemoteUrl } from "#sdk";
50
- import { hardenedGit } from "#sdk/node";
63
+ import { hardenedGit, resolveGitInvocationRepo } from "#sdk/node";
51
64
 
52
65
  const out = (line) => process.stdout.write(`${line}\n`);
53
66
  /** Every helper response block is terminated by a blank line. */
@@ -107,12 +120,35 @@ async function main(argv) {
107
120
  return 1;
108
121
  }
109
122
 
110
- const repoDir = process.cwd();
111
- const target = { project_id: address.project_id, repo_dir: repoDir };
123
+ const target = { project_id: address.project_id };
112
124
  let verbosity = 1;
113
125
 
126
+ /**
127
+ * The repository git invoked us for, resolved once and PROVEN.
128
+ *
129
+ * Deliberately lazy: `list` needs no repository, so `git ls-remote` outside
130
+ * any checkout keeps working. Deliberately not cached across a failure
131
+ * either — a refusal is terminal for the command that asked, and there is
132
+ * nothing to retry.
133
+ */
134
+ let resolvedRepo = null;
135
+ async function requireRepo() {
136
+ if (!resolvedRepo) resolvedRepo = await resolveGitInvocationRepo(process.env, process.cwd());
137
+ return resolvedRepo.repo_dir;
138
+ }
139
+
140
+ /**
141
+ * What to tell a human when we refuse. `git clone` is the case that used to
142
+ * fail; naming the working alternative beats a bare error.
143
+ */
144
+ function repoRefusalNote(err) {
145
+ note(describeError(err));
146
+ note("refusing to touch a repository git did not name — nothing was read or written.");
147
+ 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/*'`);
148
+ }
149
+
114
150
  /** Open the vault lazily — `capabilities` and `option` must never touch the network. */
115
- const openVault = async () => (await getSdk().gitvault.open(target)).vault;
151
+ const openVault = async (repoDir) => (await getSdk().gitvault.open(repoDir ? { ...target, repo_dir: repoDir } : target)).vault;
116
152
 
117
153
  async function runList() {
118
154
  const state = await (await openVault()).materialize();
@@ -128,30 +164,49 @@ async function main(argv) {
128
164
  }
129
165
 
130
166
  async function runFetch(batch) {
131
- if (verbosity >= 1) note(`restoring the vault object database for ${batch.length} ref(s)`);
132
- const restored = await getSdk().gitvault.restore({ ...target, target_dir: repoDir });
167
+ // Resolve the target repository BEFORE a single byte is decrypted: a
168
+ // refusal here must leave no objects anywhere. This is what makes `clone`
169
+ // work (git names the fresh repo in `GIT_DIR`) and what stops a clone run
170
+ // from inside an unrelated checkout from writing into that checkout.
171
+ let repoDir;
172
+ try {
173
+ repoDir = await requireRepo();
174
+ } catch (err) {
175
+ repoRefusalNote(err);
176
+ return 1;
177
+ }
178
+ if (verbosity >= 1) note(`restoring the vault object database for ${batch.length} ref(s) into ${repoDir}`);
179
+ const restored = await getSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
133
180
  if (verbosity >= 1) note(`restored generation ${restored.generation}`);
134
181
  endBlock();
182
+ return 0;
135
183
  }
136
184
 
137
185
  async function runPush(batch) {
138
186
  const specs = batch.map(parsePushSpec);
139
187
  try {
140
- const vault = await openVault();
141
- const base = await vault.materialize();
142
- const updates = [];
188
+ // Repository first, then every source revision, and only then the
189
+ // network: a push that names a ref this repository does not have must
190
+ // fail locally rather than after opening the vault.
191
+ const repoDir = await requireRepo();
192
+ const newOids = new Map();
143
193
  for (const spec of specs) {
144
- const expectedOld = base.refs?.[spec.dst] ?? null;
145
194
  // A deletion carries an empty <src>. Everything else is resolved by
146
195
  // git itself; `--end-of-options` keeps a hostile refname from being
147
196
  // read as a flag.
148
- const newOid = spec.src === ""
197
+ newOids.set(spec, spec.src === ""
149
198
  ? null
150
- : (await hardenedGit(repoDir, ["rev-parse", "--verify", "--end-of-options", spec.src])).text().trim();
199
+ : (await hardenedGit(repoDir, ["rev-parse", "--verify", "--end-of-options", spec.src])).text().trim());
200
+ }
201
+ const vault = await openVault(repoDir);
202
+ const base = await vault.materialize();
203
+ const updates = [];
204
+ for (const spec of specs) {
205
+ const expectedOld = base.refs?.[spec.dst] ?? null;
151
206
  updates.push({
152
207
  ref: spec.dst,
153
208
  expected_old_oid: expectedOld,
154
- new_oid: newOid,
209
+ new_oid: newOids.get(spec),
155
210
  // Force-with-lease still requires a lease, so a CREATE is never
156
211
  // forced. The SDK owns what force actually permits.
157
212
  force: spec.force && expectedOld !== null,
@@ -166,10 +221,12 @@ async function main(argv) {
166
221
  } catch (err) {
167
222
  // The transaction is atomic, so a failure failed every ref in it. Report
168
223
  // it against each one rather than letting some look like they landed.
224
+ if (err?.code === "GIT_INVOCATION_REPO_UNRESOLVED") repoRefusalNote(err);
169
225
  const reason = describeError(err);
170
226
  for (const spec of specs) out(`error ${spec.dst} ${reason}`);
171
227
  }
172
228
  endBlock();
229
+ return 0;
173
230
  }
174
231
 
175
232
  function handleOption(name, value) {
@@ -200,18 +257,19 @@ async function main(argv) {
200
257
  let fetchBatch = [];
201
258
  let pushBatch = [];
202
259
 
260
+ /** Returns the process exit code the flushed batch demands (0 = keep going). */
203
261
  async function flushBatches() {
204
262
  if (fetchBatch.length > 0) {
205
263
  const batch = fetchBatch;
206
264
  fetchBatch = [];
207
- await runFetch(batch);
208
- return;
265
+ return await runFetch(batch);
209
266
  }
210
267
  if (pushBatch.length > 0) {
211
268
  const batch = pushBatch;
212
269
  pushBatch = [];
213
- await runPush(batch);
270
+ return await runPush(batch);
214
271
  }
272
+ return 0;
215
273
  }
216
274
 
217
275
  const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
@@ -219,7 +277,8 @@ async function main(argv) {
219
277
  for await (const raw of rl) {
220
278
  const line = raw.replace(/\r$/, "");
221
279
  if (line === "") {
222
- await flushBatches();
280
+ const code = await flushBatches();
281
+ if (code !== 0) return code;
223
282
  continue;
224
283
  }
225
284
  const space = line.indexOf(" ");
@@ -254,8 +313,7 @@ async function main(argv) {
254
313
  // EOF. Git always terminates a batch with a blank line, but flushing here
255
314
  // means a truncated stream still does the work it already asked for
256
315
  // instead of silently dropping it.
257
- await flushBatches();
258
- return 0;
316
+ return await flushBatches();
259
317
  } finally {
260
318
  rl.close();
261
319
  }
@@ -238,8 +238,10 @@ export const COMMAND_MANIFEST = [
238
238
  // all but `status`) a local git working tree, so the gate runs structural
239
239
  // checks only — an in-process behavioral run would either no-op against the
240
240
  // universal `{}` fetch mock or touch the gate's own checkout.
241
+ { path: ["gitvault", "init"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "allocates a vault: mints key material on this machine and runs the six-stage creation journal" },
241
242
  { path: ["gitvault", "status"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "reads the local principal keystore and the live vault record" },
242
243
  { path: ["gitvault", "push"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "captures the cwd git working tree and publishes a signed head" },
244
+ { path: ["gitvault", "policy"], positionals: [p("gitvault_policy")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["required"], runStyle: "sub", skipBehavioral: "owner + step-up mutation of the live project's activation policy" },
243
245
  { path: ["gitvault", "compact"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "takes a maintenance lease and builds a checkpoint from the local repository" },
244
246
  { path: ["gitvault", "prune"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "materializes the live vault head to enumerate retention roots" },
245
247
  { path: ["gitvault", "verify"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "walks the live head chain against the keystore's authenticated pin" },
package/lib/deploy-v2.mjs CHANGED
@@ -1349,11 +1349,88 @@ const CI_DEPLOY_ERROR_GUIDANCE = {
1349
1349
  };
1350
1350
 
1351
1351
  function reportDeployApplyError(err, useGithubActionsOidc) {
1352
- const warningEnhanced = enhanceDeployWarningError(err);
1352
+ const warningEnhanced = enhanceGitvaultDeployError(enhanceDeployWarningError(err));
1353
1353
  if (!useGithubActionsOidc) return reportSdkError(warningEnhanced);
1354
1354
  return reportSdkError(enhanceCiDeployError(warningEnhanced));
1355
1355
  }
1356
1356
 
1357
+ /**
1358
+ * Whether THIS client's deploy lane speaks the gitvault protocol.
1359
+ *
1360
+ * `"unsupported"` is a statement of fact about the code in this file: it
1361
+ * declares no capture at plan time and sends no gitvault block on commit, so a
1362
+ * project with `gitvault_policy: required` refuses its commits. The vault
1363
+ * itself works — `run402 gitvault push` and `git push run402` publish captures
1364
+ * — it is the DEPLOY lane that has no capture wiring.
1365
+ *
1366
+ * RETIREMENT CONDITION: flip this to `"supported"` in the same change that
1367
+ * makes `applyCmd` declare `{capture_id, snapshot_oid_hmac}` on
1368
+ * `POST /apply/v1/plans` and present an activation token on
1369
+ * `POST /apply/v1/plans/:id/commit` (protocol §6.5). Doing that faithfully also
1370
+ * means building the deploy artifacts from an isolated materialization of the
1371
+ * snapshot commit, which is why it is a design change and not a wiring one:
1372
+ * build output is normally gitignored, so it is not in the snapshot at all.
1373
+ * `cli-deploy-gitvault-advisory.test.mjs` fails if the flag and the advisory
1374
+ * ever disagree.
1375
+ */
1376
+ export const GITVAULT_DEPLOY_LANE = "unsupported";
1377
+
1378
+ /**
1379
+ * Tell the truth about a deploy the vault gate refused.
1380
+ *
1381
+ * The gateway's `GITVAULT_CLIENT_UPGRADE_REQUIRED` envelope leads with
1382
+ * `upgrade_client` / `npm i -g run402@latest`, which is correct in principle
1383
+ * and WRONG right now: no published `run402` has a gitvault-capable deploy
1384
+ * lane, so upgrading changes nothing about this refusal (dogfood #1, finding
1385
+ * C). Relaying it unedited sends an agent into an upgrade loop. The gateway's
1386
+ * SECOND action — grandfather the policy — is real and reachable, and this
1387
+ * client now has the verb for it.
1388
+ *
1389
+ * The gateway's own actions are PRESERVED, never dropped: they are re-ordered
1390
+ * behind an honest one, and the upgrade action keeps its place with its
1391
+ * promise corrected. When the lane ships, the flag above retires this whole
1392
+ * function's rewrite and the envelope passes through untouched.
1393
+ */
1394
+ export function enhanceGitvaultDeployError(err) {
1395
+ const body = err?.body && typeof err.body === "object" && !Array.isArray(err.body) ? err.body : {};
1396
+ const code = body.code || err?.code || null;
1397
+ if (code !== "GITVAULT_CLIENT_UPGRADE_REQUIRED") return err;
1398
+ if (GITVAULT_DEPLOY_LANE === "supported") return err;
1399
+
1400
+ const gatewayActions = Array.isArray(body.next_actions) ? body.next_actions : [];
1401
+ const enhanced = Object.assign(new Error(err?.message || body.message || code), err);
1402
+ enhanced.body = {
1403
+ ...body,
1404
+ hint:
1405
+ "This project requires a vaulted capture on deploy, and this run402 CLI's deploy lane does not produce one — " +
1406
+ "upgrading the CLI does not currently fix it. Either grandfather the policy (owner + step-up) and deploy, or " +
1407
+ "keep the project vaulted and deploy later. Your source can still be vaulted today: `run402 gitvault push` and " +
1408
+ "`git push run402 <branch>` are not gated on a deploy.",
1409
+ next_actions: [
1410
+ editRequestAction(
1411
+ "run402 gitvault policy grandfathered --reason \"<why>\"",
1412
+ "Un-gate this project so deploys activate without a vaulted capture. Owner + step-up, audited, and it leaves a doctor-persistent warning until you return the project to `required`.",
1413
+ ),
1414
+ editRequestAction(
1415
+ "run402 gitvault push",
1416
+ "Capture and publish your source into the vault. Independent of deploy — a vault-only project pushes for months without one.",
1417
+ ),
1418
+ editRequestAction(
1419
+ "run402 gitvault policy required",
1420
+ "Restore the gate once a gitvault-capable deploy lane is available.",
1421
+ ),
1422
+ // Preserved verbatim in shape, with the promise corrected: the gateway
1423
+ // is describing the eventual client, not one you can install today.
1424
+ ...gatewayActions.map((action) =>
1425
+ action?.type === "upgrade_client"
1426
+ ? { ...action, why: "Tracks the eventual gitvault-capable deploy lane. No published run402 has one yet, so this does not resolve the refusal today." }
1427
+ : action,
1428
+ ),
1429
+ ],
1430
+ };
1431
+ return enhanced;
1432
+ }
1433
+
1357
1434
  function enhanceDeployWarningError(err) {
1358
1435
  const existingBody = err?.body && typeof err.body === "object" && !Array.isArray(err.body)
1359
1436
  ? err.body
package/lib/doctor.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { existsSync, statSync } from "node:fs";
15
- import { configDir, readAllowance, loadKeyStore } from "./config.mjs";
15
+ import { configDir, readAllowance, loadKeyStore, getActiveProjectId } from "./config.mjs";
16
16
  import { getSdk } from "./sdk.mjs";
17
17
  import {
18
18
  resolveScanRoot,
@@ -58,6 +58,10 @@ Checks performed:
58
58
  - Function runtime staleness: deployed functions running an older platform
59
59
  runtime than the current gateway build (refresh with 'run402 functions
60
60
  rebuild --all'; re-bundles from your stored source, no source change)
61
+ - gitvault: the active project's vault — activation policy, whether THIS
62
+ machine can produce the capture a 'required' policy demands, open
63
+ unvaulted-override journals, and where the keystore lives (back it up:
64
+ whole-keystore loss is terminal for vault history)
61
65
  - Source scan: hallucinated SDK auth names (R402_AUTH_UNKNOWN_EXPORT),
62
66
  state-changing GET handlers (R402_AUTH_STATE_CHANGING_GET),
63
67
  auth.* calls in prerendered pages (R402_AUTH_PRERENDERED),
@@ -392,6 +396,81 @@ export async function run(sub, args = []) {
392
396
  });
393
397
  }
394
398
 
399
+ // 6c. gitvault (add-gitvault). Doctor was completely silent about the vault
400
+ // even when `gitvault_policy: required` was the single thing that would break
401
+ // the project's next deploy (dogfood #1, finding D1) — and doctor is where a
402
+ // user looks when something is wrong. It also prints WHERE the keystore is:
403
+ // "whole-keystore loss is terminal" was stated three times across this
404
+ // surface while the directory to back up was stated nowhere (finding D2).
405
+ //
406
+ // Read-only and best-effort in every branch: no project, no vault, or a
407
+ // gateway that does not know gitvault are all ordinary and report `skipped`
408
+ // or `ok`, never a doctor failure. A vault-only project that has never
409
+ // deployed is a first-class shape (protocol D183), so its mere absence of a
410
+ // deploy raises nothing.
411
+ {
412
+ const projectId = (process.env.RUN402_PROJECT_ID || "").trim() || getActiveProjectId() || null;
413
+ if (!projectId) {
414
+ checks.push({
415
+ name: "gitvault",
416
+ status: "skipped",
417
+ ...(verbose && { hint: "no active project — run 'run402 projects use <project_id>' to check its vault." }),
418
+ });
419
+ } else {
420
+ try {
421
+ const gv = await getSdk().gitvault.status({ project_id: projectId, repo_dir: process.cwd() });
422
+ const value = {
423
+ project_id: projectId,
424
+ repo_id: gv.repo_id,
425
+ vault: gv.vault === null ? null : "allocated",
426
+ gitvault_policy: gv.gitvault_policy,
427
+ keystore_root: gv.keystore.root,
428
+ can_sign: gv.keystore.can_sign,
429
+ holds_repo_key: gv.keystore.holds_repo_key,
430
+ pending_overrides: gv.pending_overrides,
431
+ pins: gv.pins,
432
+ remote: gv.remote,
433
+ };
434
+ const gaps = [];
435
+ // The one that actually breaks the next deploy: the project demands a
436
+ // vaulted capture and THIS machine cannot produce one.
437
+ if (gv.gitvault_policy === "required" && !gv.keystore.holds_repo_key) {
438
+ gaps.push(
439
+ "gitvault_policy is 'required' but this machine holds no key for the vault — a deploy from here is refused with GITVAULT_CLIENT_UPGRADE_REQUIRED. " +
440
+ "Run 'run402 gitvault init' (idempotent; resolves to the existing vault), or 'run402 gitvault policy grandfathered --reason <why>' to un-gate the project.",
441
+ );
442
+ } else if (gv.gitvault_policy === "required" && !gv.keystore.can_sign) {
443
+ gaps.push("gitvault_policy is 'required' and this keystore is read-only (no signing key) — it can verify but cannot publish the capture a deploy needs");
444
+ }
445
+ if (gv.pending_overrides > 0) {
446
+ gaps.push(`${gv.pending_overrides} unvaulted-override journal(s) are still open — run 'run402 gitvault push' to drain them`);
447
+ }
448
+ if (gv.remote && !gv.remote.matches) {
449
+ gaps.push(`the '${gv.remote.name}' git remote points at a different project than ${projectId} (${gv.remote.url})`);
450
+ }
451
+ // Echoed exactly as the SDK reported them — including the
452
+ // doctor-persistent `grandfathered` advisory it owns.
453
+ for (const w of gv.warnings ?? []) gaps.push(`${w.kind}: ${w.message}`);
454
+ checks.push({
455
+ name: "gitvault",
456
+ status: gaps.length > 0 ? "warning" : "ok",
457
+ value: gaps.length > 0 ? { ...value, gaps } : value,
458
+ hint: gv.vault === null
459
+ ? `No vault for this project (that is a normal shape). Allocate one with 'run402 gitvault init'. Keystore: ${gv.keystore.root}`
460
+ : `Back up ${gv.keystore.root} — whole-machine or whole-keystore loss is terminal for vault history.`,
461
+ });
462
+ } catch (err) {
463
+ // A gateway without gitvault, an unreachable API, or a project this
464
+ // wallet cannot see. None of those is a local health problem.
465
+ checks.push({
466
+ name: "gitvault",
467
+ status: "skipped",
468
+ message: describeCheckFailure("gitvault status check", err),
469
+ });
470
+ }
471
+ }
472
+ }
473
+
395
474
  // 7. Source-tree scan (auth-aware-ssr Section 9). Detects hallucinated
396
475
  // SDK names, state-changing GETs, auth.* in prerendered pages, and
397
476
  // direct mutation of internal.sessions.authz_version. Hits with severity