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 +8 -3
- package/core-dist/config.js +12 -3
- package/core-dist/profiles.js +5 -1
- package/core-dist/redact.js +64 -0
- package/gitvault-surface.json +1 -1
- package/lib/argparse.mjs +15 -5
- package/lib/email.mjs +6 -2
- package/lib/org-context.mjs +8 -2
- package/lib/rooms-context.mjs +4 -1
- package/lib/wallet-context.mjs +35 -8
- package/lib/wallet-context.test.mjs +68 -2
- package/lib/wallets.mjs +13 -5
- package/package.json +1 -1
- package/sdk/core-dist/config.js +12 -3
- package/sdk/core-dist/profiles.js +5 -1
- package/sdk/core-dist/redact.js +64 -0
- package/sdk/dist/node/gitvault-publication.d.ts +49 -0
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +105 -17
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.d.ts +8 -0
- package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.js +20 -0
- package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
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: ${
|
|
538
|
-
: `Unknown command: ${
|
|
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:
|
|
545
|
+
details: { command: shownCmd, closest: closest ? [closest] : [] },
|
|
541
546
|
});
|
|
542
547
|
}
|
|
543
548
|
}
|
package/core-dist/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/core-dist/profiles.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/gitvault-surface.json
CHANGED
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: ${
|
|
441
|
-
: `Unknown ${displayLabel} subcommand: ${
|
|
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:
|
|
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
|
},
|
package/lib/org-context.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/rooms-context.mjs
CHANGED
|
@@ -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 {
|
package/lib/wallet-context.mjs
CHANGED
|
@@ -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
|
-
:
|
|
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 '${
|
|
267
|
+
message: `No local wallet named '${shown}'.`,
|
|
241
268
|
hint,
|
|
242
|
-
details: { wallet:
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/sdk/core-dist/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|