run402 4.61.0 → 4.62.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/lib/repos.mjs CHANGED
@@ -82,6 +82,7 @@ Maintenance:
82
82
  run402 repos access revoke-key <principal_id> [--project <id>] [--repo <repo_id>]
83
83
  run402 repos access declare-exposure [--project <id>] [--repo <repo_id>]
84
84
  run402 repos policy <required|grandfathered> [--project <id>] [--repo <repo_id>] [--reason <why>]
85
+ run402 repos policy auto-gc [<generations>|off] (local, per-checkout — no --project/--repo)
85
86
 
86
87
  Every verb above also accepts -v/--verbose (a stderr summary line of request
87
88
  stats — round trips, wire time, bytes — coexists with --human) and always
@@ -268,6 +269,15 @@ Subcommands:
268
269
  step-up, audited. \`grandfathered\` is the documented way out of a
269
270
  deploy the vault gate refused, and needs \`--reason\`; returning to
270
271
  \`required\` does not. Allocating a repo never sets this.
272
+ policy auto-gc [<generations>|off]
273
+ A LOCAL, per-checkout setting (git config, like git's own
274
+ \`gc.auto\` — no network call, no --project/--repo/--reason): the
275
+ post-push compaction cadence (gitvault-checkpoint-cadence).
276
+ Default 32 — after a push, once this many generations have
277
+ accumulated since the vault's last checkpoint, \`gc\`'s
278
+ compact+prune-plan cycle runs automatically (one stderr advisory
279
+ without a resident daemon; silently in the background with one).
280
+ \`off\` (or \`0\`) disables it. No value prints the current setting.
271
281
 
272
282
  Options:
273
283
  --project <id> Project whose repo to act on (defaults to the active project)
@@ -1359,20 +1369,84 @@ async function snapshot(args) {
1359
1369
 
1360
1370
  // ─── policy ─────────────────────────────────────────────────────────────────
1361
1371
 
1372
+ /**
1373
+ * gitvault-checkpoint-cadence design D1: `auto-gc` is a LOCAL, per-checkout
1374
+ * knob — the same local-git-config mechanism as the restore marker, and the
1375
+ * same shape as git's own `gc.auto` — deliberately NOT a gateway call like
1376
+ * `required`/`grandfathered` above (there is no server-side policy row for
1377
+ * it; the gateway task list for this change never adds one). It rides
1378
+ * `repos policy`'s NAMESPACE only, for the muscle-memory: `run402 repos
1379
+ * policy auto-gc [<generations>|off]`. No value reads the current setting;
1380
+ * `off` is sugar for `0` (disables auto-gc entirely).
1381
+ */
1382
+ async function policyAutoGc(rawValue, a) {
1383
+ const { hardenedGit, readGitvaultAutoGcThreshold, writeGitvaultAutoGcThreshold, GITVAULT_AUTO_GC_GENERATIONS_DEFAULT } = await import("#sdk/node");
1384
+ const dir = process.cwd();
1385
+ try {
1386
+ await hardenedGit(dir, ["rev-parse", "--git-dir"]);
1387
+ } catch {
1388
+ fail({
1389
+ code: "BAD_USAGE",
1390
+ message: "run402 repos policy auto-gc must run inside a git checkout.",
1391
+ hint: "cd into the repository this vault is checked out in, then re-run — auto-gc's threshold is per-checkout, like git's own `gc.auto`.",
1392
+ });
1393
+ }
1394
+ const sdk = getSdk();
1395
+ if (rawValue === undefined) {
1396
+ const current = await readGitvaultAutoGcThreshold(dir);
1397
+ printJson(sdk, { auto_gc_generations: current, default: GITVAULT_AUTO_GC_GENERATIONS_DEFAULT });
1398
+ console.error(
1399
+ current === 0
1400
+ ? "auto-gc is disabled for this checkout"
1401
+ : `auto-gc runs after a push once ${current} generation(s) have accumulated since the last checkpoint (default ${GITVAULT_AUTO_GC_GENERATIONS_DEFAULT})`,
1402
+ );
1403
+ printVerboseStats(a, sdk);
1404
+ return;
1405
+ }
1406
+ let generations;
1407
+ if (rawValue === "off") {
1408
+ generations = 0;
1409
+ } else if (/^\d+$/.test(rawValue)) {
1410
+ generations = Number.parseInt(rawValue, 10);
1411
+ } else {
1412
+ fail({
1413
+ code: "BAD_USAGE",
1414
+ message: `Invalid auto-gc value: ${rawValue}.`,
1415
+ hint: "Expected a non-negative integer (generations since checkpoint before auto-gc runs), or `off` to disable.",
1416
+ details: { value: rawValue },
1417
+ });
1418
+ }
1419
+ await writeGitvaultAutoGcThreshold(dir, generations);
1420
+ printJson(sdk, { auto_gc_generations: generations });
1421
+ console.error(
1422
+ generations === 0
1423
+ ? "auto-gc disabled for this checkout"
1424
+ : `auto-gc will run after a push once ${generations} generation(s) have accumulated since the last checkpoint`,
1425
+ );
1426
+ printVerboseStats(a, sdk);
1427
+ }
1428
+
1362
1429
  async function policy(args) {
1363
1430
  const a = normalizeArgv(args);
1364
1431
  const valueFlags = [...COMMON_VALUE_FLAGS, "--reason"];
1365
1432
  assertKnownFlags(a, [...valueFlags, "-v", "--verbose", "--help", "-h"], valueFlags);
1366
- const [requested] = requirePositionalCount(a, valueFlags, {
1367
- min: 1, max: 1, command: "run402 repos policy <required|grandfathered>",
1368
- missing: "Missing <policy>. Expected `required` or `grandfathered`.",
1433
+ const positionals = requirePositionalCount(a, valueFlags, {
1434
+ min: 1, max: 2, command: "run402 repos policy <required|grandfathered|auto-gc> [value]",
1435
+ missing: "Missing <policy>. Expected `required`, `grandfathered`, or `auto-gc [<generations>|off]`.",
1369
1436
  });
1437
+ const [requested, secondArg] = positionals;
1438
+ if (requested === "auto-gc") {
1439
+ return policyAutoGc(secondArg, a);
1440
+ }
1441
+ if (secondArg !== undefined) {
1442
+ fail({ code: "BAD_USAGE", message: `Unexpected argument for run402 repos policy ${requested}: ${secondArg}`, hint: "Only `auto-gc` takes a second argument." });
1443
+ }
1370
1444
  if (requested !== "required" && requested !== "grandfathered") {
1371
1445
  fail({
1372
1446
  code: "BAD_USAGE",
1373
1447
  message: `Unknown policy: ${requested}.`,
1374
- hint: "Expected `required` (a deploy must present a vaulted capture) or `grandfathered` (it need not).",
1375
- details: { policy: requested, known_policies: ["required", "grandfathered"] },
1448
+ hint: "Expected `required` (a deploy must present a vaulted capture), `grandfathered` (it need not), or `auto-gc [<generations>|off]` (the post-push compaction cadence).",
1449
+ details: { policy: requested, known_policies: ["required", "grandfathered", "auto-gc"] },
1376
1450
  });
1377
1451
  }
1378
1452
  const reason = flagValue(a, "--reason");
@@ -31,6 +31,7 @@ import { resolveOrg } from "./org-context.mjs";
31
31
  import { findBindingKey } from "./wallet-context.mjs";
32
32
  import { nextAction } from "./next-actions.mjs";
33
33
  import { resolveSessionKey } from "./harness-context.mjs";
34
+ import { describeRejectedValue } from "../core-dist/redact.js";
34
35
 
35
36
  export const ROOM_ENV = "RUN402_ROOM";
36
37
  export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
@@ -58,10 +59,12 @@ export async function resolveRoom({ org, room, project } = {}) {
58
59
  if (envRoom) {
59
60
  const slash = envRoom.indexOf("/");
60
61
  if (slash <= 0 || slash === envRoom.length - 1) {
62
+ // Same class as kychee-com/run402-private#640: a malformed RUN402_ROOM
63
+ // is a value we know nothing about, so it must not be echoed raw.
61
64
  fail({
62
65
  code: "BAD_USAGE",
63
66
  message: `${ROOM_ENV} must be "<org_id>/<room_key>".`,
64
- details: { value: envRoom },
67
+ details: { value: describeRejectedValue(envRoom) },
65
68
  });
66
69
  }
67
70
  return {
@@ -25,6 +25,7 @@ import { fail } from "./sdk-errors.mjs";
25
25
  import { isValidProfileName } from "../core-dist/config.js";
26
26
  import { getDefaultWallet, profileExists, readMeta, profileDir } from "../core-dist/profiles.js";
27
27
  import { readAllowance } from "../core-dist/allowance.js";
28
+ import { describeRejectedValue } from "../core-dist/redact.js";
28
29
  // The binding file is a CHECKOUT-LEVEL CONTRACT read by more than one surface,
29
30
  // so its reader lives in core — `run402-mcp` ships core/dist but not cli/, and
30
31
  // two readers of one file format is exactly the drift worth not having.
@@ -131,13 +132,21 @@ export class WalletSelectionError extends Error {
131
132
  }
132
133
  }
133
134
 
135
+ // A rejected name is a value we know nothing about — the most likely
136
+ // mistake is a typo, but kychee-com/run402-private#640 was a live
137
+ // Base-mainnet private key pasted into RUN402_WALLET (a NAME field, not a
138
+ // key field), and the raw value used to be echoed straight into this error
139
+ // — printed to a terminal, a log, and a session transcript. Route it
140
+ // through describeRejectedValue() so a short/plain typo still shows in
141
+ // full (that's what makes the error useful) while anything long or
142
+ // hex-shaped enough to be a secret is redacted instead.
134
143
  function assertValidNameCore(name, origin) {
135
144
  if (name === DEFAULT || isValidProfileName(name)) return;
136
145
  throw new WalletSelectionError({
137
146
  code: "BAD_WALLET_NAME",
138
- message: `Invalid wallet name ${JSON.stringify(name)} (from ${origin}).`,
139
- hint: "Wallet names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-').",
140
- details: { name, origin },
147
+ message: `Invalid wallet name ${JSON.stringify(describeRejectedValue(name))} (from ${origin}).`,
148
+ hint: "Wallet names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-'). If a private key or other secret ended up here, it does not belong in a NAME field — see `run402 wallets import` — and should be treated as compromised.",
149
+ details: { name: describeRejectedValue(name), origin },
141
150
  });
142
151
  }
143
152
 
@@ -187,11 +196,18 @@ export function resolveWalletCore({ walletFlag, env = {}, cwd = process.cwd(), c
187
196
  const binding = findBinding(cwd);
188
197
 
189
198
  if (envName && binding && envName !== binding.wallet && !CONFLICT_EXEMPT.has(cmd)) {
199
+ // This conflict check runs BEFORE assertValidNameCore below, so an
200
+ // unvalidated (possibly secret-shaped — kychee-com/run402-private#640)
201
+ // RUN402_WALLET reaches here first whenever a directory binding exists
202
+ // (routine per this repo's own fleet-coordination convention). Redact
203
+ // the env side the same way the format check would; `binding.wallet`
204
+ // comes from a committed .run402.json, which by convention never holds
205
+ // a secret (`wallets bind` validates the name before writing it).
190
206
  throw new WalletSelectionError({
191
207
  code: "WALLET_SELECTION_CONFLICT",
192
- message: `Ambiguous wallet: RUN402_WALLET=${envName} but ${binding.file} selects '${binding.wallet}'.`,
208
+ message: `Ambiguous wallet: RUN402_WALLET=${describeRejectedValue(envName)} but ${binding.file} selects '${binding.wallet}'.`,
193
209
  hint: "Resolve with one of: pass --wallet <name>, unset RUN402_WALLET, or run402 wallets unbind.",
194
- details: { env_wallet: envName, binding_wallet: binding.wallet, binding_file: binding.file },
210
+ details: { env_wallet: describeRejectedValue(envName), binding_wallet: binding.wallet, binding_file: binding.file },
195
211
  });
196
212
  }
197
213
 
@@ -232,14 +248,25 @@ export function enforceWalletExistsCore({ name, source }, cmd) {
232
248
  if (name === DEFAULT) return;
233
249
  if (EXISTENCE_EXEMPT.has(cmd)) return;
234
250
  if (profileExists(name)) return;
251
+ // `name` already passed the format check (assertValidNameCore), which
252
+ // constrains it to lowercase/digits/'_'/'-' — but a bare 64-hex private
253
+ // key with no "0x" prefix satisfies that charset too, so this "not found"
254
+ // path can still see raw key material. looksLikeAddress() is safe to echo
255
+ // (an address is public); anything else goes through describeRejectedValue(),
256
+ // which leaves a short/plain name untouched but redacts anything shaped
257
+ // like a secret — so only the untouched case gets a "create it" command
258
+ // that's actually safe (and sensible) to suggest re-typing.
259
+ const shown = describeRejectedValue(name);
235
260
  const hint = looksLikeAddress(name)
236
261
  ? `'${name}' looks like an address. For billing use: run402 billing ... --wallet-address ${name}`
237
- : `Run 'run402 wallets list' to see wallets, or 'run402 wallets new ${name}' to create it.`;
262
+ : shown === name
263
+ ? `Run 'run402 wallets list' to see wallets, or 'run402 wallets new ${name}' to create it.`
264
+ : "Run 'run402 wallets list' to see wallets. This value was not shown because it looks like a secret rather than a wallet name — if a private key or other credential landed here, treat it as compromised.";
238
265
  throw new WalletSelectionError({
239
266
  code: "WALLET_NOT_FOUND",
240
- message: `No local wallet named '${name}'.`,
267
+ message: `No local wallet named '${shown}'.`,
241
268
  hint,
242
- details: { wallet: name, source },
269
+ details: { wallet: shown, source },
243
270
  });
244
271
  }
245
272
 
@@ -27,12 +27,17 @@ afterEach(() => {
27
27
  });
28
28
 
29
29
  // Capture a fail() invocation: fail() does console.error(envelope) + process.exit.
30
+ // `raw` is the exact string handed to console.error — the byte-for-byte
31
+ // stderr line a real invocation would print — for tests that need to prove
32
+ // something is absent from the actual output, not just from the re-parsed
33
+ // object (key order/JSON-escaping could otherwise mask a leak).
30
34
  function captureFail(fn) {
31
35
  const origExit = process.exit;
32
36
  const origErr = console.error;
33
37
  let envelope = null;
38
+ let raw = null;
34
39
  let exited = false;
35
- console.error = (s) => { try { envelope = JSON.parse(s); } catch { envelope = s; } };
40
+ console.error = (s) => { raw = s; try { envelope = JSON.parse(s); } catch { envelope = s; } };
36
41
  process.exit = () => { exited = true; throw new Error("__EXIT__"); };
37
42
  try {
38
43
  fn();
@@ -42,7 +47,7 @@ function captureFail(fn) {
42
47
  process.exit = origExit;
43
48
  console.error = origErr;
44
49
  }
45
- return { envelope, exited };
50
+ return { envelope, raw, exited };
46
51
  }
47
52
 
48
53
  function bindingDir(wallet, localWallet) {
@@ -166,11 +171,52 @@ describe("resolveWallet — conflict + validation", () => {
166
171
  assert.equal(r.name, "personal"); // env still wins; no error
167
172
  rmSync(dir, { recursive: true, force: true });
168
173
  });
174
+
175
+ // The WALLET_SELECTION_CONFLICT check runs BEFORE assertValidNameCore, so a
176
+ // secret-shaped RUN402_WALLET reaches it unvalidated whenever a directory
177
+ // binding also exists — routine for any checkout following this repo's own
178
+ // fleet-coordination convention (`.run402.json`). This is a stricter variant
179
+ // of kychee-com/run402-private#640: no BAD_WALLET_NAME format check ever
180
+ // gets a chance to run first, so the redaction has to happen at this site
181
+ // too, not just at assertValidNameCore.
182
+ it("never echoes a secret-shaped RUN402_WALLET via the conflict path", () => {
183
+ const dir = bindingDir("client-a"); // a real, differently-named binding
184
+ const privateKey = "0x" + "22a3f0".repeat(11); // 66 chars
185
+ const { envelope, raw } = captureFail(() =>
186
+ resolveWallet({ env: { RUN402_WALLET: privateKey }, cwd: dir, cmd: "deploy" }));
187
+ assert.equal(envelope.code, "WALLET_SELECTION_CONFLICT");
188
+ assert.ok(!raw.includes(privateKey), "full stderr line must not contain the secret");
189
+ assert.ok(!raw.includes("22a3f0"), "stderr line must not contain a substring of the secret");
190
+ rmSync(dir, { recursive: true, force: true });
191
+ });
169
192
  it("rejects an invalid wallet name", () => {
170
193
  const dir = mkdtempSync(join(tmpdir(), "nobind-"));
171
194
  const { envelope } = captureFail(() =>
172
195
  resolveWallet({ env: { RUN402_WALLET: "../evil" }, cwd: dir, cmd: "status" }));
173
196
  assert.equal(envelope.code, "BAD_WALLET_NAME");
197
+ // A short, plainly-a-typo value is still shown in full — that's what
198
+ // makes the error useful for debugging an actual mistake.
199
+ assert.match(envelope.message, /\.\.\/evil/);
200
+ assert.equal(envelope.details.name, "../evil");
201
+ rmSync(dir, { recursive: true, force: true });
202
+ });
203
+
204
+ // kychee-com/run402-private#640: a live Base-mainnet private key was pasted
205
+ // into RUN402_WALLET (a NAME field, not a key field), failed this exact
206
+ // check, and got echoed verbatim into a terminal, a log, and a session
207
+ // transcript. A value that fails validation is a value the CLI knows
208
+ // nothing about, and secret-shaped values must never be echoed — no
209
+ // matter which field of the envelope, or how much of the envelope is
210
+ // serialized (message, hint, details, or the raw JSON line as printed).
211
+ it("never echoes a secret-shaped wallet name anywhere in the failure envelope", () => {
212
+ const dir = mkdtempSync(join(tmpdir(), "nobind-"));
213
+ const privateKey = "0x" + "22a3f0".repeat(11); // 66 chars — the exact shape of #640
214
+ const { envelope, raw } = captureFail(() =>
215
+ resolveWallet({ env: { RUN402_WALLET: privateKey }, cwd: dir, cmd: "status" }));
216
+ assert.equal(envelope.code, "BAD_WALLET_NAME");
217
+ assert.ok(!raw.includes(privateKey), "full stderr line must not contain the secret");
218
+ assert.ok(!raw.includes("22a3f0"), "stderr line must not contain a substring of the secret");
219
+ assert.ok(!raw.toLowerCase().includes("22a3f022a3f0"), "stderr line must not contain a longer run of the secret");
174
220
  rmSync(dir, { recursive: true, force: true });
175
221
  });
176
222
  });
@@ -189,6 +235,11 @@ describe("enforceWalletExists — fail closed", () => {
189
235
  enforceWalletExists({ name: "ghost", source: "binding" }, "deploy"));
190
236
  assert.ok(exited);
191
237
  assert.equal(envelope.code, "WALLET_NOT_FOUND");
238
+ // A short, ordinary name is still shown in full and gets an actionable
239
+ // "create it" suggestion — redaction must not degrade the common case.
240
+ assert.match(envelope.message, /ghost/);
241
+ assert.equal(envelope.details.wallet, "ghost");
242
+ assert.match(envelope.hint, /wallets new ghost/);
192
243
  });
193
244
  it("wallets + init are exempt (create paths)", () => {
194
245
  assert.doesNotThrow(() => enforceWalletExists({ name: "ghost", source: "flag" }, "wallets"));
@@ -199,6 +250,21 @@ describe("enforceWalletExists — fail closed", () => {
199
250
  enforceWalletExists({ name: "0x" + "a".repeat(40), source: "flag" }, "deploy"));
200
251
  assert.match(envelope.hint, /--wallet-address/);
201
252
  });
253
+
254
+ // A bare (no "0x" prefix) 64-char lowercase-hex private key satisfies the
255
+ // wallet-name CHARSET (lowercase letters/digits/'_'/'-'), so it sails past
256
+ // assertValidNameCore's format check and only fails HERE, on existence —
257
+ // the second, easy-to-miss half of kychee-com/run402-private#640's blast
258
+ // radius. It must still never be echoed.
259
+ it("redacts a bare-hex value that passes the name format check but not existence", () => {
260
+ const bareKey = "22a3f0".repeat(10) + "aabb"; // 64 lowercase-hex chars
261
+ assert.match(bareKey, /^[a-z0-9][a-z0-9_-]{0,63}$/); // sanity: really does pass the name regex
262
+ const { envelope, raw } = captureFail(() =>
263
+ enforceWalletExists({ name: bareKey, source: "binding" }, "deploy"));
264
+ assert.equal(envelope.code, "WALLET_NOT_FOUND");
265
+ assert.ok(!raw.includes(bareKey), "full stderr line must not contain the secret");
266
+ assert.ok(!raw.includes("22a3f0"), "stderr line must not contain a substring of the secret");
267
+ });
202
268
  });
203
269
 
204
270
  describe("emitProvenance", () => {
package/lib/wallets.mjs CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  setDefaultWallet,
32
32
  } from "../core-dist/profiles.js";
33
33
  import { readAllowance, saveAllowance } from "../core-dist/allowance.js";
34
+ import { describeRejectedValue } from "../core-dist/redact.js";
34
35
  import { getSdk } from "./sdk.mjs";
35
36
  import { readBindingFile, updateBindingFile } from "./wallet-context.mjs";
36
37
 
@@ -71,15 +72,19 @@ function out(obj) {
71
72
  console.log(JSON.stringify(obj, null, 2));
72
73
  }
73
74
 
75
+ // See core-dist/redact.js's doc comment (kychee-com/run402-private#640): a
76
+ // value that fails this check may be a secret pasted into a name argument
77
+ // by mistake, so it must never be echoed verbatim — describeRejectedValue()
78
+ // still shows a short/plain typo in full.
74
79
  function requireName(name, what = "wallet name") {
75
80
  if (!name) fail({ code: "BAD_USAGE", message: `Missing ${what}.`, hint: "run402 wallets --help" });
76
81
  if (name === DEFAULT) return name;
77
82
  if (!isValidProfileName(name)) {
78
83
  fail({
79
84
  code: "BAD_WALLET_NAME",
80
- message: `Invalid ${what} ${JSON.stringify(name)}.`,
85
+ message: `Invalid ${what} ${JSON.stringify(describeRejectedValue(name))}.`,
81
86
  hint: "Names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-').",
82
- details: { name },
87
+ details: { name: describeRejectedValue(name) },
83
88
  });
84
89
  }
85
90
  return name;
@@ -169,7 +174,10 @@ async function cmdNew(args) {
169
174
  function cmdUse(args) {
170
175
  const name = requireName(args.find((a) => a && !a.startsWith("-")));
171
176
  if (name !== DEFAULT && !profileExists(name)) {
172
- fail({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${name}'.`, hint: "run402 wallets list", details: { name } });
177
+ // `name` already passed requireName's charset check, but a bare (no
178
+ // "0x") 64-hex private key satisfies that charset too — describeRejectedValue()
179
+ // is the backstop against echoing it here (kychee-com/run402-private#640).
180
+ fail({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${describeRejectedValue(name)}'.`, hint: "run402 wallets list", details: { name: describeRejectedValue(name) } });
173
181
  }
174
182
  setDefaultWallet(name);
175
183
  out({ local_label: name, active: true });
@@ -184,7 +192,7 @@ async function cmdRename(args) {
184
192
  fail({ code: "BAD_WALLET_NAME", message: "Cannot rename a wallet to the reserved name 'default'.", details: { name: newName } });
185
193
  }
186
194
  if (!profileExists(oldName)) {
187
- fail({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${oldName}'.`, hint: "run402 wallets list", details: { name: oldName } });
195
+ fail({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${describeRejectedValue(oldName)}'.`, hint: "run402 wallets list", details: { name: describeRejectedValue(oldName) } });
188
196
  }
189
197
  try {
190
198
  renameProfile(oldName, newName);
@@ -277,7 +285,7 @@ function cmdRm(args) {
277
285
  fail({ code: "WALLET_PROTECTED", message: "Refusing to remove the reserved 'default' wallet.", details: { name } });
278
286
  }
279
287
  if (!profileExists(name)) {
280
- fail({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${name}'.`, hint: "run402 wallets list", details: { name } });
288
+ fail({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${describeRejectedValue(name)}'.`, hint: "run402 wallets list", details: { name: describeRejectedValue(name) } });
281
289
  }
282
290
  if (!args.includes("--yes")) {
283
291
  fail({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.61.0",
3
+ "version": "4.62.0",
4
4
  "description": "CLI 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.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
2
2
  import { dirname, join } from "node:path";
3
3
  import { existsSync, renameSync, mkdirSync, chmodSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { randomBytes } from "node:crypto";
5
+ import { describeRejectedValue } from "./redact.js";
5
6
  export const DEFAULT_API_BASE = "https://api.run402.com";
6
7
  /**
7
8
  * Validate a user-supplied API base URL. Throws a clear error message that
@@ -25,10 +26,10 @@ function validateApiBase(envVar, raw, fallback) {
25
26
  u = new URL(raw);
26
27
  }
27
28
  catch {
28
- throw new Error(`${envVar} is not a valid URL: ${JSON.stringify(raw)}. Expected an http(s) URL like https://api.run402.com.`);
29
+ throw new Error(`${envVar} is not a valid URL: ${JSON.stringify(describeRejectedValue(raw))}. Expected an http(s) URL like https://api.run402.com.`);
29
30
  }
30
31
  if (u.protocol !== "https:" && u.protocol !== "http:") {
31
- throw new Error(`${envVar} must use http(s):, got ${u.protocol} (full value: ${JSON.stringify(raw)}).`);
32
+ throw new Error(`${envVar} must use http(s):, got ${u.protocol} (full value: ${JSON.stringify(describeRejectedValue(raw))}).`);
32
33
  }
33
34
  return raw;
34
35
  }
@@ -81,7 +82,15 @@ function assertSafeProfileName(name) {
81
82
  if (name === DEFAULT_PROFILE)
82
83
  return;
83
84
  if (!isValidProfileName(name)) {
84
- throw new Error(`Invalid wallet/profile name ${JSON.stringify(name)}. ` +
85
+ // A value that failed this check is a value we know nothing about — the
86
+ // most likely mistake is a typo, but kychee-com/run402-private#640 was a
87
+ // Base-mainnet private key pasted into RUN402_WALLET (a NAME field), and
88
+ // the raw value used to be echoed straight into this message and
89
+ // wherever it propagated (terminal, logs, session transcripts).
90
+ // describeRejectedValue() shows short/plain values in full (useful for
91
+ // an actual typo) and redacts anything long or hex-shaped enough to be
92
+ // a secret instead of a name.
93
+ throw new Error(`Invalid wallet/profile name ${JSON.stringify(describeRejectedValue(name))}. ` +
85
94
  "Names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-'). " +
86
95
  "Check the RUN402_WALLET / RUN402_PROFILE env var.");
87
96
  }
@@ -18,6 +18,7 @@ import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSy
18
18
  import { dirname, join } from "node:path";
19
19
  import { randomBytes } from "node:crypto";
20
20
  import { getConfigBaseDir, getProfilesDir, DEFAULT_PROFILE, isValidProfileName, } from "./config.js";
21
+ import { describeRejectedValue } from "./redact.js";
21
22
  function atomicWrite(p, content, mode) {
22
23
  const dir = dirname(p);
23
24
  mkdirSync(dir, { recursive: true });
@@ -153,7 +154,10 @@ export function removeProfile(name) {
153
154
  */
154
155
  export function renameProfile(oldName, newName) {
155
156
  if (!isValidProfileName(newName)) {
156
- throw new Error(`Invalid wallet name ${JSON.stringify(newName)}.`);
157
+ // See describeRejectedValue()'s doc comment (kychee-com/run402-private#640):
158
+ // a rejected value may be a secret pasted into a name field, so it is
159
+ // never safe to echo verbatim here.
160
+ throw new Error(`Invalid wallet name ${JSON.stringify(describeRejectedValue(newName))}.`);
157
161
  }
158
162
  if (newName === oldName)
159
163
  return;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Safe-echo policy for values that failed local (client-side) validation.
3
+ *
4
+ * A value rejected by a format/existence check is a value we know nothing
5
+ * about — the CLI has only established what it is NOT. The likeliest
6
+ * failure is an ordinary typo, but the second likeliest, demonstrated in
7
+ * practice, is a credential pasted into the wrong slot:
8
+ * kychee-com/run402-private#640 — a Base-mainnet private key holding real
9
+ * funds landed in `RUN402_WALLET` (which takes a wallet NAME, not a key),
10
+ * failed the name check, and was printed verbatim to a terminal, a log, and
11
+ * a session transcript.
12
+ *
13
+ * `describeRejectedValue` is the one place that decides what a rejected
14
+ * value is safe to show, so every call site — wallet names, org ids, room
15
+ * keys, unknown commands/subcommands, "not found" lookups — gets the same
16
+ * answer instead of each reinventing (or forgetting) the judgment call.
17
+ *
18
+ * Short, low-entropy values (ordinary typos) are returned unchanged — that
19
+ * is what makes the resulting error message useful. Long or
20
+ * high-entropy-looking values are replaced with a shape-only description
21
+ * (character count only), never a substring or prefix of the original:
22
+ * even a short prefix of a private key is more than a debugging aid needs
23
+ * and more than a leak should give up.
24
+ *
25
+ * This is a heuristic, not a content-aware secret scanner: treat "returned
26
+ * unchanged" as "short and plain enough to be a typo," not as proof the
27
+ * value holds no secret. Callers with a value that is never supposed to be
28
+ * secret-shaped in the first place (a service key, an admin key) should
29
+ * still never echo it at all, redacted or not — this helper is for slots
30
+ * that normally hold a plain identifier and occasionally, by mistake,
31
+ * don't.
32
+ */
33
+ // Real hand-typed identifiers (wallet names, room keys, command names) top
34
+ // out well under this, and it comfortably clears a UUID (36 chars — the
35
+ // canonical shape of an org id, and *itself* a public, harmless-to-echo
36
+ // identifier even when it's the "wrong" one in a conflict, not a secret).
37
+ // Secrets that land in the wrong slot (private keys, API tokens, JWTs) are
38
+ // almost always well past it — the private key in #640 was 64-66 characters.
39
+ const MAX_SAFE_ECHO_LENGTH = 40;
40
+ // A contiguous hex run (with or without a `0x` prefix) reads as key
41
+ // material or a hash rather than a human-typed identifier, even when short
42
+ // enough to pass the length check above — the exact shape of #640.
43
+ const HEX_RUN_RE = /^(0x)?[0-9a-fA-F]{16,}$/;
44
+ function looksSecretShaped(str) {
45
+ return str.length > MAX_SAFE_ECHO_LENGTH || HEX_RUN_RE.test(str);
46
+ }
47
+ /**
48
+ * Return `value` unchanged when it is short and plain enough to be a
49
+ * harmless typo; otherwise return a shape-only placeholder (character count
50
+ * only — never a substring) that is still safe to embed in a message or a
51
+ * structured `details` field.
52
+ *
53
+ * Never throws. Coerces non-string input the same way template-literal
54
+ * interpolation would, so a caller can pass whatever it already has without
55
+ * a separate type check.
56
+ */
57
+ export function describeRejectedValue(value) {
58
+ const str = typeof value === "string" ? value : String(value ?? "");
59
+ if (looksSecretShaped(str)) {
60
+ return `${str.length} chars, not shown — too long or hex-shaped to be a typo (may be a credential that landed in the wrong place)`;
61
+ }
62
+ return str;
63
+ }
64
+ //# sourceMappingURL=redact.js.map
@@ -31,7 +31,7 @@
31
31
  import type { Client } from "../kernel.js";
32
32
  import { GITVAULT_DURABILITY_STATEMENT, GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT } from "./gitvault.crypto.js";
33
33
  import type { GitvaultCaptureReceipt, GitvaultHeadsListingPage, GitvaultHeadsListingRequest, GitvaultHeadTarget, GitvaultOpenReceipt, GitvaultRecipientConfirmationReceipt, GitvaultRecoveryReceipt, GitvaultRotationReason } from "./gitvault.types.js";
34
- import type { GitvaultMaintenanceLease, GitvaultMaintenanceLeaseRequest, GitvaultTransport, GitvaultVaultRecord } from "../node/gitvault-publication.js";
34
+ import type { GitvaultCompactionGrant, GitvaultMaintenanceLease, GitvaultMaintenanceLeaseRequest, GitvaultTransport, GitvaultVaultRecord } from "../node/gitvault-publication.js";
35
35
  import type { GitvaultDeployOptions, GitvaultDeployResult } from "../node/gitvault-deploy.js";
36
36
  import type { GitvaultPublishResult, GitvaultRefMap, GitvaultReconcileEnvelopeRecipientsResult, GitvaultVerifiedState } from "../node/gitvault-publication.js";
37
37
  import type { GitvaultCreationResult } from "../node/gitvault-creation-journal.js";
@@ -228,16 +228,29 @@ export interface GitvaultCompactResult {
228
228
  export interface GitvaultCompactHeadroom {
229
229
  /** Pooled storage the org is already using, across every project it owns. */
230
230
  pool_used_bytes: number;
231
- /** The org's pooled tier storage limit. */
231
+ /** The org's plain, unraised pooled tier storage limit — always the tier's own figure, never the grant-raised one, so a disclosed "used of X pooled" never implies the tier itself grew. */
232
232
  pool_limit_bytes: number;
233
233
  /** The vault's billed `source_bytes` — the checkpoint-size proxy (design D1). */
234
234
  vault_source_bytes: number;
235
235
  /** `pool_used_bytes + vault_source_bytes`. */
236
236
  projected_transient_bytes: number;
237
- /** `false` when the projection exceeds the limit. */
237
+ /** `false` when the projection exceeds the EFFECTIVE limit (`effective_pool_limit_bytes` when a grant is active, else `pool_limit_bytes`). */
238
238
  ok: boolean;
239
239
  /** `true` when the caller passed the override and a `false` `ok` was proceeded past anyway. */
240
240
  overridden: boolean;
241
+ /**
242
+ * gitvault-checkpoint-cadence design D3: `pool_limit_bytes` PLUS an active
243
+ * compaction grant's `granted_bytes`, when one is active for this cycle —
244
+ * the limit `ok`/`projected_transient_bytes` are actually computed
245
+ * against. Equal to `pool_limit_bytes` (and omittable) when no grant is
246
+ * active.
247
+ */
248
+ effective_pool_limit_bytes?: number;
249
+ /** The grant this compaction opened for itself, when one was opened and is still tracked at disclosure time — `null`/absent otherwise (no grant, an older gateway, or one already closed). */
250
+ compaction_grant?: {
251
+ granted_bytes: number;
252
+ expires_at: string;
253
+ } | null;
241
254
  }
242
255
  /**
243
256
  * `run402 gitvault snapshot --dry-run`'s report shape
@@ -1111,6 +1124,20 @@ export declare class Gitvault {
1111
1124
  * figures cannot be read.
1112
1125
  */
1113
1126
  compactHeadroom(options?: GitvaultVaultHandleOptions): Promise<GitvaultCompactHeadroom | null>;
1127
+ /**
1128
+ * Open this vault's compaction headroom grant directly (gitvault-checkpoint-cadence
1129
+ * design D3) — `compact()` already does this internally; this standalone
1130
+ * entry point exists for callers that need to inspect or drive the grant
1131
+ * without also running a full compaction cycle (tests; a future
1132
+ * operator/diagnostic surface). Throws `GITVAULT_COMPACTION_GRANT_ACTIVE`
1133
+ * (409) verbatim when another compaction already holds this project's
1134
+ * grant.
1135
+ */
1136
+ openCompactionGrant(options?: GitvaultVaultHandleOptions): Promise<GitvaultCompactionGrant>;
1137
+ /** Close this vault's compaction headroom grant directly — idempotent; `{closed: false}` when nothing was active. */
1138
+ closeCompactionGrant(options?: GitvaultVaultHandleOptions): Promise<{
1139
+ closed: boolean;
1140
+ }>;
1114
1141
  /**
1115
1142
  * Publish a checkpoint covering the canonical refs, every root unexpired at
1116
1143
  * the cutoff, and the `HEAD` target — under a maintenance lease so a