run402 4.60.0 → 4.61.1

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
@@ -526,18 +526,23 @@ switch (cmd) {
526
526
  // cli-conventions gate keeps them in lockstep), so no second list.
527
527
  const { COMMAND_MANIFEST, SKIPPED_FAMILIES } = await import("./lib/command-manifest.mjs");
528
528
  const { closestWord } = await import("./lib/argparse.mjs");
529
+ const { describeRejectedValue } = await import("./core-dist/redact.js");
529
530
  const families = new Set([
530
531
  ...COMMAND_MANIFEST.map((entry) => entry.path[0]),
531
532
  ...Object.keys(SKIPPED_FAMILIES),
532
533
  ]);
533
534
  const closest = typeof cmd === "string" ? closestWord(cmd, [...families]) : null;
535
+ // `cmd` is the first bare positional — could be anything a script passes
536
+ // by mistake (kychee-com/run402-private#640 is the demonstrated risk for
537
+ // this shape of value), so it must not be echoed verbatim.
538
+ const shownCmd = describeRejectedValue(cmd);
534
539
  fail({
535
540
  code: "UNKNOWN_COMMAND",
536
541
  message: closest
537
- ? `Unknown command: ${cmd}. Did you mean ${closest}?`
538
- : `Unknown command: ${cmd}`,
542
+ ? `Unknown command: ${shownCmd}. Did you mean ${closest}?`
543
+ : `Unknown command: ${shownCmd}`,
539
544
  hint: "Run `run402 --help` for the command list.",
540
- details: { command: cmd, closest: closest ? [closest] : [] },
545
+ details: { command: shownCmd, closest: closest ? [closest] : [] },
541
546
  });
542
547
  }
543
548
  }
@@ -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
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.60.0",
2
+ "surface_version": "4.61.1",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
package/lib/argparse.mjs CHANGED
@@ -2,6 +2,7 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { fail } from "./sdk-errors.mjs";
3
3
  import { resolveProjectId } from "./config.mjs";
4
4
  import { COMMAND_MANIFEST } from "./command-manifest.mjs";
5
+ import { describeRejectedValue } from "../core-dist/redact.js";
5
6
 
6
7
  export function normalizeArgv(argv = []) {
7
8
  const out = [];
@@ -140,12 +141,15 @@ export function validateEvmAddress(value, fieldName = "address") {
140
141
  }
141
142
  }
142
143
 
144
+ // A rejected positional could be anything the caller typed or piped in — see
145
+ // core-dist/redact.js's doc comment (kychee-com/run402-private#640) — so it
146
+ // is never safe to echo verbatim.
143
147
  export function failBadProjectId(value) {
144
148
  fail({
145
149
  code: "BAD_PROJECT_ID",
146
- message: `Argument '${value}' is not a project id. Project IDs must start with 'prj_'.`,
150
+ message: `Argument '${describeRejectedValue(value)}' is not a project id. Project IDs must start with 'prj_'.`,
147
151
  hint: "Omit the project id to use the active project, or pass the full prj_... id.",
148
- details: { value, expected_prefix: "prj_" },
152
+ details: { value: describeRejectedValue(value), expected_prefix: "prj_" },
149
153
  });
150
154
  }
151
155
 
