grepmax 0.26.27 → 0.26.30
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/.claude-plugin/marketplace.json +1 -1
- package/README.md +40 -0
- package/dist/commands/audit.js +28 -260
- package/dist/commands/context.js +42 -26
- package/dist/commands/dead.js +37 -35
- package/dist/commands/doctor.js +280 -10
- package/dist/commands/extract.js +79 -49
- package/dist/commands/impact.js +74 -20
- package/dist/commands/log.js +25 -25
- package/dist/commands/mcp.js +639 -475
- package/dist/commands/peek.js +49 -47
- package/dist/commands/project.js +29 -107
- package/dist/commands/related.js +43 -52
- package/dist/commands/search-run.js +23 -8
- package/dist/commands/search.js +7 -2
- package/dist/commands/similar.js +50 -76
- package/dist/commands/skeleton.js +137 -83
- package/dist/commands/status.js +90 -25
- package/dist/commands/surprises.js +73 -16
- package/dist/commands/symbols.js +58 -77
- package/dist/commands/test-find.js +51 -17
- package/dist/commands/trace.js +26 -11
- package/dist/commands/watch.js +1 -1
- package/dist/config.js +8 -2
- package/dist/lib/analysis/surprising-connections.js +135 -15
- package/dist/lib/daemon/daemon.js +157 -13
- package/dist/lib/daemon/graph-handler.js +859 -0
- package/dist/lib/daemon/ipc-handler.js +36 -4
- package/dist/lib/daemon/read-verbs.js +156 -0
- package/dist/lib/daemon/rows-handler.js +665 -0
- package/dist/lib/daemon/search-handler.js +7 -4
- package/dist/lib/daemon/vector-handler.js +199 -0
- package/dist/lib/daemon/watch-leases.js +179 -0
- package/dist/lib/index/batch-processor.js +7 -1
- package/dist/lib/index/watcher.js +4 -0
- package/dist/lib/search/pagerank.js +80 -19
- package/dist/lib/store/vector-db.js +35 -1
- package/dist/lib/utils/async-semaphore.js +141 -0
- package/dist/lib/utils/daemon-client.js +11 -0
- package/dist/lib/utils/process-footprint.js +51 -0
- package/dist/lib/utils/store-access.js +381 -0
- package/dist/lib/utils/tests-footer.js +19 -2
- package/dist/lib/utils/watcher-launcher.js +46 -19
- package/dist/lib/workers/pool.js +72 -2
- package/package.json +1 -1
- package/plugins/grepmax/.claude-plugin/plugin.json +1 -1
- package/plugins/grepmax/hooks/cwd-changed.js +19 -36
- package/plugins/grepmax/hooks/start.js +15 -19
- package/plugins/grepmax/hooks/stop.js +13 -4
- package/plugins/grepmax/hooks/watch-lease.js +163 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Counting semaphore with abortable waits, plus a bounded-concurrency map.
|
|
4
|
+
*
|
|
5
|
+
* The daemon serves every MCP session over one socket, and some read verbs are
|
|
6
|
+
* full-table scans whose Arrow buffers live in native memory. Without a cap,
|
|
7
|
+
* N sessions asking at once means N scans resident at once — the footprint that
|
|
8
|
+
* froze a 48 GB machine at 9.6 GB. The semaphore bounds how many run; a waiter
|
|
9
|
+
* whose client disconnects leaves the queue instead of running for nobody.
|
|
10
|
+
*/
|
|
11
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
12
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
13
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
14
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
15
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
16
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
17
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.AsyncSemaphore = void 0;
|
|
22
|
+
exports.mapWithConcurrency = mapWithConcurrency;
|
|
23
|
+
function abortError(reason) {
|
|
24
|
+
if (reason instanceof Error)
|
|
25
|
+
return reason;
|
|
26
|
+
const error = new Error("Aborted");
|
|
27
|
+
error.name = "AbortError";
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
class AsyncSemaphore {
|
|
31
|
+
constructor(capacity) {
|
|
32
|
+
this.capacity = capacity;
|
|
33
|
+
this.queue = [];
|
|
34
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
35
|
+
throw new RangeError(`semaphore capacity must be >= 1, got ${capacity}`);
|
|
36
|
+
}
|
|
37
|
+
this.available = capacity;
|
|
38
|
+
}
|
|
39
|
+
/** Slots currently held. */
|
|
40
|
+
get active() {
|
|
41
|
+
return this.capacity - this.available;
|
|
42
|
+
}
|
|
43
|
+
/** Callers waiting for a slot. */
|
|
44
|
+
get waiting() {
|
|
45
|
+
return this.queue.length;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolve with a release function once a slot is free. Rejects with an
|
|
49
|
+
* AbortError if `signal` fires first; the waiter is removed from the queue,
|
|
50
|
+
* so an abandoned request never takes a slot.
|
|
51
|
+
*/
|
|
52
|
+
acquire(signal) {
|
|
53
|
+
if (signal === null || signal === void 0 ? void 0 : signal.aborted)
|
|
54
|
+
return Promise.reject(abortError(signal.reason));
|
|
55
|
+
if (this.available > 0) {
|
|
56
|
+
this.available--;
|
|
57
|
+
return Promise.resolve(this.releaser());
|
|
58
|
+
}
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const waiter = {
|
|
61
|
+
resolve: () => resolve(this.releaser()),
|
|
62
|
+
reject,
|
|
63
|
+
signal,
|
|
64
|
+
};
|
|
65
|
+
if (signal) {
|
|
66
|
+
waiter.onAbort = () => {
|
|
67
|
+
const index = this.queue.indexOf(waiter);
|
|
68
|
+
if (index !== -1)
|
|
69
|
+
this.queue.splice(index, 1);
|
|
70
|
+
reject(abortError(signal.reason));
|
|
71
|
+
};
|
|
72
|
+
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
73
|
+
}
|
|
74
|
+
this.queue.push(waiter);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Run `fn` while holding a slot; the slot is released however `fn` ends. */
|
|
78
|
+
run(signal, fn) {
|
|
79
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
80
|
+
const release = yield this.acquire(signal);
|
|
81
|
+
try {
|
|
82
|
+
return yield fn();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
release();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
releaser() {
|
|
90
|
+
let released = false;
|
|
91
|
+
return () => {
|
|
92
|
+
var _a;
|
|
93
|
+
if (released)
|
|
94
|
+
return;
|
|
95
|
+
released = true;
|
|
96
|
+
const next = this.queue.shift();
|
|
97
|
+
if (next) {
|
|
98
|
+
// Hand the slot straight to the next waiter; `available` is unchanged.
|
|
99
|
+
if (next.onAbort) {
|
|
100
|
+
(_a = next.signal) === null || _a === void 0 ? void 0 : _a.removeEventListener("abort", next.onAbort);
|
|
101
|
+
}
|
|
102
|
+
next.resolve();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.available++;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
exports.AsyncSemaphore = AsyncSemaphore;
|
|
110
|
+
/**
|
|
111
|
+
* `Promise.all(items.map(fn))` with at most `limit` calls in flight, results in
|
|
112
|
+
* input order. Once `signal` aborts no new item starts, and the call rejects
|
|
113
|
+
* with the abort after the in-flight ones settle.
|
|
114
|
+
*/
|
|
115
|
+
function mapWithConcurrency(items, limit, fn, signal) {
|
|
116
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
117
|
+
const results = new Array(items.length);
|
|
118
|
+
let next = 0;
|
|
119
|
+
let failure = null;
|
|
120
|
+
const worker = () => __awaiter(this, void 0, void 0, function* () {
|
|
121
|
+
while (!failure && next < items.length) {
|
|
122
|
+
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
|
|
123
|
+
failure = { error: abortError(signal.reason) };
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const index = next++;
|
|
127
|
+
try {
|
|
128
|
+
results[index] = yield fn(items[index], index);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
failure !== null && failure !== void 0 ? failure : (failure = { error });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
const width = Math.max(1, Math.min(Math.floor(limit) || 1, items.length));
|
|
136
|
+
yield Promise.all(Array.from({ length: width }, worker));
|
|
137
|
+
if (failure)
|
|
138
|
+
throw failure.error;
|
|
139
|
+
return results;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -273,6 +273,11 @@ function isDaemonDraining(pid) {
|
|
|
273
273
|
* This is the implicit spawn path, so it honors the autostart kill switch: with
|
|
274
274
|
* it on, an already-running daemon is still used, but a dead one is not
|
|
275
275
|
* revived and callers fall back to in-process work.
|
|
276
|
+
*
|
|
277
|
+
* It also refuses to spawn from a sandboxed shell. A daemon inherits its
|
|
278
|
+
* parent's sandbox for its whole life, so one spawned where `~/.gmax` is
|
|
279
|
+
* read-only fails its own lock mkdir, dies, and leaves a confusing crash in
|
|
280
|
+
* daemon.log while this function burns the full readiness timeout.
|
|
276
281
|
*/
|
|
277
282
|
function ensureDaemonRunning() {
|
|
278
283
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -282,6 +287,12 @@ function ensureDaemonRunning() {
|
|
|
282
287
|
const { isAutostartDisabled } = yield Promise.resolve().then(() => __importStar(require("./autostart")));
|
|
283
288
|
if (isAutostartDisabled())
|
|
284
289
|
return false;
|
|
290
|
+
const { storeWriteDeniedNotice, warnStoreWriteDeniedOnce } = yield Promise.resolve().then(() => __importStar(require("./store-access")));
|
|
291
|
+
const denied = storeWriteDeniedNotice();
|
|
292
|
+
if (denied) {
|
|
293
|
+
warnStoreWriteDeniedOnce(denied);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
285
296
|
const { spawnDaemon } = yield Promise.resolve().then(() => __importStar(require("./daemon-launcher")));
|
|
286
297
|
const pid = yield spawnDaemon();
|
|
287
298
|
if (!pid)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readFootprintMb = readFootprintMb;
|
|
4
|
+
exports.parseFootprintMb = parseFootprintMb;
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
/**
|
|
7
|
+
* Physical footprint of a process, in MB — the number Activity Monitor and jetsam
|
|
8
|
+
* use, and the one the daemon's recycle watermark has to read.
|
|
9
|
+
*
|
|
10
|
+
* `process.memoryUsage().rss` is not that number on macOS. RSS only counts pages
|
|
11
|
+
* currently resident, so once the compressor or swap takes a page it drops out:
|
|
12
|
+
* the daemon read 469 MB of RSS while `footprint` reported 4 GB (9.6 GB peak), and
|
|
13
|
+
* the RSS watermark never fired. `footprint -p` counts compressed and swapped dirty
|
|
14
|
+
* memory too, and costs ~50 ms.
|
|
15
|
+
*
|
|
16
|
+
* Off macOS, or when the sample cannot be parsed, falls back to RSS — an
|
|
17
|
+
* underestimate, which only ever delays a recycle, never causes a spurious one.
|
|
18
|
+
*/
|
|
19
|
+
function readFootprintMb(pid = process.pid) {
|
|
20
|
+
if (process.platform === "darwin") {
|
|
21
|
+
try {
|
|
22
|
+
const output = (0, node_child_process_1.execFileSync)("footprint", ["-p", String(pid)], {
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
timeout: 5000,
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
});
|
|
27
|
+
const parsed = parseFootprintMb(output);
|
|
28
|
+
if (parsed != null)
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
catch (_a) { }
|
|
32
|
+
}
|
|
33
|
+
return pid === process.pid ? process.memoryUsage().rss / (1024 * 1024) : 0;
|
|
34
|
+
}
|
|
35
|
+
const UNIT_TO_MB = {
|
|
36
|
+
B: 1 / (1024 * 1024),
|
|
37
|
+
KB: 1 / 1024,
|
|
38
|
+
MB: 1,
|
|
39
|
+
GB: 1024,
|
|
40
|
+
};
|
|
41
|
+
/** Parse the `Footprint: 4232 MB` header line of `footprint -p` output. */
|
|
42
|
+
function parseFootprintMb(output) {
|
|
43
|
+
const match = /Footprint:\s*([\d.]+)\s*(B|KB|MB|GB)\b/.exec(output);
|
|
44
|
+
if (!match)
|
|
45
|
+
return null;
|
|
46
|
+
const value = Number(match[1]);
|
|
47
|
+
const scale = UNIT_TO_MB[match[2]];
|
|
48
|
+
if (!Number.isFinite(value) || scale == null)
|
|
49
|
+
return null;
|
|
50
|
+
return value * scale;
|
|
51
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Client-side access policy for store reads.
|
|
4
|
+
*
|
|
5
|
+
* Every read-only gmax command has three ways to get an answer, and exactly one
|
|
6
|
+
* of them is correct in any given situation:
|
|
7
|
+
*
|
|
8
|
+
* 1. Ask the daemon over `~/.gmax/daemon.sock`. Preferred whenever a daemon
|
|
9
|
+
* is up: the daemon is the only process that should hold the store open.
|
|
10
|
+
* 2. Open the store in-process. Legitimate only when *no daemon exists*
|
|
11
|
+
* (autostart disabled, tests, CI). Opening the store takes a `StoreLease`,
|
|
12
|
+
* which mkdirs under `~/.gmax` — so a "read" is a writer as far as the
|
|
13
|
+
* filesystem is concerned.
|
|
14
|
+
* 3. Refuse. When the process is sandboxed, both of the above fail with an
|
|
15
|
+
* errno that says nothing useful. One actionable line beats a stack trace.
|
|
16
|
+
*
|
|
17
|
+
* `withStoreRead` encodes that decision. The fallback set is deliberately tiny:
|
|
18
|
+
* `ENOENT`/`ECONNREFUSED` mean there is no daemon listening, and nothing else
|
|
19
|
+
* does. A *live* daemon error (DAEMON_BUSY, timeout, a scope rejection, a store
|
|
20
|
+
* failure) must never send the caller into the store concurrently.
|
|
21
|
+
*
|
|
22
|
+
* Sandbox handling: Claude Code's Bash sandbox denies writes under `~/.gmax`
|
|
23
|
+
* and blocks Unix sockets. The socket connect then fails `EPERM`, and the lease
|
|
24
|
+
* mkdir fails `EPERM`/`EACCES`. Both are turned into a refusal carrying the one
|
|
25
|
+
* settings key that fixes it, exit code 2, and no stack.
|
|
26
|
+
*
|
|
27
|
+
* Env switches:
|
|
28
|
+
* GMAX_NO_DAEMON=1 Skip the daemon attempt entirely and run the in-process
|
|
29
|
+
* path. Intended for byte-identical output comparisons
|
|
30
|
+
* between the two paths; it does not relax the sandbox
|
|
31
|
+
* refusal, so a sandboxed shell still gets the lease hint.
|
|
32
|
+
*/
|
|
33
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
34
|
+
if (k2 === undefined) k2 = k;
|
|
35
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
36
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
37
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
38
|
+
}
|
|
39
|
+
Object.defineProperty(o, k2, desc);
|
|
40
|
+
}) : (function(o, m, k, k2) {
|
|
41
|
+
if (k2 === undefined) k2 = k;
|
|
42
|
+
o[k2] = m[k];
|
|
43
|
+
}));
|
|
44
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
45
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
46
|
+
}) : function(o, v) {
|
|
47
|
+
o["default"] = v;
|
|
48
|
+
});
|
|
49
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
50
|
+
var ownKeys = function(o) {
|
|
51
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
52
|
+
var ar = [];
|
|
53
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
54
|
+
return ar;
|
|
55
|
+
};
|
|
56
|
+
return ownKeys(o);
|
|
57
|
+
};
|
|
58
|
+
return function (mod) {
|
|
59
|
+
if (mod && mod.__esModule) return mod;
|
|
60
|
+
var result = {};
|
|
61
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
62
|
+
__setModuleDefault(result, mod);
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
})();
|
|
66
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
67
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
68
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
69
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
70
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
71
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
72
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
76
|
+
exports.StoreAccessRefused = exports.LEASE_DENIED_MESSAGE = exports.SOCKET_DENIED_MESSAGE = void 0;
|
|
77
|
+
exports.socketDeniedMessage = socketDeniedMessage;
|
|
78
|
+
exports.leaseDeniedMessage = leaseDeniedMessage;
|
|
79
|
+
exports.isStoreAccessRefused = isStoreAccessRefused;
|
|
80
|
+
exports.refuseStoreAccess = refuseStoreAccess;
|
|
81
|
+
exports.reportStoreAccessRefusal = reportStoreAccessRefusal;
|
|
82
|
+
exports.classifyDaemonError = classifyDaemonError;
|
|
83
|
+
exports.shouldFallbackFromDaemonError = shouldFallbackFromDaemonError;
|
|
84
|
+
exports.isUnknownCommandError = isUnknownCommandError;
|
|
85
|
+
exports.isOversizeError = isOversizeError;
|
|
86
|
+
exports.classifyLeaseError = classifyLeaseError;
|
|
87
|
+
exports.asStoreAccessError = asStoreAccessError;
|
|
88
|
+
exports.isDaemonBypassed = isDaemonBypassed;
|
|
89
|
+
exports.withStoreRead = withStoreRead;
|
|
90
|
+
exports.probeStoreWritable = probeStoreWritable;
|
|
91
|
+
exports.storeWriteDeniedNotice = storeWriteDeniedNotice;
|
|
92
|
+
exports.warnStoreWriteDeniedOnce = warnStoreWriteDeniedOnce;
|
|
93
|
+
exports.resetStoreWriteDeniedNotice = resetStoreWriteDeniedNotice;
|
|
94
|
+
const fs = __importStar(require("node:fs"));
|
|
95
|
+
const path = __importStar(require("node:path"));
|
|
96
|
+
const config_1 = require("../../config");
|
|
97
|
+
/**
|
|
98
|
+
* Socket-level errors that prove no daemon is listening. These — and only these
|
|
99
|
+
* — permit the in-process path.
|
|
100
|
+
*/
|
|
101
|
+
const NO_DAEMON_ERRORS = new Set(["ENOENT", "ECONNREFUSED"]);
|
|
102
|
+
/** Errnos that mean the sandbox denied the operation, not that it failed. */
|
|
103
|
+
const SANDBOX_ERRNOS = new Set(["EPERM", "EACCES", "EROFS"]);
|
|
104
|
+
exports.SOCKET_DENIED_MESSAGE = 'gmax: cannot reach the daemon socket from this sandbox. Add to Claude Code settings: "sandbox": {"network": {"allowUnixSockets": ["~/.gmax/daemon.sock"]}}';
|
|
105
|
+
exports.LEASE_DENIED_MESSAGE = 'gmax: cannot open the store from this sandbox. Add to Claude Code settings: "sandbox": {"filesystem": {"allowWrite": ["~/.gmax"]}}';
|
|
106
|
+
/** The one line to print when the daemon socket is unreachable from a sandbox. */
|
|
107
|
+
function socketDeniedMessage() {
|
|
108
|
+
return exports.SOCKET_DENIED_MESSAGE;
|
|
109
|
+
}
|
|
110
|
+
/** The one line to print when the store lease cannot be taken from a sandbox. */
|
|
111
|
+
function leaseDeniedMessage() {
|
|
112
|
+
return exports.LEASE_DENIED_MESSAGE;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Refusal to reach the store at all. Carries the actionable line as its
|
|
116
|
+
* message; commands print it verbatim (one line, no stack) and exit 2.
|
|
117
|
+
*/
|
|
118
|
+
class StoreAccessRefused extends Error {
|
|
119
|
+
constructor(kind) {
|
|
120
|
+
super(kind === "socket" ? exports.SOCKET_DENIED_MESSAGE : exports.LEASE_DENIED_MESSAGE);
|
|
121
|
+
this.exitCode = 2;
|
|
122
|
+
this.name = "StoreAccessRefused";
|
|
123
|
+
this.kind = kind;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
exports.StoreAccessRefused = StoreAccessRefused;
|
|
127
|
+
function isStoreAccessRefused(err) {
|
|
128
|
+
return err instanceof StoreAccessRefused;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build the refusal and mark the process failed. Exit code 2 distinguishes
|
|
132
|
+
* "refused, configure your sandbox" from an ordinary command failure (1).
|
|
133
|
+
*/
|
|
134
|
+
function refuseStoreAccess(kind) {
|
|
135
|
+
process.exitCode = 2;
|
|
136
|
+
return new StoreAccessRefused(kind);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Print a refusal as a single line and set the exit code. Returns false when
|
|
140
|
+
* `err` is something else, so callers can chain it into an existing catch:
|
|
141
|
+
*
|
|
142
|
+
* catch (err) { if (reportStoreAccessRefusal(err)) return; ...existing... }
|
|
143
|
+
*/
|
|
144
|
+
function reportStoreAccessRefusal(err) {
|
|
145
|
+
if (!isStoreAccessRefused(err))
|
|
146
|
+
return false;
|
|
147
|
+
console.error(err.message);
|
|
148
|
+
process.exitCode = 2;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
/** Pull an errno-ish string out of whatever the socket layer handed back. */
|
|
152
|
+
function errorCode(error) {
|
|
153
|
+
if (typeof error === "string")
|
|
154
|
+
return error;
|
|
155
|
+
if (error && typeof error === "object") {
|
|
156
|
+
const code = error.code;
|
|
157
|
+
if (typeof code === "string")
|
|
158
|
+
return code;
|
|
159
|
+
const message = error.message;
|
|
160
|
+
if (typeof message === "string")
|
|
161
|
+
return message;
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Classify a daemon-side failure. `error` is either the `error` field of a
|
|
167
|
+
* DaemonResponse (sendDaemonCommand reports `err.code ?? err.message`) or a
|
|
168
|
+
* thrown ErrnoException.
|
|
169
|
+
*
|
|
170
|
+
* - "ok" no error present
|
|
171
|
+
* - "no-daemon" nothing is listening; the in-process path is allowed
|
|
172
|
+
* - "sandboxed" the sandbox blocked the socket; refuse with the socket hint
|
|
173
|
+
* - "daemon-error" a live daemon said no; report it, never fall back
|
|
174
|
+
*/
|
|
175
|
+
function classifyDaemonError(error) {
|
|
176
|
+
if (error === undefined || error === null || error === "")
|
|
177
|
+
return "ok";
|
|
178
|
+
const code = errorCode(error);
|
|
179
|
+
if (!code)
|
|
180
|
+
return "daemon-error";
|
|
181
|
+
if (NO_DAEMON_ERRORS.has(code))
|
|
182
|
+
return "no-daemon";
|
|
183
|
+
if (SANDBOX_ERRNOS.has(code))
|
|
184
|
+
return "sandboxed";
|
|
185
|
+
return "daemon-error";
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* True only for the two socket errors that prove no daemon exists.
|
|
189
|
+
*
|
|
190
|
+
* Kept as a named predicate because it is the exact rule `search-run.ts` has
|
|
191
|
+
* always applied (and re-exports); `unknown command` is deliberately NOT in the
|
|
192
|
+
* set — see `isUnknownCommandError` for the version-skew allowance.
|
|
193
|
+
*/
|
|
194
|
+
function shouldFallbackFromDaemonError(error) {
|
|
195
|
+
return classifyDaemonError(error) === "no-daemon";
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A daemon older than this CLI does not know a newly added verb. Treat that as
|
|
199
|
+
* "no daemon" for one release so an unrestarted daemon does not break the
|
|
200
|
+
* command, then remove the allowance. Mirrors the `search-v2` transition rule.
|
|
201
|
+
*/
|
|
202
|
+
function isUnknownCommandError(error) {
|
|
203
|
+
return typeof error === "string" && error.startsWith("unknown command");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The daemon caps a single response line at 2 MB and answers `oversize` rather
|
|
207
|
+
* than writing a frame no client can parse. It is a *protocol* limit, not a
|
|
208
|
+
* store failure — the daemon is healthy and the store is fine.
|
|
209
|
+
*
|
|
210
|
+
* That is why it is opt-in (`fallbackOnOversize`) and off by default:
|
|
211
|
+
*
|
|
212
|
+
* - **MCP opts in.** An MCP tool has no user at a terminal to re-run a
|
|
213
|
+
* narrower query, and its whole session is one long-lived process; degrading
|
|
214
|
+
* to its own store read returns an answer instead of an error string.
|
|
215
|
+
* - **A CLI refuses.** The caller is a short-lived process at a prompt who can
|
|
216
|
+
* narrow the query, and falling back would put a *second opener* on the
|
|
217
|
+
* store while the daemon is busy enough to have produced a 2 MB answer —
|
|
218
|
+
* exactly the concurrency this routing exists to prevent. One clear error
|
|
219
|
+
* beats a second lease.
|
|
220
|
+
*
|
|
221
|
+
* No graph verb ships a capped response today (the largest measured is 963 KiB,
|
|
222
|
+
* `trace VectorDB -d 3`), so nothing trips this yet; the option exists so MCP's
|
|
223
|
+
* long-standing search behaviour survives the switch-over unchanged.
|
|
224
|
+
*/
|
|
225
|
+
function isOversizeError(error) {
|
|
226
|
+
return typeof error === "string" && error.toLowerCase().includes("oversize");
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Classify a failure from opening the store in-process (the `StoreLease` mkdir,
|
|
230
|
+
* an LMDB `env.open`, a LanceDB write). Sandbox denials become a refusal that
|
|
231
|
+
* names the filesystem settings key; everything else is a real error.
|
|
232
|
+
*/
|
|
233
|
+
function classifyLeaseError(err) {
|
|
234
|
+
const code = err === null || err === void 0 ? void 0 : err.code;
|
|
235
|
+
if (typeof code === "string" && SANDBOX_ERRNOS.has(code))
|
|
236
|
+
return "sandboxed";
|
|
237
|
+
const message = err instanceof Error ? err.message : "";
|
|
238
|
+
// Node fs errors stringify as "EPERM: operation not permitted, mkdir '...'".
|
|
239
|
+
// LMDB reports the same denial as "Operation not permitted: ...".
|
|
240
|
+
if (/^(EPERM|EACCES|EROFS):/.test(message))
|
|
241
|
+
return "sandboxed";
|
|
242
|
+
if (/^Operation not permitted\b/.test(message))
|
|
243
|
+
return "sandboxed";
|
|
244
|
+
return "other";
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Convert a lease/store-open failure into a refusal when the sandbox caused it,
|
|
248
|
+
* otherwise hand the original error back unchanged.
|
|
249
|
+
*/
|
|
250
|
+
function asStoreAccessError(err) {
|
|
251
|
+
if (isStoreAccessRefused(err))
|
|
252
|
+
return err;
|
|
253
|
+
return classifyLeaseError(err) === "sandboxed"
|
|
254
|
+
? refuseStoreAccess("lease")
|
|
255
|
+
: err;
|
|
256
|
+
}
|
|
257
|
+
function defaultDaemonErrorMessage(name, resp) {
|
|
258
|
+
const detail = typeof resp.hint === "string"
|
|
259
|
+
? `: ${resp.hint}`
|
|
260
|
+
: typeof resp.error === "string"
|
|
261
|
+
? `: ${resp.error}`
|
|
262
|
+
: "";
|
|
263
|
+
return `Daemon ${name} failed${detail}`;
|
|
264
|
+
}
|
|
265
|
+
/** True when the caller asked for the in-process path explicitly. */
|
|
266
|
+
function isDaemonBypassed() {
|
|
267
|
+
return process.env.GMAX_NO_DAEMON === "1";
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Run one store read through the access policy. See the file header for the
|
|
271
|
+
* decision table; `name` names the operation in daemon-error messages.
|
|
272
|
+
*/
|
|
273
|
+
function withStoreRead(name, opts) {
|
|
274
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
275
|
+
var _a, _b;
|
|
276
|
+
const runInProcess = () => __awaiter(this, void 0, void 0, function* () {
|
|
277
|
+
try {
|
|
278
|
+
return yield opts.inProcess();
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
throw asStoreAccessError(err);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
if (opts.skipDaemon || isDaemonBypassed())
|
|
285
|
+
return runInProcess();
|
|
286
|
+
let resp;
|
|
287
|
+
try {
|
|
288
|
+
resp = yield opts.daemon();
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
if (isStoreAccessRefused(err))
|
|
292
|
+
throw err;
|
|
293
|
+
const cls = classifyDaemonError(err === null || err === void 0 ? void 0 : err.code);
|
|
294
|
+
if (cls === "sandboxed")
|
|
295
|
+
throw refuseStoreAccess("socket");
|
|
296
|
+
if (cls !== "no-daemon")
|
|
297
|
+
throw err;
|
|
298
|
+
return runInProcess();
|
|
299
|
+
}
|
|
300
|
+
if (resp.ok) {
|
|
301
|
+
return opts.render ? yield opts.render(resp) : resp;
|
|
302
|
+
}
|
|
303
|
+
const cls = classifyDaemonError(resp.error);
|
|
304
|
+
if (cls === "sandboxed")
|
|
305
|
+
throw refuseStoreAccess("socket");
|
|
306
|
+
if (cls === "no-daemon" ||
|
|
307
|
+
(opts.fallbackOnUnknownVerb && isUnknownCommandError(resp.error)) ||
|
|
308
|
+
(opts.fallbackOnOversize && isOversizeError(resp.error)) ||
|
|
309
|
+
((_a = opts.extraFallback) === null || _a === void 0 ? void 0 : _a.call(opts, resp.error)) === true) {
|
|
310
|
+
if (process.env.GMAX_DEBUG === "1") {
|
|
311
|
+
console.error(`[${name}] daemon path unavailable: ${resp.error}`);
|
|
312
|
+
}
|
|
313
|
+
return runInProcess();
|
|
314
|
+
}
|
|
315
|
+
throw new Error(((_b = opts.daemonErrorMessage) !== null && _b !== void 0 ? _b : ((r) => defaultDaemonErrorMessage(name, r)))(resp));
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
// --- Spawn guard -----------------------------------------------------------
|
|
319
|
+
/**
|
|
320
|
+
* Probe whether this process could write under `~/.gmax` at all.
|
|
321
|
+
*
|
|
322
|
+
* A daemon spawned from a sandboxed shell inherits the sandbox for its whole
|
|
323
|
+
* life: it fails its own lock mkdir, dies, and leaves a confusing crash in
|
|
324
|
+
* daemon.log while the caller waits out the 30s readiness poll. Cheaper to ask
|
|
325
|
+
* first. mkdir+rmdir of a PID-named probe dir is the smallest operation that
|
|
326
|
+
* exercises the same permission the lease needs.
|
|
327
|
+
*
|
|
328
|
+
* "unknown" (e.g. ENOSPC, a weird EIO) is deliberately not "sandboxed": only a
|
|
329
|
+
* denial should suppress a spawn.
|
|
330
|
+
*/
|
|
331
|
+
function probeStoreWritable(dir = config_1.PATHS.globalRoot) {
|
|
332
|
+
// Only a denial may suppress a spawn, so an unusable path (a test harness
|
|
333
|
+
// with a partial PATHS, say) reports "unknown" rather than guessing.
|
|
334
|
+
if (typeof dir !== "string" || dir === "")
|
|
335
|
+
return "unknown";
|
|
336
|
+
const probe = path.join(dir, `.spawn-probe-${process.pid}`);
|
|
337
|
+
try {
|
|
338
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
339
|
+
try {
|
|
340
|
+
fs.mkdirSync(probe);
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
// A same-PID leftover from a crashed probe still proves writability.
|
|
344
|
+
if ((err === null || err === void 0 ? void 0 : err.code) !== "EEXIST")
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
fs.rmdirSync(probe);
|
|
349
|
+
}
|
|
350
|
+
catch (_a) { }
|
|
351
|
+
return "writable";
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
return classifyLeaseError(err) === "sandboxed" ? "sandboxed" : "unknown";
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* One-line notice for a spawn suppressed because the sandbox denies writes to
|
|
359
|
+
* `~/.gmax`, or null when a spawn is fine. Same shape as
|
|
360
|
+
* `autostartDisabledNotice()` in autostart.ts: the caller decides whether to
|
|
361
|
+
* print, and the text names the exact fix.
|
|
362
|
+
*/
|
|
363
|
+
function storeWriteDeniedNotice(dir) {
|
|
364
|
+
return probeStoreWritable(dir) === "sandboxed" ? exports.LEASE_DENIED_MESSAGE : null;
|
|
365
|
+
}
|
|
366
|
+
let sandboxNoticePrinted = false;
|
|
367
|
+
/**
|
|
368
|
+
* Print the lease-denied hint at most once per process. The spawn guard sits on
|
|
369
|
+
* paths that run per command (`ensureDaemonRunning`, `launchWatcher`), and a
|
|
370
|
+
* repeated hint is noise.
|
|
371
|
+
*/
|
|
372
|
+
function warnStoreWriteDeniedOnce(message = exports.LEASE_DENIED_MESSAGE) {
|
|
373
|
+
if (sandboxNoticePrinted)
|
|
374
|
+
return;
|
|
375
|
+
sandboxNoticePrinted = true;
|
|
376
|
+
console.error(message);
|
|
377
|
+
}
|
|
378
|
+
/** Test hook: forget that the notice was printed. */
|
|
379
|
+
function resetStoreWriteDeniedNotice() {
|
|
380
|
+
sandboxNoticePrinted = false;
|
|
381
|
+
}
|
|
@@ -9,14 +9,31 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.FOOTER_TIMEOUT_MS = void 0;
|
|
13
|
+
exports.withFooterTimeout = withFooterTimeout;
|
|
12
14
|
exports.fetchTestsForFooter = fetchTestsForFooter;
|
|
13
15
|
exports.renderTestsFooterAgent = renderTestsFooterAgent;
|
|
14
16
|
exports.renderTestsFooterHuman = renderTestsFooterHuman;
|
|
15
17
|
const impact_1 = require("../graph/impact");
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The footer is a courtesy line under a body the user asked for; it must never
|
|
20
|
+
* make the command feel slow. Past this budget the lookup is abandoned and the
|
|
21
|
+
* footer is simply omitted (`null`, not an empty list — "did not finish" is not
|
|
22
|
+
* "no tests").
|
|
23
|
+
*/
|
|
24
|
+
exports.FOOTER_TIMEOUT_MS = 1500;
|
|
17
25
|
const MAX_SHOWN = 5;
|
|
18
26
|
const useColors = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
19
27
|
const dim = (s) => (useColors ? `\x1b[2m${s}\x1b[22m` : s);
|
|
28
|
+
/**
|
|
29
|
+
* Race a footer lookup against `FOOTER_TIMEOUT_MS`. Exported because `extract`
|
|
30
|
+
* now fetches its footer over `graph.tests` rather than calling
|
|
31
|
+
* `fetchTestsForFooter` directly, and the budget has to cover the round trip
|
|
32
|
+
* the same way it covered the local query.
|
|
33
|
+
*/
|
|
34
|
+
function withFooterTimeout(p) {
|
|
35
|
+
return withTimeout(p, exports.FOOTER_TIMEOUT_MS);
|
|
36
|
+
}
|
|
20
37
|
function withTimeout(p, ms) {
|
|
21
38
|
return __awaiter(this, void 0, void 0, function* () {
|
|
22
39
|
let timer;
|
|
@@ -34,7 +51,7 @@ function withTimeout(p, ms) {
|
|
|
34
51
|
}
|
|
35
52
|
function fetchTestsForFooter(symbol, vectorDb, pathPrefix, excludePrefixes) {
|
|
36
53
|
return __awaiter(this, void 0, void 0, function* () {
|
|
37
|
-
return withTimeout((0, impact_1.findTests)([symbol], vectorDb, pathPrefix, 1, excludePrefixes), FOOTER_TIMEOUT_MS);
|
|
54
|
+
return withTimeout((0, impact_1.findTests)([symbol], vectorDb, pathPrefix, 1, excludePrefixes), exports.FOOTER_TIMEOUT_MS);
|
|
38
55
|
});
|
|
39
56
|
}
|
|
40
57
|
function relPath(p, projectRoot) {
|