run402 4.59.0 → 4.60.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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.59.0",
2
+ "surface_version": "4.60.0",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Daemon identity + transport addressing (gitvault-persistent-helper D4/D5)
3
+ * — the ONE module the thin client's fast path may import beside node
4
+ * builtins (pinned by the import-graph gate test), so it must stay tiny.
5
+ *
6
+ * The socket lives INSIDE the client configuration directory — the same
7
+ * 0700 trust boundary that already holds the allowance private key and the
8
+ * gitvault keystore — and its path is keyed by CLI version, so a CLI
9
+ * upgrade resolves a NEW path: the new client never reaches the old daemon,
10
+ * which idles out on its own. Windows uses a named pipe whose name carries
11
+ * a hash of the config dir (pipes have no filesystem home) plus the same
12
+ * version key.
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { homedir, tmpdir, userInfo } from "node:os";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { createHash } from "node:crypto";
19
+
20
+ /** Mirrors core config's base-dir resolution without importing its graph. */
21
+ export function configBaseDir() {
22
+ return process.env.RUN402_CONFIG_DIR || join(homedir(), ".config", "run402");
23
+ }
24
+
25
+ let cachedVersion = null;
26
+ export function cliVersion() {
27
+ if (cachedVersion) return cachedVersion;
28
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
29
+ cachedVersion = String(pkg.version);
30
+ return cachedVersion;
31
+ }
32
+
33
+ /** Bump when the client↔daemon frame protocol changes shape. */
34
+ export const DAEMON_PROTOCOL_VERSION = 1;
35
+
36
+ /**
37
+ * Unix domain sockets cap `sun_path` around 104 bytes (macOS) — a long
38
+ * RUN402_CONFIG_DIR (test tmpdirs, deep homes) makes the config-dir socket
39
+ * un-listenable, which the fallback would silently mask forever. So the
40
+ * home is the config dir when the path FITS, else an owner-only per-user
41
+ * directory under tmpdir whose socket NAME still keys on the config dir —
42
+ * the trust boundary (0700 dir the daemon creates and verifies) travels
43
+ * with it either way.
44
+ */
45
+ const SUN_PATH_BUDGET = 100;
46
+
47
+ function fallbackDir() {
48
+ let who;
49
+ try {
50
+ who = String(userInfo().uid ?? userInfo().username);
51
+ } catch {
52
+ who = "u";
53
+ }
54
+ return join(tmpdir(), `run402-gvd-${who}`);
55
+ }
56
+
57
+ /** The socket's full address — preferred home when it fits the budget, else the owner-only fallback with a config-keyed name. */
58
+ function resolveSocket() {
59
+ const version = cliVersion().replace(/[^0-9A-Za-z.]/g, "_");
60
+ if (process.platform === "win32") {
61
+ const key = createHash("sha256").update(configBaseDir()).digest("hex").slice(0, 12);
62
+ return { dir: null, path: `\\\\.\\pipe\\run402-gvd-${key}-${version}` };
63
+ }
64
+ const preferredDir = join(configBaseDir(), "daemon");
65
+ const preferred = join(preferredDir, `gv-${version}.sock`);
66
+ if (Buffer.byteLength(preferred) <= SUN_PATH_BUDGET) return { dir: preferredDir, path: preferred };
67
+ const dir = fallbackDir();
68
+ const key = createHash("sha256").update(configBaseDir()).digest("hex").slice(0, 12);
69
+ return { dir, path: join(dir, `g${key}${version.replace(/\./g, "")}.sock`) };
70
+ }
71
+
72
+ /** Directory holding the socket (created 0700 by the daemon). */
73
+ export function daemonDir() {
74
+ return resolveSocket().dir;
75
+ }
76
+
77
+ export function daemonSocketPath() {
78
+ return resolveSocket().path;
79
+ }
80
+
81
+ export function daemonRunnerPath() {
82
+ return join(dirname(fileURLToPath(import.meta.url)), "gitvault-daemon-run.mjs");
83
+ }
84
+
85
+ /**
86
+ * The env allowlist a session forwards (D1): everything run402- or
87
+ * git-shaped, plus the locale pair git itself respects. Deliberately NOT
88
+ * the whole environment — the daemon runs with its own HOME/PATH (same
89
+ * user, same machine), and forwarding arbitrary env would make the daemon's
90
+ * behavior depend on whichever client spoke last in ways nothing re-reads.
91
+ */
92
+ export function forwardableEnv(env) {
93
+ const out = {};
94
+ for (const [k, v] of Object.entries(env)) {
95
+ if (v === undefined) continue;
96
+ if (k.startsWith("RUN402_") || k.startsWith("GIT_") || k === "LC_ALL" || k === "LANG") out[k] = v;
97
+ }
98
+ return out;
99
+ }
package/lib/doctor.mjs CHANGED
@@ -602,6 +602,50 @@ export async function run(sub, args = []) {
602
602
  // all of that (the resolver's own top tier), same as every other gitvault
603
603
  // verb's `--project`.
604
604
  if (wanted("gitvault")) {
605
+ // gitvault-persistent-helper: a bounded LOCAL probe of the resident
606
+ // helper engine — {running:false} is a fine answer, never a finding
607
+ // (the daemon is an accelerator, not a dependency).
608
+ const daemonInfo = await (async () => {
609
+ try {
610
+ const { daemonSocketPath } = await import("./daemon-path.mjs");
611
+ const { connect: netConnect } = await import("node:net");
612
+ return await new Promise((resolve) => {
613
+ let settled = false;
614
+ const done = (v) => {
615
+ if (!settled) {
616
+ settled = true;
617
+ resolve(v);
618
+ }
619
+ };
620
+ const socket = netConnect(daemonSocketPath());
621
+ const timer = setTimeout(() => {
622
+ socket.destroy();
623
+ done({ running: false });
624
+ }, 500);
625
+ let data = "";
626
+ socket.on("data", (c) => {
627
+ data += c.toString("utf8");
628
+ const nl = data.indexOf("\n");
629
+ if (nl === -1) return;
630
+ clearTimeout(timer);
631
+ try {
632
+ const { t: _t, ...rest } = JSON.parse(data.slice(0, nl));
633
+ done({ running: true, ...rest });
634
+ } catch {
635
+ done({ running: false });
636
+ }
637
+ socket.end();
638
+ });
639
+ socket.once("error", () => {
640
+ clearTimeout(timer);
641
+ done({ running: false });
642
+ });
643
+ socket.once("connect", () => socket.write('{"t":"status"}\n'));
644
+ });
645
+ } catch {
646
+ return { running: false };
647
+ }
648
+ })();
605
649
  const target = await resolveGitvaultTarget({ repoDir: process.cwd(), explicitProjectId: projectOverride ?? undefined });
606
650
  const projectId = target.project_id ?? null;
607
651
  const repoId = target.repo_id ?? null;
@@ -633,6 +677,7 @@ export async function run(sub, args = []) {
633
677
  // already proved a second covering recipient, so the hint switches
634
678
  // to the durability sentence instead of the terminal-loss claim.
635
679
  covering_recipients: gv.covering_recipients ?? null,
680
+ daemon: daemonInfo,
636
681
  };
637
682
  const gaps = [];
638
683
  // The one that actually breaks the next deploy: the project demands a
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Daemon process entry (gitvault-persistent-helper) — spawned detached by
3
+ * the thin client's fallback path; never invoked by users directly. Enables
4
+ * the compile cache (the daemon compiles the heavy graph exactly once, so
5
+ * this mostly benefits the NEXT daemon after an upgrade), then runs the
6
+ * listener. A second copy losing the listen race exits quietly — the socket
7
+ * winner serves everyone.
8
+ */
9
+ import * as nodeModule from "node:module";
10
+
11
+ try {
12
+ nodeModule.enableCompileCache?.();
13
+ } catch {
14
+ /* silent by contract */
15
+ }
16
+
17
+ const { runDaemon } = await import("./gitvault-daemon.mjs");
18
+ try {
19
+ await runDaemon();
20
+ } catch {
21
+ process.exit(0);
22
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The resident helper engine (gitvault-persistent-helper D2–D6).
3
+ *
4
+ * Holds the WARM things — the loaded session module (whose top-level await
5
+ * pulls the whole SDK graph), the owned dispatcher's live h2 connection,
6
+ * signer precomputation, the paid stack — and re-reads the SMALL things
7
+ * (keystore, pins, wallet binding, allowance, env-derived config) fresh per
8
+ * forwarded session, so no cache-invalidation class exists (D2).
9
+ *
10
+ * SINGLE-SESSION BY DESIGN (D6 as applied): a session is {cwd, env}
11
+ * applied to THIS process (`process.chdir` + an env allowlist swap), and
12
+ * the SDK reads both at call time throughout — two concurrent sessions
13
+ * with different cwd/env would race process-global state. Concurrent git
14
+ * helpers are rare; a `busy` rejection makes the second client fall back
15
+ * in-process, which is exactly today's behavior. Correct and boring beats
16
+ * clever here.
17
+ *
18
+ * Lifecycle (D4): idle exit after 15 minutes; the socket path is keyed by
19
+ * CLI version so an upgraded client never reaches this daemon; a `hello`
20
+ * carrying a different version or protocol is rejected (the client falls
21
+ * back and spawns a fresh daemon at ITS path). Trust boundary (D5): the
22
+ * socket lives in the 0700 config dir, 0600 where the OS honors socket
23
+ * modes; no network listener exists or ever will in this module.
24
+ */
25
+ import net from "node:net";
26
+ import { mkdirSync, unlinkSync, chmodSync } from "node:fs";
27
+ import { daemonDir, daemonSocketPath, DAEMON_PROTOCOL_VERSION, cliVersion, forwardableEnv } from "./daemon-path.mjs";
28
+ import { PassThrough } from "node:stream";
29
+
30
+ const IDLE_EXIT_MS = 15 * 60 * 1000;
31
+
32
+ function frame(obj) {
33
+ return `${JSON.stringify(obj)}\n`;
34
+ }
35
+
36
+ /** Swap the allowlisted env to the session's view; returns a restore fn. */
37
+ function applySessionEnv(sessionEnv) {
38
+ const mine = forwardableEnv(process.env);
39
+ const keys = new Set([...Object.keys(mine), ...Object.keys(sessionEnv)]);
40
+ for (const k of keys) {
41
+ if (k in sessionEnv) process.env[k] = sessionEnv[k];
42
+ else delete process.env[k];
43
+ }
44
+ return () => {
45
+ for (const k of new Set([...keys, ...Object.keys(forwardableEnv(process.env))])) {
46
+ if (k in mine) process.env[k] = mine[k];
47
+ else delete process.env[k];
48
+ }
49
+ };
50
+ }
51
+
52
+ export async function runDaemon() {
53
+ // Load the heavy module ONCE — this is the entire point of residency.
54
+ // Its top-level await pulls the SDK graph; the prewarm dials the API
55
+ // origin so the first forwarded session rides a warm h2 connection.
56
+ const { prewarmGitvaultConnection } = await import("../sdk/dist/node/gitvault-prewarm.js");
57
+ prewarmGitvaultConnection();
58
+ const { runHelperSession } = await import("./remote-helper-session.mjs");
59
+
60
+ const socketPath = daemonSocketPath();
61
+ if (process.platform !== "win32") {
62
+ mkdirSync(daemonDir(), { recursive: true, mode: 0o700 });
63
+ }
64
+
65
+ let busy = false;
66
+ let sessionsServed = 0;
67
+ const startedAt = Date.now();
68
+ let idleTimer = null;
69
+ const armIdle = () => {
70
+ if (idleTimer) clearTimeout(idleTimer);
71
+ idleTimer = setTimeout(() => {
72
+ if (busy) {
73
+ // Never exit under a live session — re-arm and check again later.
74
+ armIdle();
75
+ return;
76
+ }
77
+ try {
78
+ server.close();
79
+ if (process.platform !== "win32") unlinkSync(socketPath);
80
+ } catch {
81
+ /* exiting anyway */
82
+ }
83
+ process.exit(0);
84
+ }, IDLE_EXIT_MS);
85
+ idleTimer.unref?.();
86
+ };
87
+
88
+ const server = net.createServer((socket) => {
89
+ socket.setNoDelay(true);
90
+ armIdle();
91
+ let buf = "";
92
+ let session = null; // { stdin, restoreEnv, restoreCwd, restoreWrites }
93
+
94
+ const send = (obj) => {
95
+ try {
96
+ socket.write(frame(obj));
97
+ } catch {
98
+ /* client gone — session teardown happens on 'close' */
99
+ }
100
+ };
101
+
102
+ const teardown = () => {
103
+ if (!session) return;
104
+ const s = session;
105
+ session = null;
106
+ busy = false;
107
+ try {
108
+ s.stdin.end();
109
+ } catch {
110
+ /* already ended */
111
+ }
112
+ s.restoreWrites();
113
+ s.restoreEnv();
114
+ s.restoreCwd();
115
+ armIdle();
116
+ };
117
+ socket.on("close", teardown);
118
+ socket.on("error", () => socket.destroy());
119
+
120
+ socket.on("data", (chunk) => {
121
+ buf += chunk.toString("utf8");
122
+ for (;;) {
123
+ const nl = buf.indexOf("\n");
124
+ if (nl === -1) return;
125
+ const line = buf.slice(0, nl);
126
+ buf = buf.slice(nl + 1);
127
+ let msg;
128
+ try {
129
+ msg = JSON.parse(line);
130
+ } catch {
131
+ socket.destroy();
132
+ return;
133
+ }
134
+ if (msg.t === "hello") {
135
+ if (msg.proto !== DAEMON_PROTOCOL_VERSION || msg.version !== cliVersion()) {
136
+ send({ t: "reject", reason: "version" });
137
+ socket.end();
138
+ return;
139
+ }
140
+ if (busy) {
141
+ // D6: one session at a time — the client falls back in-process,
142
+ // which is exactly the pre-daemon behavior.
143
+ send({ t: "reject", reason: "busy" });
144
+ socket.end();
145
+ return;
146
+ }
147
+ busy = true;
148
+ sessionsServed += 1;
149
+ const stdin = new PassThrough();
150
+ const restoreEnv = applySessionEnv(msg.env ?? {});
151
+ const prevCwd = process.cwd();
152
+ let restoreCwd = () => {};
153
+ try {
154
+ process.chdir(msg.cwd);
155
+ restoreCwd = () => {
156
+ try {
157
+ process.chdir(prevCwd);
158
+ } catch {
159
+ /* prev dir may be gone; daemon cwd is inert between sessions */
160
+ }
161
+ };
162
+ } catch {
163
+ restoreEnv();
164
+ busy = false;
165
+ send({ t: "reject", reason: "cwd" });
166
+ socket.end();
167
+ return;
168
+ }
169
+ // Redirect BOTH write streams for the session's duration: the
170
+ // session's own out()/note() and every SDK trace/warn line reach
171
+ // the CLIENT, exactly as they reach a standalone helper's pipes.
172
+ const realOut = process.stdout.write.bind(process.stdout);
173
+ const realErr = process.stderr.write.bind(process.stderr);
174
+ const toBuffer = (data, rest) => (typeof data === "string" ? Buffer.from(data, typeof rest[0] === "string" ? rest[0] : "utf8") : Buffer.from(data));
175
+ process.stdout.write = (data, ...rest) => {
176
+ send({ t: "out", d: toBuffer(data, rest).toString("base64") });
177
+ rest.find((a) => typeof a === "function")?.();
178
+ return true;
179
+ };
180
+ process.stderr.write = (data, ...rest) => {
181
+ send({ t: "err", d: toBuffer(data, rest).toString("base64") });
182
+ rest.find((a) => typeof a === "function")?.();
183
+ return true;
184
+ };
185
+ const restoreWrites = () => {
186
+ process.stdout.write = realOut;
187
+ process.stderr.write = realErr;
188
+ };
189
+ session = { stdin, restoreEnv, restoreCwd, restoreWrites };
190
+ send({ t: "ready" });
191
+ runHelperSession(Array.isArray(msg.argv) ? msg.argv : [], { stdin })
192
+ .then((code) => {
193
+ teardown();
194
+ send({ t: "exit", code });
195
+ socket.end();
196
+ })
197
+ .catch(() => {
198
+ teardown();
199
+ send({ t: "exit", code: 1 });
200
+ socket.end();
201
+ });
202
+ } else if (msg.t === "in") {
203
+ session?.stdin.write(Buffer.from(msg.d, "base64"));
204
+ } else if (msg.t === "in_end") {
205
+ session?.stdin.end();
206
+ } else if (msg.t === "status") {
207
+ send({ t: "status", version: cliVersion(), proto: DAEMON_PROTOCOL_VERSION, pid: process.pid, started_at: startedAt, sessions_served: sessionsServed, busy });
208
+ socket.end();
209
+ } else if (msg.t === "stop") {
210
+ send({ t: "stopping" });
211
+ socket.end();
212
+ try {
213
+ server.close();
214
+ if (process.platform !== "win32") unlinkSync(socketPath);
215
+ } catch {
216
+ /* exiting anyway */
217
+ }
218
+ process.exit(0);
219
+ }
220
+ }
221
+ });
222
+ });
223
+
224
+ await new Promise((resolve, reject) => {
225
+ const tryListen = (attempt) => {
226
+ server.once("error", (err) => {
227
+ if (err.code === "EADDRINUSE" && attempt === 0 && process.platform !== "win32") {
228
+ // A live daemon OR a stale socket file from a crash. Probe it: a
229
+ // refused connection means stale — unlink and take the address.
230
+ const probe = net.connect(socketPath);
231
+ probe.once("connect", () => {
232
+ probe.destroy();
233
+ reject(new Error("daemon already running"));
234
+ });
235
+ probe.once("error", () => {
236
+ try {
237
+ unlinkSync(socketPath);
238
+ } catch {
239
+ /* raced */
240
+ }
241
+ tryListen(1);
242
+ });
243
+ } else {
244
+ reject(err);
245
+ }
246
+ });
247
+ server.listen(socketPath, () => {
248
+ if (process.platform !== "win32") {
249
+ try {
250
+ chmodSync(socketPath, 0o600);
251
+ } catch {
252
+ /* the 0700 parent dir is the real boundary */
253
+ }
254
+ }
255
+ resolve();
256
+ });
257
+ };
258
+ tryListen(0);
259
+ });
260
+
261
+ armIdle();
262
+ return server;
263
+ }