grepmax 0.26.27 → 0.26.28

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.
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ /**
3
+ * Daemon-side handlers for the `vector.*` read verbs — the two commands whose
4
+ * store work is a vector scan rather than a row select.
5
+ *
6
+ * Both ship the *analysed* answer, never the vectors:
7
+ *
8
+ * - `vector.similar` runs the source-chunk lookup, `configureAnnVectorQuery`,
9
+ * `table.vectorSearch`, and the self/threshold filter, and returns the ranked
10
+ * chunks. The 384-float source vector never crosses the socket.
11
+ * - `vector.surprises` runs `analyzeSurprisingConnections` and returns the
12
+ * summary plus the findings the caller will actually print. The raw `pairs`
13
+ * array is dropped: nothing renders it, and it is the largest field by far.
14
+ *
15
+ * As in rows-handler.ts, each `run*` function is the single implementation both
16
+ * the verb and the command's in-process fallback call, so the two paths cannot
17
+ * produce different output.
18
+ */
19
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
20
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
21
+ return new (P || (P = Promise))(function (resolve, reject) {
22
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
23
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
24
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
25
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
26
+ });
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.runSimilar = runSimilar;
30
+ exports.handleVectorSimilar = handleVectorSimilar;
31
+ exports.runSurprises = runSurprises;
32
+ exports.handleVectorSurprises = handleVectorSurprises;
33
+ exports.registerVectorVerbs = registerVectorVerbs;
34
+ const surprising_connections_1 = require("../analysis/surprising-connections");
35
+ const ann_config_1 = require("../store/ann-config");
36
+ const arrow_1 = require("../utils/arrow");
37
+ const filter_builder_1 = require("../utils/filter-builder");
38
+ const path_containment_1 = require("../utils/path-containment");
39
+ const scope_filter_1 = require("../utils/scope-filter");
40
+ const read_verbs_1 = require("./read-verbs");
41
+ const rows_handler_1 = require("./rows-handler");
42
+ function runSimilar(deps, req) {
43
+ return __awaiter(this, void 0, void 0, function* () {
44
+ var _a, _b;
45
+ if (!deps.vectorDb)
46
+ throw new rows_handler_1.ReadVerbError("daemon not ready");
47
+ (_a = deps.touchActivity) === null || _a === void 0 ? void 0 : _a.call(deps);
48
+ const table = yield deps.vectorDb.ensureTable();
49
+ const sourceSelect = ["vector", "path", "defined_symbols", "start_line"];
50
+ const sourceRows = yield table
51
+ .query()
52
+ .select(sourceSelect)
53
+ .where(req.absPath !== undefined
54
+ ? `path = '${(0, filter_builder_1.escapeSqlString)(req.absPath)}'`
55
+ : `array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)((_b = req.symbol) !== null && _b !== void 0 ? _b : "")}') AND ${(0, filter_builder_1.pathStartsWith)(`${req.projectRoot}/`)}`)
56
+ .limit(1)
57
+ .toArray();
58
+ if (sourceRows.length === 0)
59
+ return { status: "not-found" };
60
+ const source = sourceRows[0];
61
+ const sourceVector = source.vector;
62
+ const sourcePath = String(source.path || "");
63
+ if (!sourceVector || sourceVector.length === 0)
64
+ return { status: "no-vector" };
65
+ const pathScope = (0, scope_filter_1.buildScopeWhere)(req.scope);
66
+ const results = yield (0, ann_config_1.configureAnnVectorQuery)(table.vectorSearch(sourceVector))
67
+ .select([
68
+ "path",
69
+ "start_line",
70
+ "end_line",
71
+ "defined_symbols",
72
+ "role",
73
+ "content",
74
+ "_distance",
75
+ ])
76
+ .where(pathScope)
77
+ .limit(req.limit + 5) // fetch extra to account for self-filtering
78
+ .toArray();
79
+ // Filter out self and apply threshold
80
+ const filtered = results.filter((raw) => {
81
+ const r = raw;
82
+ if (r.path === sourcePath && r.start_line === source.start_line)
83
+ return false;
84
+ if (req.threshold > 0) {
85
+ // LanceDB returns L2 distance; convert to similarity
86
+ const sim = 1 / (1 + (Number(r._distance) || 0));
87
+ if (sim < req.threshold)
88
+ return false;
89
+ }
90
+ return true;
91
+ });
92
+ // `content` is selected (keeping the query identical) but never rendered, so
93
+ // it is dropped here rather than shipped.
94
+ return {
95
+ status: "ok",
96
+ results: filtered.map((raw) => {
97
+ var _a, _b, _c, _d, _e;
98
+ const r = raw;
99
+ return {
100
+ path: String((_a = r.path) !== null && _a !== void 0 ? _a : ""),
101
+ start_line: Number((_b = r.start_line) !== null && _b !== void 0 ? _b : 0),
102
+ end_line: Number((_c = r.end_line) !== null && _c !== void 0 ? _c : 0),
103
+ defined_symbols: (0, arrow_1.toArr)(r.defined_symbols),
104
+ role: String((_d = r.role) !== null && _d !== void 0 ? _d : ""),
105
+ _distance: Number((_e = r._distance) !== null && _e !== void 0 ? _e : 0),
106
+ };
107
+ }),
108
+ };
109
+ });
110
+ }
111
+ function handleVectorSimilar(deps, payload) {
112
+ return __awaiter(this, void 0, void 0, function* () {
113
+ return (0, rows_handler_1.asReadVerbResponse)(() => __awaiter(this, void 0, void 0, function* () {
114
+ const projectRoot = (0, rows_handler_1.assertReadableProject)(payload.projectRoot);
115
+ const scope = (0, rows_handler_1.resolveWireScope)(projectRoot, payload.scope);
116
+ const absPath = typeof payload.absPath === "string" && payload.absPath
117
+ ? (0, path_containment_1.resolveContainedPath)(projectRoot, payload.absPath)
118
+ : undefined;
119
+ const symbol = typeof payload.symbol === "string" && payload.symbol
120
+ ? payload.symbol
121
+ : undefined;
122
+ if (!absPath && !symbol)
123
+ throw new rows_handler_1.ReadVerbError("missing target");
124
+ const result = yield runSimilar(deps, {
125
+ projectRoot,
126
+ absPath,
127
+ symbol,
128
+ scope,
129
+ limit: (0, rows_handler_1.clampInt)(payload.limit, 5, 1, 25),
130
+ threshold: typeof payload.threshold === "number" &&
131
+ Number.isFinite(payload.threshold)
132
+ ? payload.threshold
133
+ : 0,
134
+ });
135
+ return Object.assign({ ok: true }, result);
136
+ }));
137
+ });
138
+ }
139
+ function runSurprises(deps, req) {
140
+ return __awaiter(this, void 0, void 0, function* () {
141
+ var _a;
142
+ if (!deps.vectorDb)
143
+ throw new rows_handler_1.ReadVerbError("daemon not ready");
144
+ (_a = deps.touchActivity) === null || _a === void 0 ? void 0 : _a.call(deps);
145
+ const table = yield deps.vectorDb.ensureTable();
146
+ const result = yield (0, surprising_connections_1.analyzeSurprisingConnections)(table, req.projectRoot, req.options);
147
+ // Both formatters use only `summary` and `findings.slice(0, top)`, plus
148
+ // `findings.length === 0`. Trimming to `top` here preserves both and keeps
149
+ // `pairs` (the bulk of the object) off the wire entirely.
150
+ return {
151
+ summary: result.summary,
152
+ findings: result.findings.slice(0, req.top),
153
+ };
154
+ });
155
+ }
156
+ function handleVectorSurprises(deps, payload) {
157
+ return __awaiter(this, void 0, void 0, function* () {
158
+ return (0, rows_handler_1.asReadVerbResponse)(() => __awaiter(this, void 0, void 0, function* () {
159
+ var _a;
160
+ const projectRoot = (0, rows_handler_1.assertReadableProject)(payload.projectRoot);
161
+ const raw = ((_a = payload.options) !== null && _a !== void 0 ? _a : {});
162
+ const toPrefixList = (value) => {
163
+ if (!Array.isArray(value) || value.length === 0)
164
+ return undefined;
165
+ // Validated here rather than trusted: these reach resolveScope, which
166
+ // builds the WHERE clause the scan runs under.
167
+ return value.map((v) => (0, path_containment_1.resolveContainedPath)(projectRoot, String(v)));
168
+ };
169
+ const result = yield runSurprises(deps, {
170
+ projectRoot,
171
+ options: {
172
+ sample: (0, rows_handler_1.clampInt)(raw.sample, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.sample, 1, 10000),
173
+ neighbors: (0, rows_handler_1.clampInt)(raw.neighbors, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.neighbors, 1, 200),
174
+ dirDepth: (0, rows_handler_1.clampInt)(raw.dirDepth, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.dirDepth, 1, 20),
175
+ minSimilarity: typeof raw.minSimilarity === "number" &&
176
+ Number.isFinite(raw.minSimilarity) &&
177
+ raw.minSimilarity >= 0
178
+ ? raw.minSimilarity
179
+ : surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.minSimilarity,
180
+ maxRows: (0, rows_handler_1.clampInt)(raw.maxRows, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.maxRows, 1, surprising_connections_1.MAX_SURPRISE_ROWS),
181
+ includeTests: Boolean(raw.includeTests),
182
+ includeEval: Boolean(raw.includeEval),
183
+ in: toPrefixList(raw.in),
184
+ exclude: toPrefixList(raw.exclude),
185
+ },
186
+ top: (0, rows_handler_1.clampInt)(payload.top, 20, 1, 100),
187
+ });
188
+ return Object.assign({ ok: true }, result);
189
+ }));
190
+ });
191
+ }
192
+ // --- Registration -----------------------------------------------------------
193
+ /** Called from `Daemon.start()`; see registerRowsVerbs for why not at import. */
194
+ function registerVectorVerbs() {
195
+ (0, read_verbs_1.registerReadVerbs)({
196
+ "vector.similar": (payload, ctx) => handleVectorSimilar(ctx.daemon.storeReadDeps(), payload),
197
+ "vector.surprises": (payload, ctx) => handleVectorSurprises(ctx.daemon.storeReadDeps(), payload),
198
+ });
199
+ }
@@ -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,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
- const FOOTER_TIMEOUT_MS = 1500;
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) {
@@ -20,6 +20,7 @@ const autostart_1 = require("./autostart");
20
20
  const daemon_client_1 = require("./daemon-client");
21
21
  const daemon_launcher_1 = require("./daemon-launcher");
22
22
  const project_registry_1 = require("./project-registry");
23
+ const store_access_1 = require("./store-access");
23
24
  const watcher_store_1 = require("./watcher-store");
24
25
  function launchWatcher(projectRoot) {
25
26
  return __awaiter(this, void 0, void 0, function* () {
@@ -56,6 +57,15 @@ function launchWatcher(projectRoot) {
56
57
  if (notice) {
57
58
  return { ok: false, reason: "autostart-disabled", message: notice };
58
59
  }
60
+ // A spawned daemon (or per-project watcher) inherits this process's sandbox
61
+ // for its whole life. If ~/.gmax is read-only here it will be read-only
62
+ // there, and the child dies on its own lock mkdir — so probe once and say so
63
+ // instead of leaving a crash in daemon.log.
64
+ const sandboxed = (0, store_access_1.storeWriteDeniedNotice)();
65
+ if (sandboxed) {
66
+ (0, store_access_1.warnStoreWriteDeniedOnce)(sandboxed);
67
+ return { ok: false, reason: "sandboxed", message: sandboxed };
68
+ }
59
69
  const error = resp.error;
60
70
  if (error === "ENOENT" || error === "ECONNREFUSED") {
61
71
  const daemonPid = yield (0, daemon_launcher_1.spawnDaemon)();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.26.27",
3
+ "version": "0.26.28",
4
4
  "author": "Robert Owens <78518764+reowens@users.noreply.github.com>",
5
5
  "homepage": "https://github.com/reowens/grepmax",
6
6
  "bugs": {