@@ -433,16 +437,22 @@ export function knownSubcommands(family) {
433
437
  export function failUnknownSubcommand(family, sub, { hint, label, extraSubcommands = [], next_actions } = {}) {
434
438
  const displayLabel = label ?? family;
435
439
  const known = [...new Set([...knownSubcommands(family), ...extraSubcommands])].sort();
440
+ // closestWord() itself never echoes — it only ever returns one of the
441
+ // known, safe `known` candidates — but `sub` is an arbitrary positional
442
+ // (could be anything piped or mistyped in; kychee-com/run402-private#640
443
+ // is the demonstrated risk), so the ECHO of `sub` below still needs
444
+ // redaction even though the matching runs on the raw value.
436
445
  const closest = typeof sub === "string" ? closestWord(sub, known) : null;
446
+ const shownSub = describeRejectedValue(sub);
437
447
  fail({
438
448
  code: "UNKNOWN_SUBCOMMAND",
439
449
  message: closest
440
- ? `Unknown ${displayLabel} subcommand: ${sub}. Did you mean ${closest}?`
441
- : `Unknown ${displayLabel} subcommand: ${sub}`,
450
+ ? `Unknown ${displayLabel} subcommand: ${shownSub}. Did you mean ${closest}?`
451
+ : `Unknown ${displayLabel} subcommand: ${shownSub}`,
442
452
  hint: hint ?? `Run \`run402 ${displayLabel} --help\` for usage.`,
443
453
  details: {
444
454
  command: displayLabel,
445
- subcommand: sub,
455
+ subcommand: shownSub,
446
456
  closest: closest ? [closest] : [],
447
457
  known_subcommands: known,
448
458
  },
package/lib/email.mjs CHANGED
@@ -4,6 +4,7 @@ import { resolveProjectId } from "./config.mjs";
4
4
  import { getSdk } from "./sdk.mjs";
5
5
  import { reportSdkError, fail, parseFlagJson } from "./sdk-errors.mjs";
6
6
  import { assertKnownFlags, flagValue, normalizeArgv, parseIntegerFlag, positionalArgs, failUnknownSubcommand } from "./argparse.mjs";
7
+ import { describeRejectedValue } from "../core-dist/redact.js";
7
8
 
8
9
  // Extension → content-type for `--attach <path>` without an explicit `:type`.
9
10
  const ATTACH_EXT_CONTENT_TYPES = {
@@ -365,11 +366,14 @@ function mailboxIdFromSelector(envelope, selector, flag) {
365
366
  if (/^mbx_/.test(selector)) return selector;
366
367
  const hit = (envelope.mailboxes ?? []).find((m) => m.mailbox_id === selector || m.slug === selector);
367
368
  if (!hit) {
369
+ // A selector that matches nothing is a value we know nothing about — see
370
+ // core-dist/redact.js's doc comment (kychee-com/run402-private#640) —
371
+ // so it must not be echoed verbatim.
368
372
  fail({
369
373
  code: "MAILBOX_NOT_FOUND",
370
- message: `No mailbox matching ${JSON.stringify(selector)} for ${flag}.`,
374
+ message: `No mailbox matching ${JSON.stringify(describeRejectedValue(selector))} for ${flag}.`,
371
375
  details: {
372
- selector,
376
+ selector: describeRejectedValue(selector),
373
377
  flag,
374
378
  candidates: (envelope.mailboxes ?? []).map(summarizeMailboxForDefaults),
375
379
  },
@@ -43,6 +43,7 @@ import { flagValue } from "./argparse.mjs";
43
43
  import { findBindingKey } from "./wallet-context.mjs";
44
44
  import { fail } from "./sdk-errors.mjs";
45
45
  import { nextAction } from "./next-actions.mjs";
46
+ import { describeRejectedValue } from "../core-dist/redact.js";
46
47
  import {
47
48
  getActiveOrgId as coreGetActiveOrgId,
48
49
  setActiveOrgId as coreSetActiveOrgId,
@@ -66,14 +67,19 @@ const trimmed = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
66
67
  /**
67
68
  * Shape-validate an organization id supplied by a local source. Membership is
68
69
  * NEVER checked here — only that the value could be an org id at all.
70
+ *
71
+ * A rejected value is a value we know nothing about — see
72
+ * core-dist/redact.js's doc comment (kychee-com/run402-private#640): the
73
+ * same class of mistake that put a private key into RUN402_WALLET can put
74
+ * one into RUN402_ORG / --org, so this must never echo the raw value.
69
75
  */
70
76
  function assertOrgIdShape(orgId, origin) {
71
77
  if (ORG_ID_RE.test(orgId)) return orgId;
72
78
  fail({
73
79
  code: "BAD_ORG_ID",
74
- message: `Invalid organization id ${JSON.stringify(orgId)} (from ${origin}).`,
80
+ message: `Invalid organization id ${JSON.stringify(describeRejectedValue(orgId))} (from ${origin}).`,
75
81
  hint: "An org_id is a UUID. Run 'run402 org list' to see the organizations you belong to.",
76
- details: { org_id: orgId, origin },
82
+ details: { org_id: describeRejectedValue(orgId), origin },
77
83
  next_actions: [listOrgsAction()],
78
84
  });
79
85
  }
@@ -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.60.0",
3
+ "version": "4.61.1",
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;