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/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/gitvault-daemon.mjs +45 -8
- package/lib/org-context.mjs +8 -2
- package/lib/remote-helper-session.mjs +153 -11
- package/lib/repos.mjs +79 -5
- 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/namespaces/gitvault.d.ts +30 -3
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +99 -10
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +67 -1
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +60 -2
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/index.d.ts +2 -2
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +1 -1
- package/sdk/dist/node/index.js.map +1 -1
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
|
|
1367
|
-
min: 1, max:
|
|
1368
|
-
missing: "Missing <policy>. Expected `required` or `
|
|
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)
|
|
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");
|
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.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": {
|
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;
|
|
@@ -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
|