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/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/gitvault-daemon.mjs
CHANGED
|
@@ -89,7 +89,7 @@ export async function runDaemon() {
|
|
|
89
89
|
socket.setNoDelay(true);
|
|
90
90
|
armIdle();
|
|
91
91
|
let buf = "";
|
|
92
|
-
let session = null; // { stdin, restoreEnv, restoreCwd, restoreWrites }
|
|
92
|
+
let session = null; // { stdin, restoreEnv, restoreCwd, restoreWrites, backgroundWork }
|
|
93
93
|
|
|
94
94
|
const send = (obj) => {
|
|
95
95
|
try {
|
|
@@ -99,22 +99,47 @@ export async function runDaemon() {
|
|
|
99
99
|
}
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
/**
|
|
103
|
+
* gitvault-checkpoint-cadence design D2: `session.backgroundWork` — set
|
|
104
|
+
* by `onBackgroundWork` below when a push's auto-gc cycle was handed
|
|
105
|
+
* off — is a promise this daemon keeps itself alive for BEFORE
|
|
106
|
+
* restoring env/cwd/`busy`, even though the CLIENT already got its
|
|
107
|
+
* `exit` and the socket is already ending. `session` is cleared and
|
|
108
|
+
* write-redirection restored immediately and SYNCHRONOUSLY (so a stray
|
|
109
|
+
* log line from the background cycle lands on the daemon's own
|
|
110
|
+
* stdout/stderr, never an attempt to write into an already-closing
|
|
111
|
+
* socket) — only `busy`/env/cwd/idle-arming wait on the promise. This
|
|
112
|
+
* is what "keep the daemon alive through it" means structurally:
|
|
113
|
+
* `busy` stays `true` (refusing a new `hello`, and holding off
|
|
114
|
+
* idle-exit) for the ENTIRE compaction, not just the git protocol
|
|
115
|
+
* exchange that preceded it — the single-session invariant this
|
|
116
|
+
* module's whole design rests on would otherwise race a NEW session's
|
|
117
|
+
* env/cwd swap against the still-running compaction's own SDK calls.
|
|
118
|
+
* `backgroundWork` itself never rejects (`maybeRunAutoGc` swallows the
|
|
119
|
+
* cycle's own failure before handing the promise off) — the `catch`
|
|
120
|
+
* here is belt-and-suspenders, not a real error path.
|
|
121
|
+
*/
|
|
122
|
+
const teardown = async () => {
|
|
103
123
|
if (!session) return;
|
|
104
124
|
const s = session;
|
|
105
125
|
session = null;
|
|
106
|
-
busy = false;
|
|
107
126
|
try {
|
|
108
127
|
s.stdin.end();
|
|
109
128
|
} catch {
|
|
110
129
|
/* already ended */
|
|
111
130
|
}
|
|
112
131
|
s.restoreWrites();
|
|
132
|
+
if (s.backgroundWork) {
|
|
133
|
+
await s.backgroundWork.catch(() => undefined);
|
|
134
|
+
}
|
|
135
|
+
busy = false;
|
|
113
136
|
s.restoreEnv();
|
|
114
137
|
s.restoreCwd();
|
|
115
138
|
armIdle();
|
|
116
139
|
};
|
|
117
|
-
socket.on("close",
|
|
140
|
+
socket.on("close", () => {
|
|
141
|
+
void teardown();
|
|
142
|
+
});
|
|
118
143
|
socket.on("error", () => socket.destroy());
|
|
119
144
|
|
|
120
145
|
socket.on("data", (chunk) => {
|
|
@@ -186,18 +211,30 @@ export async function runDaemon() {
|
|
|
186
211
|
process.stdout.write = realOut;
|
|
187
212
|
process.stderr.write = realErr;
|
|
188
213
|
};
|
|
189
|
-
session = { stdin, restoreEnv, restoreCwd, restoreWrites };
|
|
214
|
+
session = { stdin, restoreEnv, restoreCwd, restoreWrites, backgroundWork: null };
|
|
190
215
|
send({ t: "ready" });
|
|
191
|
-
|
|
216
|
+
// gitvault-checkpoint-cadence design D2: a push's auto-gc cycle
|
|
217
|
+
// (if triggered) hands its ALREADY-STARTED promise here instead
|
|
218
|
+
// of being awaited inline — `runHelperSession` itself still
|
|
219
|
+
// resolves at push speed, so `exit`/`socket.end()` below reach
|
|
220
|
+
// the client immediately; `teardown()` is what actually waits on
|
|
221
|
+
// it (see its own doc comment) before this daemon looks idle or
|
|
222
|
+
// accepts a new session.
|
|
223
|
+
runHelperSession(Array.isArray(msg.argv) ? msg.argv : [], {
|
|
224
|
+
stdin,
|
|
225
|
+
onBackgroundWork: (promise) => {
|
|
226
|
+
if (session) session.backgroundWork = promise;
|
|
227
|
+
},
|
|
228
|
+
})
|
|
192
229
|
.then((code) => {
|
|
193
|
-
teardown();
|
|
194
230
|
send({ t: "exit", code });
|
|
195
231
|
socket.end();
|
|
232
|
+
void teardown();
|
|
196
233
|
})
|
|
197
234
|
.catch(() => {
|
|
198
|
-
teardown();
|
|
199
235
|
send({ t: "exit", code: 1 });
|
|
200
236
|
socket.end();
|
|
237
|
+
void teardown();
|
|
201
238
|
});
|
|
202
239
|
} else if (msg.t === "in") {
|
|
203
240
|
session?.stdin.write(Buffer.from(msg.d, "base64"));
|
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
|
}
|
|
@@ -126,7 +126,7 @@ const nodeModP = import("#sdk/node");
|
|
|
126
126
|
const { getSdk } = await sdkModP;
|
|
127
127
|
const { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } = await walletModP;
|
|
128
128
|
const { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } = await isoModP;
|
|
129
|
-
const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo } = await nodeModP;
|
|
129
|
+
const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo, readGitvaultRestoreMarker, readGitvaultAutoGcThreshold } = await nodeModP;
|
|
130
130
|
|
|
131
131
|
/** The session's input stream — injected per session (daemon: the socket's forwarded stdin). */
|
|
132
132
|
let sessionStdin = process.stdin;
|
|
@@ -339,7 +339,20 @@ export function chooseGitvaultHeadTargetForPush({ baseHeadTarget, baseRefs, upda
|
|
|
339
339
|
return { head_target: { kind: "symref", ref: chosen }, note };
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
-
|
|
342
|
+
/**
|
|
343
|
+
* gitvault-checkpoint-cadence design D1 — the pure post-push auto-gc
|
|
344
|
+
* threshold decision, exported standalone so the threshold matrix (below /
|
|
345
|
+
* at / disabled) is directly unit-testable without the session's heavier
|
|
346
|
+
* closures (network, git, daemon plumbing). `0` (or anything not a
|
|
347
|
+
* positive finite number — a corrupt local config value) means disabled;
|
|
348
|
+
* `generationsSinceCheckpoint >= threshold` is the trigger (matches
|
|
349
|
+
* `gitvaultCheckpointStaleness`'s own `since >= THRESHOLD` shape).
|
|
350
|
+
*/
|
|
351
|
+
export function shouldRunAutoGc(threshold, generationsSinceCheckpoint) {
|
|
352
|
+
return Number.isFinite(threshold) && threshold > 0 && Number.isFinite(generationsSinceCheckpoint) && generationsSinceCheckpoint >= threshold;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function main(argv, { onBackgroundWork } = {}) {
|
|
343
356
|
// gitvault-connection-amortization (bench P5) note: the prewarm now fires
|
|
344
357
|
// at the module TOP, before the SDK graph loads (gitvault-startup-
|
|
345
358
|
// amortization D1) — connection dial and signer warmup both race module
|
|
@@ -511,13 +524,29 @@ async function main(argv) {
|
|
|
511
524
|
// checkout) — `list` still works, and there is nothing for `push` to
|
|
512
525
|
// share later in that case.
|
|
513
526
|
}
|
|
527
|
+
// gitvault-session-state-reuse design D2: read the restore MARKER —
|
|
528
|
+
// NEVER the chain-trust pin (see `tryStateFastPath`'s own doc comment on
|
|
529
|
+
// why the pin is the wrong `since` for a standing clone that is
|
|
530
|
+
// generations behind) — once, here, and thread it to the materialize
|
|
531
|
+
// call below AND to `fetch`'s reuse below. Best-effort: a marker-read
|
|
532
|
+
// failure (no repository, no prior restore) just means no `since` is
|
|
533
|
+
// sent, exactly today's behavior.
|
|
534
|
+
let fetchMarker = null;
|
|
535
|
+
if (repoDir) {
|
|
536
|
+
try {
|
|
537
|
+
fetchMarker = await readGitvaultRestoreMarker(repoDir);
|
|
538
|
+
} catch {
|
|
539
|
+
fetchMarker = null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const materializeOpts = fetchMarker ? { deltaSince: fetchMarker.generation } : {};
|
|
514
543
|
let vault;
|
|
515
544
|
let state;
|
|
516
545
|
try {
|
|
517
546
|
const opened = await openVault(repoDir ?? undefined);
|
|
518
547
|
vault = opened.vault;
|
|
519
548
|
try {
|
|
520
|
-
state = await vault.materialize();
|
|
549
|
+
state = await vault.materialize(materializeOpts);
|
|
521
550
|
} catch (err) {
|
|
522
551
|
// An OFFLINE (id-carrying pin) resolution discovers a stale pin on
|
|
523
552
|
// its FIRST repo-scoped read (client-surface spec, id-pinning
|
|
@@ -532,7 +561,7 @@ async function main(argv) {
|
|
|
532
561
|
if (!recovered) throw err;
|
|
533
562
|
note(`pinned vault ${opened.resolution.repo_id} no longer resolves — re-resolved to ${recovered.resolution.repo_id}, retrying`);
|
|
534
563
|
vault = recovered.handle.vault;
|
|
535
|
-
state = await vault.materialize();
|
|
564
|
+
state = await vault.materialize(materializeOpts);
|
|
536
565
|
}
|
|
537
566
|
} catch (err) {
|
|
538
567
|
// An unallocated vault is not an error here: `list` is the read half of
|
|
@@ -549,7 +578,12 @@ async function main(argv) {
|
|
|
549
578
|
}
|
|
550
579
|
throw err;
|
|
551
580
|
}
|
|
552
|
-
|
|
581
|
+
// gitvault-session-state-reuse design D1: `fetchState`/`fetchMarker`
|
|
582
|
+
// extend the SAME session-scoped handoff `push` already reuses (`base`)
|
|
583
|
+
// — the `fetch` phase of THIS session reuses this response instead of
|
|
584
|
+
// issuing its own state read. Session-scoped only: dropped the moment a
|
|
585
|
+
// push admits in this same session (see `runPush`'s reset below).
|
|
586
|
+
if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, base: state, fetchMarker, fetchState: state };
|
|
553
587
|
const refs = state.refs ?? {};
|
|
554
588
|
for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
|
|
555
589
|
// A snapshot-only vault holds protocol refs but no branch heads, so a
|
|
@@ -586,7 +620,17 @@ async function main(argv) {
|
|
|
586
620
|
// wherever clone was run FROM, unrelated to the target repository).
|
|
587
621
|
applyWalletForDir(repoDir);
|
|
588
622
|
if (verbosity >= 1) note(`restoring the vault object database for ${batch.length} ref(s) into ${repoDir}`);
|
|
589
|
-
|
|
623
|
+
// gitvault-session-state-reuse design D1/D4: reuse THIS session's `list`
|
|
624
|
+
// phase state — same resolved repository AND wallet, exactly the same
|
|
625
|
+
// matching rule `push`'s own reuse uses above — instead of a second
|
|
626
|
+
// network state read. Any mismatch (no prior `list`, a failed list, a
|
|
627
|
+
// different repository/wallet, or a push that already admitted in this
|
|
628
|
+
// same session — see `runPush`'s reset) falls back to the vault's own
|
|
629
|
+
// read, unchanged.
|
|
630
|
+
const shared = sharedListSession && sharedListSession.repoDir === repoDir && sharedListSession.walletName === (resolvedWallet?.name ?? null) ? sharedListSession : null;
|
|
631
|
+
const restored = shared
|
|
632
|
+
? await shared.vault.restoreObjectsInto(repoDir, { marker: shared.fetchMarker, state: shared.fetchState })
|
|
633
|
+
: await getSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
|
|
590
634
|
if (verbosity >= 1) note(`restored generation ${restored.generation}`);
|
|
591
635
|
// clone-installs-retained-refs D3: a bookkeeping failure here degrades to
|
|
592
636
|
// exactly today's (pre-change) behavior — one stderr note, fetch still
|
|
@@ -600,8 +644,81 @@ async function main(argv) {
|
|
|
600
644
|
return 0;
|
|
601
645
|
}
|
|
602
646
|
|
|
647
|
+
/**
|
|
648
|
+
* gitvault-checkpoint-cadence (design D1/D2) — the post-push auto-gc
|
|
649
|
+
* cadence, `git gc --auto`'s shape: a cheap local threshold check after
|
|
650
|
+
* every successful push, maintenance only past it.
|
|
651
|
+
*
|
|
652
|
+
* MUST be called strictly AFTER the push's own `ok`/`error` lines and
|
|
653
|
+
* `endBlock()` have already been written — auto-gc can never fail, slow,
|
|
654
|
+
* or reorder the push it follows (this function itself never throws).
|
|
655
|
+
* `generationsSinceCheckpoint` comes from `published.checkpoint_staleness`
|
|
656
|
+
* (the push's own already-materialized chain) — zero extra reads to learn
|
|
657
|
+
* it.
|
|
658
|
+
*
|
|
659
|
+
* Threshold: `repoDir`'s local `auto_gc_generations` (default 32, `0`
|
|
660
|
+
* disables — `readGitvaultAutoGcThreshold`, design D1's "rides the
|
|
661
|
+
* existing vault policy surface" as a per-checkout `repos policy` knob).
|
|
662
|
+
*
|
|
663
|
+
* Dispatch: with a daemon-supplied `onBackgroundWork`, the compaction is
|
|
664
|
+
* STARTED immediately and handed off as a promise — the daemon keeps
|
|
665
|
+
* itself alive until it settles, but THIS call returns immediately so the
|
|
666
|
+
* client-visible session (and the user's prompt) is not held up. Without
|
|
667
|
+
* one (the in-process fallback), the SAME cycle is awaited right here,
|
|
668
|
+
* with one stderr advisory line naming the wait.
|
|
669
|
+
*
|
|
670
|
+
* `compact()` (namespace, `sdk/src/namespaces/gitvault.ts`) already owns
|
|
671
|
+
* the compaction headroom grant's open/close and single-flight refusal
|
|
672
|
+
* (`GITVAULT_COMPACTION_IN_PROGRESS`) — this function only decides WHETHER
|
|
673
|
+
* and HOW to run the cycle, never re-implements those. `--force-headroom`
|
|
674
|
+
* is NEVER passed on this path (design D5) — an insufficient-headroom
|
|
675
|
+
* refusal, a conflicting in-flight compaction, or any other failure all
|
|
676
|
+
* degrade identically: one advisory line naming `run402 repos gc`, never
|
|
677
|
+
* a thrown error, never a retry loop.
|
|
678
|
+
*/
|
|
679
|
+
async function maybeRunAutoGc({ repoDir, generationsSinceCheckpoint }) {
|
|
680
|
+
let threshold;
|
|
681
|
+
try {
|
|
682
|
+
threshold = await readGitvaultAutoGcThreshold(repoDir);
|
|
683
|
+
} catch {
|
|
684
|
+
return; // never let a local-config read failure touch the push it follows
|
|
685
|
+
}
|
|
686
|
+
if (!shouldRunAutoGc(threshold, generationsSinceCheckpoint)) return;
|
|
687
|
+
|
|
688
|
+
const runCycle = async () => {
|
|
689
|
+
const sdk = getSdk();
|
|
690
|
+
const checkpoint = await sdk.gitvault.compact({ ...target });
|
|
691
|
+
const prune = await sdk.gitvault.prune(target); // PLAN only — never `submit`; see compact()'s own grant-close doc comment for why prune needs no headroom of its own
|
|
692
|
+
return { checkpoint, prune };
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
if (typeof onBackgroundWork === "function") {
|
|
696
|
+
// Daemon host: start it now, hand the PROMISE off, return immediately.
|
|
697
|
+
// Failures are swallowed here (best-effort, matching the fallback
|
|
698
|
+
// branch's degrade contract) — there is no live client to advise by
|
|
699
|
+
// the time this settles, and the daemon's own teardown does not
|
|
700
|
+
// depend on the outcome, only on the promise SETTLING.
|
|
701
|
+
const cyclePromise = runCycle().catch(() => undefined);
|
|
702
|
+
onBackgroundWork(cyclePromise);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// In-process fallback: the extra wall time is real, so name it.
|
|
707
|
+
note(`gitvault: compacting (${generationsSinceCheckpoint} generations since checkpoint)…`);
|
|
708
|
+
try {
|
|
709
|
+
await runCycle();
|
|
710
|
+
} catch (err) {
|
|
711
|
+
note(`gitvault: auto-compaction stopped short — ${describeError(err)} — run \`run402 repos gc\` to finish it by hand.`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
603
715
|
async function runPush(batch) {
|
|
604
716
|
const specs = batch.map(parsePushSpec);
|
|
717
|
+
// gitvault-checkpoint-cadence: set ONLY after a successful admission
|
|
718
|
+
// (never in the catch block) — `null` means "auto-gc has nothing to
|
|
719
|
+
// do", which is also the correct value for every early-return path
|
|
720
|
+
// above (nothing pushed, nothing refused-only, dry-run).
|
|
721
|
+
let autoGcCandidate = null;
|
|
605
722
|
// D4: `refs/r402/*` is client-local — refuse it per-ref, BEFORE any
|
|
606
723
|
// repository/wallet/network work, while unrelated branch updates in the
|
|
607
724
|
// SAME push proceed normally (client-surface spec's own scenario).
|
|
@@ -721,14 +838,26 @@ async function main(argv) {
|
|
|
721
838
|
base,
|
|
722
839
|
...(headFix.head_target ? { head_target: headFix.head_target } : {}),
|
|
723
840
|
});
|
|
841
|
+
// gitvault-session-state-reuse design D4: this admission just advanced
|
|
842
|
+
// the vault, so the `list` phase's handoff (if any) is now stale —
|
|
843
|
+
// drop it. A `fetch` later in this SAME session (an unusual ordering
|
|
844
|
+
// git's own protocol does not normally produce) reads fresh rather
|
|
845
|
+
// than reusing pre-admission state.
|
|
846
|
+
sharedListSession = null;
|
|
724
847
|
if (verbosity >= 1) note(`published generation ${published.generation} (${published.form})`);
|
|
725
|
-
// gitvault-clone-scaling (P3): advisory only —
|
|
726
|
-
//
|
|
727
|
-
//
|
|
848
|
+
// gitvault-clone-scaling (P3): advisory only — informational, always
|
|
849
|
+
// fires at 25 generations regardless of the SEPARATE auto-gc
|
|
850
|
+
// threshold below (design D1's `auto_gc_generations`, default 32) —
|
|
851
|
+
// the two are deliberately different numbers for different purposes.
|
|
728
852
|
if (published.checkpoint_staleness?.advised) {
|
|
729
853
|
note(`${published.checkpoint_staleness.generations_since_checkpoint} generations since the last checkpoint — cold clones re-verify each one; run402 repos gc compacts them`);
|
|
730
854
|
}
|
|
731
855
|
for (const spec of allowed) out(`ok ${spec.dst}`);
|
|
856
|
+
// gitvault-checkpoint-cadence: captured here (never in the catch
|
|
857
|
+
// block below — auto-gc must never fire on a failed push) and acted
|
|
858
|
+
// on AFTER this function's own `endBlock()`, so the auto-gc check
|
|
859
|
+
// itself can never delay, alter, or reorder the push's own report.
|
|
860
|
+
autoGcCandidate = { repoDir, generationsSinceCheckpoint: published.checkpoint_staleness?.generations_since_checkpoint ?? 0 };
|
|
732
861
|
} catch (err) {
|
|
733
862
|
// The transaction is atomic, so a failure failed every ref in it. Report
|
|
734
863
|
// it against each one rather than letting some look like they landed.
|
|
@@ -742,6 +871,10 @@ async function main(argv) {
|
|
|
742
871
|
for (const spec of allowed) out(`error ${spec.dst} ${reason}`);
|
|
743
872
|
}
|
|
744
873
|
endBlock();
|
|
874
|
+
// gitvault-checkpoint-cadence design D1/D2: strictly AFTER the push's
|
|
875
|
+
// own report — `null` (a failed push, a refused-only batch, dry-run)
|
|
876
|
+
// is a silent no-op. `maybeRunAutoGc` itself never throws.
|
|
877
|
+
if (autoGcCandidate) await maybeRunAutoGc(autoGcCandidate);
|
|
745
878
|
return 0;
|
|
746
879
|
}
|
|
747
880
|
|
|
@@ -852,12 +985,21 @@ async function main(argv) {
|
|
|
852
985
|
* serving sequential sessions never leaks one invocation's resolution into
|
|
853
986
|
* the next (D2). Never throws — the error path is the same
|
|
854
987
|
* note-and-exit-1 the standalone binary always had.
|
|
988
|
+
*
|
|
989
|
+
* `onBackgroundWork` (gitvault-checkpoint-cadence design D2): when supplied
|
|
990
|
+
* (the DAEMON host only — see `gitvault-daemon.mjs`), a successful push's
|
|
991
|
+
* auto-gc cycle is handed to it as an ALREADY-STARTED promise instead of
|
|
992
|
+
* being awaited inline, so this call resolves (and the client-visible
|
|
993
|
+
* session ends) at push speed. The caller is responsible for keeping
|
|
994
|
+
* whatever this promise needs alive (its own connections, its own process)
|
|
995
|
+
* until the promise settles. Absent (the in-process fallback host), the
|
|
996
|
+
* SAME cycle is awaited inline instead — see `maybeRunAutoGc` below.
|
|
855
997
|
*/
|
|
856
|
-
export async function runHelperSession(argv, { stdin } = {}) {
|
|
998
|
+
export async function runHelperSession(argv, { stdin, onBackgroundWork } = {}) {
|
|
857
999
|
sessionStdin = stdin ?? process.stdin;
|
|
858
1000
|
resolvedWallet = null;
|
|
859
1001
|
try {
|
|
860
|
-
return await main(argv);
|
|
1002
|
+
return await main(argv, { onBackgroundWork });
|
|
861
1003
|
} catch (err) {
|
|
862
1004
|
note(describeError(err));
|
|
863
1005
|
return 1;
|