@massu/core 1.4.0-soak.0 → 1.5.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/commands/README.md +0 -3
- package/dist/cli.js +9423 -5453
- package/dist/hooks/auto-learning-pipeline.js +27 -1
- package/dist/hooks/classify-failure.js +27 -1
- package/dist/hooks/cost-tracker.js +27 -1
- package/dist/hooks/fix-detector.js +27 -1
- package/dist/hooks/incident-pipeline.js +27 -1
- package/dist/hooks/post-edit-context.js +27 -1
- package/dist/hooks/post-tool-use.js +27 -1
- package/dist/hooks/pre-compact.js +27 -1
- package/dist/hooks/pre-delete-check.js +27 -1
- package/dist/hooks/quality-event.js +27 -1
- package/dist/hooks/rule-enforcement-pipeline.js +27 -1
- package/dist/hooks/session-end.js +27 -1
- package/dist/hooks/session-start.js +2677 -2675
- package/dist/hooks/user-prompt.js +27 -1
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +10 -3
- package/src/adapter.ts +90 -0
- package/src/cli.ts +7 -0
- package/src/commands/adapters.ts +824 -0
- package/src/commands/config-check-drift.ts +1 -0
- package/src/commands/config-refresh.ts +4 -3
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +3 -1
- package/src/commands/template-engine.ts +0 -2
- package/src/commands/watch.ts +1 -1
- package/src/config.ts +71 -0
- package/src/detect/adapters/aspnet.ts +293 -0
- package/src/detect/adapters/discover.ts +469 -0
- package/src/detect/adapters/go-chi.ts +261 -0
- package/src/detect/adapters/index.ts +49 -0
- package/src/detect/adapters/phoenix.ts +277 -0
- package/src/detect/adapters/python-flask.ts +235 -0
- package/src/detect/adapters/rails.ts +279 -0
- package/src/detect/adapters/runner.ts +32 -0
- package/src/detect/adapters/spring.ts +284 -0
- package/src/detect/adapters/tree-sitter-loader.ts +171 -2
- package/src/detect/adapters/types.ts +19 -2
- package/src/detect/migrate.ts +4 -4
- package/src/detect/monorepo-detector.ts +1 -0
- package/src/hooks/post-tool-use.ts +1 -0
- package/src/hooks/session-start.ts +1 -0
- package/src/lib/fileLock.ts +203 -0
- package/src/lib/installLock.ts +31 -144
- package/src/lsp/auto-detect.ts +10 -1
- package/src/lsp/client.ts +188 -2
- package/src/memory-file-ingest.ts +1 -0
- package/src/security/adapter-origin.ts +130 -0
- package/src/security/adapter-verifier.ts +319 -0
- package/src/security/atomic-write.ts +164 -0
- package/src/security/fetcher.ts +200 -0
- package/src/security/install-tracking.ts +319 -0
- package/src/security/local-fingerprint.ts +225 -0
- package/src/security/manifest-cache.ts +333 -0
- package/src/security/manifest-schema.ts +129 -0
- package/src/security/registry-pubkey.generated.ts +35 -0
- package/src/security/telemetry.ts +320 -0
- package/src/watch/daemon.ts +1 -1
- package/src/watch/paths.ts +2 -2
- package/templates/aspnet/massu.config.yaml +57 -0
- package/templates/go-chi/massu.config.yaml +52 -0
- package/templates/phoenix/massu.config.yaml +54 -0
- package/templates/python-flask/massu.config.yaml +51 -0
- package/templates/rails/massu.config.yaml +56 -0
- package/templates/spring/massu.config.yaml +56 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic synchronous file-lock primitive built on `proper-lockfile`.
|
|
6
|
+
*
|
|
7
|
+
* Plan 3c gap-59 deliverable. Single source of truth for "acquire-lock,
|
|
8
|
+
* run fn, release on every exit path" across the codebase. Both
|
|
9
|
+
* `lib/installLock.ts:withInstallLock` (Plan 3a installAll serialization)
|
|
10
|
+
* AND `security/manifest-cache.ts:refreshManifest` (Plan 3c manifest
|
|
11
|
+
* cache writes) MUST delegate to `withFileLockSync` here — there is NO
|
|
12
|
+
* parallel lock implementation in the codebase per CR-46 / Rule 0
|
|
13
|
+
* (single-source-of-truth for lock semantics).
|
|
14
|
+
*
|
|
15
|
+
* What this primitive provides:
|
|
16
|
+
* 1. mkdirSync the lock parent dir if absent (fresh-repo / fresh-home case).
|
|
17
|
+
* 2. proper-lockfile.lockSync acquires the lock; we wrap the manual retry
|
|
18
|
+
* loop because lockSync rejects retries>0 (`Cannot use retries with
|
|
19
|
+
* the sync api` per node_modules/proper-lockfile/lib/adapter.js).
|
|
20
|
+
* 3. Surface ELOCKED (POSIX) and EBUSY (Windows) as the same FileLockBusyError.
|
|
21
|
+
* 4. Persist the lock-holder PID alongside the lock as `<lockPath>.pid` so
|
|
22
|
+
* the next contender can include it in a user-friendly error message.
|
|
23
|
+
* 5. Default 30s block-then-bail per Plan 3a §190; configurable per-callsite.
|
|
24
|
+
* 6. `errorFactory` opt lets callers customize the busy-error class so
|
|
25
|
+
* domain-specific helpers (`InstallLockBusyError`, future Phase 5
|
|
26
|
+
* `ManifestCacheBusyError`) can extend the base type without each
|
|
27
|
+
* re-implementing the lock logic.
|
|
28
|
+
*
|
|
29
|
+
* NOT provided by this primitive:
|
|
30
|
+
* - Async variant (`withFileLockAsync`). The current Phase 5 cache-write
|
|
31
|
+
* path resolves the async fetch BEFORE acquiring the lock, so the lock
|
|
32
|
+
* is held only during the brief sync atomicWrite. Async-while-holding-
|
|
33
|
+
* the-lock would deadlock under contention; the design is "fetch first,
|
|
34
|
+
* then lock-for-write only".
|
|
35
|
+
* - Reentrancy. `proper-lockfile.lockSync` is non-reentrant; calling
|
|
36
|
+
* withFileLockSync recursively from inside its own `fn` will fail with
|
|
37
|
+
* ELOCKED. Plan 3a observed and documented this in
|
|
38
|
+
* __tests__/watch/config-refresh-autoyes.test.ts:129.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
42
|
+
import { dirname } from 'path';
|
|
43
|
+
import * as lockfile from 'proper-lockfile';
|
|
44
|
+
|
|
45
|
+
export interface FileLockOpts {
|
|
46
|
+
/** Default 30s — proper-lockfile considers a lock stale after this elapses. */
|
|
47
|
+
staleMs?: number;
|
|
48
|
+
/**
|
|
49
|
+
* How long the manual retry loop should block waiting for the holder to
|
|
50
|
+
* release before bailing with the busy error. Default 30s.
|
|
51
|
+
* Pass `0` to bail immediately (used in tests).
|
|
52
|
+
*/
|
|
53
|
+
blockMs?: number;
|
|
54
|
+
/** Sleep granularity inside the retry loop. Default 100ms. */
|
|
55
|
+
pollIntervalMs?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Backwards-compat: legacy callers pass `retries: 0` to mean "do not
|
|
58
|
+
* block". When set to a positive integer, used by tests that want to
|
|
59
|
+
* exercise a specific retry count instead of the default time-based loop.
|
|
60
|
+
*/
|
|
61
|
+
retries?: number;
|
|
62
|
+
/** Override clock (test seam). */
|
|
63
|
+
now?: () => number;
|
|
64
|
+
/** Override sleep (test seam). Defaults to a busy-wait spinloop. */
|
|
65
|
+
sleep?: (ms: number) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Optional custom busy-error factory. When provided, the default
|
|
68
|
+
* FileLockBusyError throw is replaced with whatever this factory returns.
|
|
69
|
+
* Domain-specific callers (installLock, manifest-cache) use this to
|
|
70
|
+
* keep their own user-facing error types and messages.
|
|
71
|
+
*/
|
|
72
|
+
errorFactory?: (
|
|
73
|
+
lockPath: string,
|
|
74
|
+
holderPid: number | null,
|
|
75
|
+
retryAfterSeconds: number,
|
|
76
|
+
causeCode: string | undefined,
|
|
77
|
+
) => Error;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class FileLockBusyError extends Error {
|
|
81
|
+
constructor(
|
|
82
|
+
public lockPath: string,
|
|
83
|
+
public holderPid: number | null,
|
|
84
|
+
public retryAfterSeconds: number,
|
|
85
|
+
public causeCode?: string,
|
|
86
|
+
) {
|
|
87
|
+
const pidPart = holderPid != null ? `(PID=${holderPid})` : '(PID=unknown)';
|
|
88
|
+
super(`File lock at ${lockPath} held by another process ${pidPart} — try again in ${retryAfterSeconds}s`);
|
|
89
|
+
this.name = 'FileLockBusyError';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Best-effort: read the PID of the current lock holder from the
|
|
95
|
+
* `<lockPath>.pid` sidecar file. Returns null on any read error.
|
|
96
|
+
*/
|
|
97
|
+
export function readLockHolderPid(lockPath: string): number | null {
|
|
98
|
+
try {
|
|
99
|
+
const raw = readFileSync(`${lockPath}.pid`, 'utf-8').trim();
|
|
100
|
+
const pid = Number.parseInt(raw, 10);
|
|
101
|
+
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
102
|
+
return pid;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* CR-9 audit L3 fix: REQUIRES SharedArrayBuffer + Atomics for the sync
|
|
110
|
+
* wait. The prior fallback (a tight `while (Date.now() < end)` spinloop)
|
|
111
|
+
* burned 100% CPU on environments without Atomics — typically sandboxed
|
|
112
|
+
* serverless runtimes — making contended manifest refreshes a DoS
|
|
113
|
+
* surface against the host. With this throw, callers running on
|
|
114
|
+
* Atomics-less environments fail loudly + early instead of silently
|
|
115
|
+
* spinning.
|
|
116
|
+
*/
|
|
117
|
+
export function busyWaitSync(ms: number): void {
|
|
118
|
+
if (typeof SharedArrayBuffer === 'undefined' || typeof Atomics === 'undefined') {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`withFileLockSync requires SharedArrayBuffer + Atomics for its retry-loop wait. ` +
|
|
121
|
+
`This Node runtime does not provide them — refusing to fall back to a CPU spinloop. ` +
|
|
122
|
+
`If you hit this in a sandboxed serverless env, the fix is to perform the ` +
|
|
123
|
+
`lock-protected operation in a host runtime that supports Atomics.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const sab = new SharedArrayBuffer(4);
|
|
127
|
+
const view = new Int32Array(sab);
|
|
128
|
+
Atomics.wait(view, 0, 0, ms);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Acquire the lock at `lockPath`, run `fn`, release on every exit path.
|
|
133
|
+
* Synchronous all the way through. See module-level doc for guarantees.
|
|
134
|
+
*
|
|
135
|
+
* Throws:
|
|
136
|
+
* - The result of `opts.errorFactory(...)` if provided AND lock is busy
|
|
137
|
+
* beyond `blockMs`. Otherwise throws FileLockBusyError.
|
|
138
|
+
* - Any non-ELOCKED/EBUSY filesystem error from proper-lockfile is
|
|
139
|
+
* re-thrown unchanged.
|
|
140
|
+
*/
|
|
141
|
+
export function withFileLockSync<T>(lockPath: string, fn: () => T, opts: FileLockOpts = {}): T {
|
|
142
|
+
// Ensure the lock's parent directory exists. Fresh repos / fresh user-home
|
|
143
|
+
// .massu/ may not have the parent yet.
|
|
144
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
145
|
+
|
|
146
|
+
const staleMs = opts.staleMs ?? 30_000;
|
|
147
|
+
const blockMs = opts.retries === 0 ? 0 : (opts.blockMs ?? 30_000);
|
|
148
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 100;
|
|
149
|
+
const now = opts.now ?? Date.now;
|
|
150
|
+
const sleep = opts.sleep ?? busyWaitSync;
|
|
151
|
+
const makeBusyError =
|
|
152
|
+
opts.errorFactory ??
|
|
153
|
+
((path, pid, retrySeconds, code) => new FileLockBusyError(path, pid, retrySeconds, code));
|
|
154
|
+
|
|
155
|
+
let release: (() => void) | null = null;
|
|
156
|
+
const deadline = now() + blockMs;
|
|
157
|
+
|
|
158
|
+
for (;;) {
|
|
159
|
+
try {
|
|
160
|
+
release = lockfile.lockSync(lockPath, {
|
|
161
|
+
stale: staleMs,
|
|
162
|
+
retries: 0,
|
|
163
|
+
realpath: false,
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(`${lockPath}.pid`, String(process.pid), 'utf-8');
|
|
167
|
+
} catch {
|
|
168
|
+
// best-effort
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const e = err as NodeJS.ErrnoException;
|
|
173
|
+
const code = e.code;
|
|
174
|
+
if (code !== 'ELOCKED' && code !== 'EBUSY') {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
if (now() >= deadline) {
|
|
178
|
+
const holderPid = readLockHolderPid(lockPath);
|
|
179
|
+
const remainingMs = Math.max(0, deadline - now());
|
|
180
|
+
const retryAfterSeconds = blockMs === 0
|
|
181
|
+
? Math.round(staleMs / 1000)
|
|
182
|
+
: Math.round(remainingMs / 1000);
|
|
183
|
+
throw makeBusyError(lockPath, holderPid, retryAfterSeconds, code);
|
|
184
|
+
}
|
|
185
|
+
sleep(pollIntervalMs);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
return fn();
|
|
191
|
+
} finally {
|
|
192
|
+
try {
|
|
193
|
+
if (release) release();
|
|
194
|
+
} catch {
|
|
195
|
+
// best-effort
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
rmSync(`${lockPath}.pid`, { force: true });
|
|
199
|
+
} catch {
|
|
200
|
+
// best-effort
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/lib/installLock.ts
CHANGED
|
@@ -8,49 +8,29 @@
|
|
|
8
8
|
* `runConfigRefresh` path AND the watcher auto-trigger. Without
|
|
9
9
|
* serialization, two concurrent callers can race on `.claude/commands/`
|
|
10
10
|
* file writes. proper-lockfile gives us atomic mkdir-based locks that
|
|
11
|
-
* work cross-platform
|
|
11
|
+
* work cross-platform.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Plan 3c gap-59 / Rule 0 single-source-of-truth refactor (commit pending,
|
|
14
|
+
* 2026-05-07): the proper-lockfile-wrapping logic + manual retry loop +
|
|
15
|
+
* .pid sidecar bookkeeping now lives in `lib/fileLock.ts:withFileLockSync`.
|
|
16
|
+
* This file is a thin domain-specific wrapper that:
|
|
17
|
+
* 1. Computes the project-root-anchored lockPath
|
|
18
|
+
* 2. Delegates to withFileLockSync via an `errorFactory` that returns
|
|
19
|
+
* `InstallLockBusyError` (preserving the exact error message format
|
|
20
|
+
* Plan 3a §243 specified and the install-lock tests assert)
|
|
16
21
|
*
|
|
17
|
-
* Plan §190 retry behavior: "second caller blocks up to 30s,
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `lockSync` loop with a busy-wait sleep.
|
|
22
|
-
*
|
|
23
|
-
* iter-3 (third pass, G3-iter3-1+2): align error message with plan §243
|
|
24
|
-
* format `"installAll already running (PID=X) — try again in <N>s"` AND
|
|
25
|
-
* add the manual retry-block loop.
|
|
22
|
+
* Plan 3a §190 retry behavior preserved: "second caller blocks up to 30s,
|
|
23
|
+
* then bails". InstallLockBusyError instances continue to expose
|
|
24
|
+
* `lockPath`, `holderPid`, `retryAfterSeconds`, `causeCode` — backwards-
|
|
25
|
+
* compatible for any caller that does `instanceof InstallLockBusyError`.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import * as lockfile from 'proper-lockfile';
|
|
28
|
+
import { resolve } from 'path';
|
|
29
|
+
import { withFileLockSync, type FileLockOpts } from './fileLock.js';
|
|
31
30
|
|
|
32
|
-
export interface InstallLockOpts {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* How long the manual retry loop should block waiting for the holder to
|
|
37
|
-
* release before bailing with `InstallLockBusyError`. Default 30s per
|
|
38
|
-
* plan §190 ("second caller blocks up to 30s, then bails").
|
|
39
|
-
* Pass `0` to bail immediately (used in tests).
|
|
40
|
-
*/
|
|
41
|
-
blockMs?: number;
|
|
42
|
-
/** Sleep granularity inside the retry loop. Default 100ms. */
|
|
43
|
-
pollIntervalMs?: number;
|
|
44
|
-
/**
|
|
45
|
-
* Backwards-compat: legacy callers pass `retries: 0` to mean "do not
|
|
46
|
-
* block". When set to a positive integer, used by tests that want to
|
|
47
|
-
* exercise a specific retry count instead of the default time-based loop.
|
|
48
|
-
*/
|
|
49
|
-
retries?: number;
|
|
50
|
-
/** Override clock (test seam). */
|
|
51
|
-
now?: () => number;
|
|
52
|
-
/** Override sleep (test seam). Defaults to a busy-wait spinloop. */
|
|
53
|
-
sleep?: (ms: number) => void;
|
|
31
|
+
export interface InstallLockOpts extends FileLockOpts {
|
|
32
|
+
// No additional fields — all options come from FileLockOpts. This alias
|
|
33
|
+
// preserves the public surface for any caller that imported `InstallLockOpts`.
|
|
54
34
|
}
|
|
55
35
|
|
|
56
36
|
export class InstallLockBusyError extends Error {
|
|
@@ -67,113 +47,20 @@ export class InstallLockBusyError extends Error {
|
|
|
67
47
|
}
|
|
68
48
|
|
|
69
49
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* error message degrades gracefully to `(PID=unknown)`.
|
|
75
|
-
*/
|
|
76
|
-
function readHolderPid(lockPath: string): number | null {
|
|
77
|
-
try {
|
|
78
|
-
const raw = readFileSync(`${lockPath}.pid`, 'utf-8').trim();
|
|
79
|
-
const pid = Number.parseInt(raw, 10);
|
|
80
|
-
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
81
|
-
return pid;
|
|
82
|
-
} catch {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function busyWaitSync(ms: number): void {
|
|
88
|
-
const end = Date.now() + ms;
|
|
89
|
-
// Atomics.wait against a SharedArrayBuffer is the cleanest portable sync
|
|
90
|
-
// sleep; fall back to a tight loop if SharedArrayBuffer is unavailable
|
|
91
|
-
// (older runtimes / sandboxed envs).
|
|
92
|
-
if (typeof SharedArrayBuffer !== 'undefined' && typeof Atomics !== 'undefined') {
|
|
93
|
-
const sab = new SharedArrayBuffer(4);
|
|
94
|
-
const view = new Int32Array(sab);
|
|
95
|
-
Atomics.wait(view, 0, 0, ms);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
while (Date.now() < end) {
|
|
99
|
-
// Spin — this should never run on modern Node, kept as belt-and-suspenders.
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Acquire the lock, run `fn`, release on every exit path.
|
|
105
|
-
* Synchronous all the way through so installAll() keeps its sync signature.
|
|
50
|
+
* Acquire the install lock for `projectRoot`, run `fn`, release on every
|
|
51
|
+
* exit path. Throws `InstallLockBusyError` when the lock is held beyond
|
|
52
|
+
* `blockMs`. See `lib/fileLock.ts:withFileLockSync` for the underlying
|
|
53
|
+
* primitive.
|
|
106
54
|
*/
|
|
107
55
|
export function withInstallLock<T>(projectRoot: string, fn: () => T, opts: InstallLockOpts = {}): T {
|
|
108
56
|
const lockPath = resolve(projectRoot, '.massu', 'installAll.lock');
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const pollIntervalMs = opts.pollIntervalMs ?? 100;
|
|
119
|
-
const now = opts.now ?? Date.now;
|
|
120
|
-
const sleep = opts.sleep ?? busyWaitSync;
|
|
121
|
-
|
|
122
|
-
let release: (() => void) | null = null;
|
|
123
|
-
const deadline = now() + blockMs;
|
|
124
|
-
let lastErr: NodeJS.ErrnoException | null = null;
|
|
125
|
-
|
|
126
|
-
// Manual retry loop. proper-lockfile.lockSync forbids retries>0, so we
|
|
127
|
-
// wrap it ourselves: try → on ELOCKED/EBUSY, sleep → try again until
|
|
128
|
-
// deadline. This satisfies plan §190 "second caller blocks up to 30s".
|
|
129
|
-
for (;;) {
|
|
130
|
-
try {
|
|
131
|
-
release = lockfile.lockSync(lockPath, {
|
|
132
|
-
stale: staleMs,
|
|
133
|
-
retries: 0,
|
|
134
|
-
realpath: false,
|
|
135
|
-
});
|
|
136
|
-
// Persist our PID alongside the lock so the next contender can include
|
|
137
|
-
// it in the user-friendly error per plan §243 format.
|
|
138
|
-
try {
|
|
139
|
-
writeFileSync(`${lockPath}.pid`, String(process.pid), 'utf-8');
|
|
140
|
-
} catch {
|
|
141
|
-
// best-effort
|
|
142
|
-
}
|
|
143
|
-
break;
|
|
144
|
-
} catch (err) {
|
|
145
|
-
lastErr = err as NodeJS.ErrnoException;
|
|
146
|
-
const code = lastErr.code;
|
|
147
|
-
if (code !== 'ELOCKED' && code !== 'EBUSY') {
|
|
148
|
-
throw err;
|
|
149
|
-
}
|
|
150
|
-
if (now() >= deadline) {
|
|
151
|
-
const holderPid = readHolderPid(lockPath);
|
|
152
|
-
const remainingMs = Math.max(0, deadline - now());
|
|
153
|
-
// Surface a hint about how long the *next* poll cycle should wait.
|
|
154
|
-
// When `blockMs=0` the user got bail-immediately semantics; report
|
|
155
|
-
// the staleness window so they know the lock auto-releases in N s.
|
|
156
|
-
const retryAfterSeconds = blockMs === 0
|
|
157
|
-
? Math.round(staleMs / 1000)
|
|
158
|
-
: Math.round(remainingMs / 1000);
|
|
159
|
-
throw new InstallLockBusyError(lockPath, holderPid, retryAfterSeconds, code);
|
|
160
|
-
}
|
|
161
|
-
sleep(pollIntervalMs);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
return fn();
|
|
167
|
-
} finally {
|
|
168
|
-
try {
|
|
169
|
-
if (release) release();
|
|
170
|
-
} catch {
|
|
171
|
-
// best-effort
|
|
172
|
-
}
|
|
173
|
-
try {
|
|
174
|
-
rmSync(`${lockPath}.pid`, { force: true });
|
|
175
|
-
} catch {
|
|
176
|
-
// best-effort
|
|
177
|
-
}
|
|
178
|
-
}
|
|
57
|
+
return withFileLockSync(
|
|
58
|
+
lockPath,
|
|
59
|
+
fn,
|
|
60
|
+
{
|
|
61
|
+
...opts,
|
|
62
|
+
errorFactory: (path, pid, retrySeconds, code) =>
|
|
63
|
+
new InstallLockBusyError(path, pid, retrySeconds, code),
|
|
64
|
+
},
|
|
65
|
+
);
|
|
179
66
|
}
|
package/src/lsp/auto-detect.ts
CHANGED
|
@@ -77,7 +77,12 @@ export async function findRunningLSPs(
|
|
|
77
77
|
* passing it to `LSPClient.fromCommand`. The factory rejects relative paths
|
|
78
78
|
* and `..`-containing argv elements.
|
|
79
79
|
*/
|
|
80
|
-
function splitCommand(server: {
|
|
80
|
+
function splitCommand(server: {
|
|
81
|
+
language: string;
|
|
82
|
+
command: string;
|
|
83
|
+
allow_setuid?: boolean;
|
|
84
|
+
max_rss_mb?: number;
|
|
85
|
+
}): LSPServerSpec {
|
|
81
86
|
const cmd = (server.command ?? '').trim();
|
|
82
87
|
// Whitespace split — not a full shell parser. Quoted args with spaces are
|
|
83
88
|
// not supported at v1; users with such commands should run a wrapper script.
|
|
@@ -85,5 +90,9 @@ function splitCommand(server: { language: string; command: string }): LSPServerS
|
|
|
85
90
|
return {
|
|
86
91
|
language: server.language,
|
|
87
92
|
argv,
|
|
93
|
+
// F-014 / F-015 (closed 2026-05-06): pass through the security knobs
|
|
94
|
+
// from config so the spawn surface enforces them.
|
|
95
|
+
allowSetuid: server.allow_setuid ?? false,
|
|
96
|
+
maxRssMb: server.max_rss_mb ?? undefined,
|
|
88
97
|
};
|
|
89
98
|
}
|
package/src/lsp/client.ts
CHANGED
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
* ESM imports throughout.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import { spawn, type ChildProcess } from 'child_process';
|
|
37
|
-
import {
|
|
36
|
+
import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
|
37
|
+
import { lstatSync, realpathSync } from 'fs';
|
|
38
|
+
import { isAbsolute, resolve as resolvePath } from 'path';
|
|
38
39
|
import {
|
|
39
40
|
DefinitionResponseSchema,
|
|
40
41
|
DocumentSymbolResponseSchema,
|
|
@@ -215,6 +216,166 @@ export interface LSPServerSpec {
|
|
|
215
216
|
argv: string[];
|
|
216
217
|
/** When true, allow non-absolute argv[0]. Default false (security). */
|
|
217
218
|
allowRelativePath?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* F-014 (closed 2026-05-06): when true, allow argv[0] to be a SUID/SGID
|
|
221
|
+
* binary (or symlink resolving to one). Default false. SUID binaries
|
|
222
|
+
* inherit elevated privileges from the kernel at exec time; Node has no
|
|
223
|
+
* post-spawn way to strip them. The user-trust boundary is at config
|
|
224
|
+
* time, but a defensive lstat catches accidental misconfigs (e.g.
|
|
225
|
+
* pointing argv[0] at a system tool).
|
|
226
|
+
*/
|
|
227
|
+
allowSetuid?: boolean;
|
|
228
|
+
/**
|
|
229
|
+
* F-015 (closed 2026-05-06): RSS budget in MB. The watchdog polls
|
|
230
|
+
* `ps -p <pid> -o rss=` every WATCHDOG_INTERVAL_MS and SIGKILLs the
|
|
231
|
+
* child if RSS exceeds this budget for two consecutive samples.
|
|
232
|
+
* Default 1024 (1 GB). Set to 0 to disable the watchdog.
|
|
233
|
+
*/
|
|
234
|
+
maxRssMb?: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ============================================================
|
|
238
|
+
// Typed errors — F-014, F-015 (closed 2026-05-06)
|
|
239
|
+
// ============================================================
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Thrown by `LSPClient.fromCommand` when argv[0] (or its symlink target)
|
|
243
|
+
* has the SUID/SGID bit set and `spec.allowSetuid: true` was not opted in.
|
|
244
|
+
*
|
|
245
|
+
* Why throw rather than silently accept: SUID binaries inherit elevated
|
|
246
|
+
* privileges from the kernel at exec time. Node cannot strip them
|
|
247
|
+
* post-spawn. A user who wants this MUST opt in explicitly so the
|
|
248
|
+
* decision is auditable in their config.
|
|
249
|
+
*/
|
|
250
|
+
export class LspBinaryIsSetuidError extends Error {
|
|
251
|
+
public readonly path: string;
|
|
252
|
+
public readonly mode: number;
|
|
253
|
+
constructor(path: string, mode: number) {
|
|
254
|
+
super(
|
|
255
|
+
`LSPClient.fromCommand: refused SUID/SGID binary at "${path}" ` +
|
|
256
|
+
`(mode=${mode.toString(8)}). The kernel will exec this with ` +
|
|
257
|
+
`elevated privileges; Node cannot strip that post-spawn. ` +
|
|
258
|
+
`Set spec.allowSetuid: true to opt in (auditable in config).`,
|
|
259
|
+
);
|
|
260
|
+
this.name = 'LspBinaryIsSetuidError';
|
|
261
|
+
this.path = path;
|
|
262
|
+
this.mode = mode;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Constants for the F-015 RSS watchdog. Exported so tests can inspect
|
|
268
|
+
* (and so a future config can override per-deployment if needed).
|
|
269
|
+
*/
|
|
270
|
+
export const DEFAULT_LSP_MAX_RSS_MB = 1024;
|
|
271
|
+
export const LSP_WATCHDOG_INTERVAL_MS = 30_000;
|
|
272
|
+
/**
|
|
273
|
+
* Number of consecutive over-budget samples required before SIGKILL.
|
|
274
|
+
* Avoids killing a server that briefly spikes during indexing — only
|
|
275
|
+
* sustained over-budget triggers eviction.
|
|
276
|
+
*/
|
|
277
|
+
export const LSP_WATCHDOG_OVERBUDGET_SAMPLES = 2;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* F-014 helper: detect SUID/SGID bits on a file. Follows the chain via
|
|
281
|
+
* lstat then statSync(realpath) so a symlink to a SUID binary is also
|
|
282
|
+
* caught. Returns null if the file doesn't exist or the stat fails.
|
|
283
|
+
*
|
|
284
|
+
* Bit semantics (per stat(2)):
|
|
285
|
+
* - 0o4000 = SUID (set-user-ID on execution)
|
|
286
|
+
* - 0o2000 = SGID (set-group-ID on execution)
|
|
287
|
+
*/
|
|
288
|
+
export function _detectSetuid(path: string): { hasSetuid: boolean; mode: number; resolvedPath: string } | null {
|
|
289
|
+
// First lstat — if argv[0] itself is a symlink, follow it via realpath.
|
|
290
|
+
let resolved = path;
|
|
291
|
+
try {
|
|
292
|
+
const linkStat = lstatSync(path);
|
|
293
|
+
if (linkStat.isSymbolicLink()) {
|
|
294
|
+
resolved = realpathSync(path);
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
// Now stat the resolved (non-symlink) target.
|
|
300
|
+
try {
|
|
301
|
+
const targetStat = lstatSync(resolved);
|
|
302
|
+
const mode = targetStat.mode;
|
|
303
|
+
return {
|
|
304
|
+
hasSetuid: (mode & 0o4000) !== 0 || (mode & 0o2000) !== 0,
|
|
305
|
+
mode,
|
|
306
|
+
resolvedPath: resolved,
|
|
307
|
+
};
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* F-015 helper: probe a child's RSS in MB via `ps -p <pid> -o rss=`.
|
|
315
|
+
* Returns null if ps fails (e.g., process already gone, or non-POSIX
|
|
316
|
+
* platform without ps). Best-effort — watchdog treats null as "no
|
|
317
|
+
* sample, don't count toward over-budget streak."
|
|
318
|
+
*/
|
|
319
|
+
export function _probeChildRssMb(pid: number): number | null {
|
|
320
|
+
try {
|
|
321
|
+
const result = spawnSync('ps', ['-o', 'rss=', '-p', String(pid)], {
|
|
322
|
+
encoding: 'utf-8',
|
|
323
|
+
timeout: 5_000,
|
|
324
|
+
});
|
|
325
|
+
if (result.status !== 0 || !result.stdout) return null;
|
|
326
|
+
const rssKb = parseInt(result.stdout.trim(), 10);
|
|
327
|
+
if (!Number.isFinite(rssKb) || rssKb < 0) return null;
|
|
328
|
+
return Math.round((rssKb / 1024) * 10) / 10;
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* F-015 helper: install an interval-based RSS watchdog on a spawned child.
|
|
336
|
+
* Returns the watchdog handle (interval id + cleanup) so the caller can
|
|
337
|
+
* stop it on transport shutdown / process exit.
|
|
338
|
+
*
|
|
339
|
+
* The watchdog SIGKILLs the child if RSS exceeds the budget for
|
|
340
|
+
* `LSP_WATCHDOG_OVERBUDGET_SAMPLES` consecutive samples. Killing emits a
|
|
341
|
+
* stderr warning naming the LSP language and the breach.
|
|
342
|
+
*/
|
|
343
|
+
export function _startRssWatchdog(
|
|
344
|
+
child: ChildProcess,
|
|
345
|
+
language: string,
|
|
346
|
+
maxRssMb: number,
|
|
347
|
+
intervalMs: number = LSP_WATCHDOG_INTERVAL_MS,
|
|
348
|
+
): { stop: () => void } {
|
|
349
|
+
if (maxRssMb <= 0) return { stop: () => { /* disabled */ } };
|
|
350
|
+
let overBudgetStreak = 0;
|
|
351
|
+
const tick = (): void => {
|
|
352
|
+
if (!child.pid || child.killed || child.exitCode !== null) return;
|
|
353
|
+
const rss = _probeChildRssMb(child.pid);
|
|
354
|
+
if (rss === null) return; // no sample; don't penalise
|
|
355
|
+
if (rss > maxRssMb) {
|
|
356
|
+
overBudgetStreak += 1;
|
|
357
|
+
process.stderr.write(
|
|
358
|
+
`[massu/lsp] WARN: ${language} server RSS=${rss}MB > budget ${maxRssMb}MB ` +
|
|
359
|
+
`(streak=${overBudgetStreak}/${LSP_WATCHDOG_OVERBUDGET_SAMPLES})\n`,
|
|
360
|
+
);
|
|
361
|
+
if (overBudgetStreak >= LSP_WATCHDOG_OVERBUDGET_SAMPLES) {
|
|
362
|
+
process.stderr.write(
|
|
363
|
+
`[massu/lsp] KILLING ${language} server pid=${child.pid}: ` +
|
|
364
|
+
`sustained RSS over budget. (F-015 watchdog)\n`,
|
|
365
|
+
);
|
|
366
|
+
try { child.kill('SIGKILL'); } catch { /* best-effort */ }
|
|
367
|
+
clearInterval(handle);
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
overBudgetStreak = 0;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
const handle = setInterval(tick, intervalMs);
|
|
374
|
+
// Don't keep the event loop alive solely for the watchdog.
|
|
375
|
+
if (typeof handle.unref === 'function') handle.unref();
|
|
376
|
+
return {
|
|
377
|
+
stop: () => clearInterval(handle),
|
|
378
|
+
};
|
|
218
379
|
}
|
|
219
380
|
|
|
220
381
|
// ============================================================
|
|
@@ -309,6 +470,20 @@ export class LSPClient {
|
|
|
309
470
|
);
|
|
310
471
|
}
|
|
311
472
|
|
|
473
|
+
// F-014 (closed 2026-05-06): SUID/SGID bit detection. We only check
|
|
474
|
+
// when argv[0] is absolute (post the relative-path gate) — for
|
|
475
|
+
// allowRelativePath shapes the user has explicitly accepted that
|
|
476
|
+
// PATH-resolution semantics apply, including any SUID a binary
|
|
477
|
+
// resolved from PATH might have. Resolve the path to an absolute
|
|
478
|
+
// form so the lstat target is unambiguous.
|
|
479
|
+
if (!spec.allowSetuid) {
|
|
480
|
+
const absExe = isAbsolute(exe) ? exe : resolvePath(exe);
|
|
481
|
+
const det = _detectSetuid(absExe);
|
|
482
|
+
if (det !== null && det.hasSetuid) {
|
|
483
|
+
throw new LspBinaryIsSetuidError(det.resolvedPath, det.mode);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
312
487
|
const child = spawn(exe, spec.argv.slice(1), {
|
|
313
488
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
314
489
|
// Explicitly NO `shell: true` — argv array form is the security
|
|
@@ -324,6 +499,17 @@ export class LSPClient {
|
|
|
324
499
|
LANG: process.env.LANG ?? 'C.UTF-8',
|
|
325
500
|
},
|
|
326
501
|
});
|
|
502
|
+
|
|
503
|
+
// F-015 (closed 2026-05-06): RSS watchdog. Polls every 30s, kills
|
|
504
|
+
// child after sustained over-budget. Disabled when maxRssMb === 0.
|
|
505
|
+
const maxRssMb = spec.maxRssMb ?? DEFAULT_LSP_MAX_RSS_MB;
|
|
506
|
+
const watchdog = _startRssWatchdog(child, spec.language, maxRssMb);
|
|
507
|
+
|
|
508
|
+
// Stop the watchdog when the child exits naturally so the interval
|
|
509
|
+
// doesn't outlive the process.
|
|
510
|
+
child.once('exit', () => watchdog.stop());
|
|
511
|
+
child.once('error', () => watchdog.stop());
|
|
512
|
+
|
|
327
513
|
return new LSPClient(createStdioTransport(child), options);
|
|
328
514
|
}
|
|
329
515
|
|
|
@@ -42,6 +42,7 @@ export function ingestMemoryFile(
|
|
|
42
42
|
|
|
43
43
|
if (frontmatterMatch) {
|
|
44
44
|
try {
|
|
45
|
+
// pattern-scanner-allow: yaml-parse — reason: parses YAML FRONTMATTER from markdown memory files (NOT massu.config.yaml). This is document metadata parsing, not application config access; getConfig() does not apply.
|
|
45
46
|
const fm = parseYaml(frontmatterMatch[1]) as Record<string, unknown>;
|
|
46
47
|
name = (fm.name as string) ?? basename;
|
|
47
48
|
description = (fm.description as string) ?? '';
|