nubos-pilot 1.2.3 → 1.2.4
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/CHANGELOG.md +8 -0
- package/README.md +16 -0
- package/bin/np-tools/learnings.cjs +4 -0
- package/bin/np-tools/security.cjs +3 -0
- package/bin/np-tools/spawn-headless.cjs +35 -1
- package/bin/np-tools/spawn-headless.test.cjs +135 -0
- package/lib/headless-guard.cjs +127 -0
- package/lib/headless-guard.test.cjs +119 -0
- package/package.json +1 -1
- package/templates/claude/payload/hooks/np-learnings-hook.cjs +1 -0
- package/templates/claude/payload/hooks/np-security-hook.cjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to nubos-pilot are documented in this file. Format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning
|
|
5
5
|
follows [SemVer](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [1.2.4] - 2026-06-15
|
|
8
|
+
|
|
9
|
+
Fixed a recursion fault in the in-session hooks that could spawn an unbounded cascade of headless `claude -p` processes.
|
|
10
|
+
|
|
11
|
+
- The Stop-hook security review and continuous-learning capture each spawn a headless `claude -p` to do their work. That headless run re-fires the same SessionStart/Stop hooks, which spawned another headless run, and so on — a fork bomb of `claude`, `np-tools` and duplicated MCP servers that survived closing the terminal. nubos-pilot now marks every headless spawn with `NUBOS_PILOT_HEADLESS=1` and a `NUBOS_PILOT_HOOK_DEPTH` counter; the hooks no-op immediately inside a headless run, so the chain stops at exactly one level.
|
|
12
|
+
- Three independent guards back this up: the hook scripts and the `security`/`learnings` backends exit early when `NUBOS_PILOT_HEADLESS` is set; `spawn-headless` refuses to start a nested headless run (reentrancy + depth cap, default one level); and a per-agent lockfile under `.nubos-pilot/run/` bounds concurrent headless runs to one per agent even if the environment is not inherited. Headless runs already carry a hard timeout with SIGKILL, so a hung review cannot linger.
|
|
13
|
+
- Escape hatch: the guard keys off `NUBOS_PILOT_HEADLESS`, set automatically on the spawned `claude` — do not set it in your own shell or the in-session hooks will silently no-op. Raise the depth cap with `NUBOS_PILOT_MAX_HOOK_DEPTH` only if you understand the recursion risk.
|
|
14
|
+
|
|
7
15
|
## [1.2.3] — 2026-06-14
|
|
8
16
|
|
|
9
17
|
Three opt-in layers that make execution cheaper, more reliable, and self-improving.
|
package/README.md
CHANGED
|
@@ -169,6 +169,22 @@ load-bearing ones for users and contributors:
|
|
|
169
169
|
See [`SECURITY.md`](./SECURITY.md) for the vulnerability disclosure policy
|
|
170
170
|
and threat model.
|
|
171
171
|
|
|
172
|
+
### Headless recursion guard
|
|
173
|
+
|
|
174
|
+
The in-session security review and continuous-learning hooks do their work in
|
|
175
|
+
a headless `claude -p` subprocess. To stop that subprocess from re-firing the
|
|
176
|
+
same hooks (which would cascade into an unbounded fork of `claude`/`np-tools`
|
|
177
|
+
processes), nubos-pilot sets `NUBOS_PILOT_HEADLESS=1` and a
|
|
178
|
+
`NUBOS_PILOT_HOOK_DEPTH` counter on every headless spawn. The hooks no-op when
|
|
179
|
+
`NUBOS_PILOT_HEADLESS` is set, `spawn-headless` refuses a nested or
|
|
180
|
+
depth-exceeded spawn, and a per-agent lockfile under `.nubos-pilot/run/` bounds
|
|
181
|
+
concurrent headless runs to one per agent.
|
|
182
|
+
|
|
183
|
+
The guard is automatic — do not export `NUBOS_PILOT_HEADLESS` in your own
|
|
184
|
+
shell, or the in-session hooks will silently do nothing. The depth cap is one
|
|
185
|
+
level; override it with `NUBOS_PILOT_MAX_HOOK_DEPTH` only if you understand the
|
|
186
|
+
recursion risk.
|
|
187
|
+
|
|
172
188
|
## Support
|
|
173
189
|
|
|
174
190
|
- Bugs / features: [GitHub issues](https://github.com/Nubos-AI/nubos-pilot/issues)
|
|
@@ -6,6 +6,7 @@ const child_process = require('node:child_process');
|
|
|
6
6
|
const { tryReadConfigPath } = require('../../lib/config.cjs');
|
|
7
7
|
const ledger = require('../../lib/learnings/capture-ledger.cjs');
|
|
8
8
|
const extract = require('../../lib/learnings/extract.cjs');
|
|
9
|
+
const headlessGuard = require('../../lib/headless-guard.cjs');
|
|
9
10
|
const args = require('./_args.cjs');
|
|
10
11
|
|
|
11
12
|
function _readStdin() {
|
|
@@ -60,6 +61,9 @@ async function run(argv, ctx) {
|
|
|
60
61
|
const stdout = context.stdout || process.stdout;
|
|
61
62
|
const list = Array.isArray(argv) ? argv : [];
|
|
62
63
|
const verb = list[0];
|
|
64
|
+
|
|
65
|
+
if (headlessGuard.isHeadless(process.env)) return 0;
|
|
66
|
+
|
|
63
67
|
const cfg = _cfg(cwd);
|
|
64
68
|
|
|
65
69
|
// 'reset' (UserPromptSubmit) and 'run-extract' (background worker) are not
|
|
@@ -8,6 +8,7 @@ const { tryReadConfigPath } = require('../../lib/config.cjs');
|
|
|
8
8
|
const scan = require('../../lib/security/scan.cjs');
|
|
9
9
|
const ledger = require('../../lib/security/ledger.cjs');
|
|
10
10
|
const review = require('../../lib/security/review.cjs');
|
|
11
|
+
const headlessGuard = require('../../lib/headless-guard.cjs');
|
|
11
12
|
const args = require('./_args.cjs');
|
|
12
13
|
|
|
13
14
|
const COMMIT_RE = /\bgit\b[\s\S]*\b(commit|push)\b/;
|
|
@@ -93,6 +94,8 @@ async function run(argv, ctx) {
|
|
|
93
94
|
const list = Array.isArray(argv) ? argv : [];
|
|
94
95
|
const verb = list[0];
|
|
95
96
|
|
|
97
|
+
if (headlessGuard.isHeadless(process.env)) return 0;
|
|
98
|
+
|
|
96
99
|
const cfg = _cfg(cwd);
|
|
97
100
|
if (!cfg.enabled && verb !== 'run-review') return 0;
|
|
98
101
|
|
|
@@ -8,6 +8,7 @@ const child_process = require('node:child_process');
|
|
|
8
8
|
const { NubosPilotError, atomicWriteFileSync, appendJsonl, findProjectRoot } = require('../../lib/core.cjs');
|
|
9
9
|
const runContext = require('../../lib/run-context.cjs');
|
|
10
10
|
const safePath = require('../../lib/safe-path.cjs');
|
|
11
|
+
const headlessGuard = require('../../lib/headless-guard.cjs');
|
|
11
12
|
const args = require('./_args.cjs');
|
|
12
13
|
|
|
13
14
|
function _sha256(s) {
|
|
@@ -171,6 +172,22 @@ function run(argv, ctx) {
|
|
|
171
172
|
const stdout = context.stdout || process.stdout;
|
|
172
173
|
const list = Array.isArray(argv) ? argv : [];
|
|
173
174
|
|
|
175
|
+
if (headlessGuard.isHeadless(process.env)) {
|
|
176
|
+
throw new NubosPilotError(
|
|
177
|
+
'spawn-headless-reentrant',
|
|
178
|
+
'refusing to spawn a nested headless `claude` (NUBOS_PILOT_HEADLESS is set) — recursion guard',
|
|
179
|
+
{ depth: headlessGuard.currentDepth(process.env) },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (headlessGuard.depthExceeded(process.env)) {
|
|
183
|
+
throw new NubosPilotError(
|
|
184
|
+
'spawn-headless-depth-exceeded',
|
|
185
|
+
'refusing to spawn headless `claude`: hook depth ' + headlessGuard.currentDepth(process.env)
|
|
186
|
+
+ ' has reached the cap ' + headlessGuard.maxDepth(process.env) + ' (recursion guard)',
|
|
187
|
+
{ depth: headlessGuard.currentDepth(process.env), max: headlessGuard.maxDepth(process.env) },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
174
191
|
const agent = args.getFlag(list, '--agent');
|
|
175
192
|
if (!agent) {
|
|
176
193
|
throw new NubosPilotError(
|
|
@@ -213,6 +230,21 @@ function run(argv, ctx) {
|
|
|
213
230
|
|
|
214
231
|
const runId = runContext.getRunId();
|
|
215
232
|
|
|
233
|
+
let lockRoot;
|
|
234
|
+
try { lockRoot = findProjectRoot(cwd); }
|
|
235
|
+
catch { lockRoot = cwd; }
|
|
236
|
+
const lock = headlessGuard.tryAcquireSpawnLock(lockRoot, agent);
|
|
237
|
+
if (!lock.acquired) {
|
|
238
|
+
throw new NubosPilotError(
|
|
239
|
+
'spawn-headless-locked',
|
|
240
|
+
'another headless run for agent `' + agent + '` is already active in this project (concurrency guard)',
|
|
241
|
+
{ agent, holder: lock.holder || null },
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const childEnv = _filterSpawnEnv(process.env);
|
|
246
|
+
Object.assign(childEnv, headlessGuard.childSpawnEnv(process.env));
|
|
247
|
+
|
|
216
248
|
const bin = _claudeBinary();
|
|
217
249
|
const claudeArgs = ['-p', '--output-format', 'json'];
|
|
218
250
|
const startedAt = new Date().toISOString();
|
|
@@ -224,7 +256,7 @@ function run(argv, ctx) {
|
|
|
224
256
|
timeout: timeoutMs,
|
|
225
257
|
maxBuffer: 64 * 1024 * 1024,
|
|
226
258
|
encoding: 'utf-8',
|
|
227
|
-
env:
|
|
259
|
+
env: childEnv,
|
|
228
260
|
killSignal: 'SIGKILL',
|
|
229
261
|
});
|
|
230
262
|
} catch (err) {
|
|
@@ -233,6 +265,8 @@ function run(argv, ctx) {
|
|
|
233
265
|
'failed to spawn `' + bin + '`: ' + (err && err.message),
|
|
234
266
|
{ bin, cause: err && err.code },
|
|
235
267
|
);
|
|
268
|
+
} finally {
|
|
269
|
+
lock.release();
|
|
236
270
|
}
|
|
237
271
|
if (result.error && result.error.code === 'ENOENT') {
|
|
238
272
|
throw new NubosPilotError(
|
|
@@ -8,6 +8,14 @@ const assert = require('node:assert/strict');
|
|
|
8
8
|
|
|
9
9
|
const spawnHeadless = require('./spawn-headless.cjs');
|
|
10
10
|
const runContext = require('../../lib/run-context.cjs');
|
|
11
|
+
const headlessGuard = require('../../lib/headless-guard.cjs');
|
|
12
|
+
|
|
13
|
+
function _mockClaude(r, name, body) {
|
|
14
|
+
const p = path.join(r, name);
|
|
15
|
+
fs.writeFileSync(p, body, 'utf-8');
|
|
16
|
+
fs.chmodSync(p, 0o755);
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
11
19
|
|
|
12
20
|
const _sandboxes = [];
|
|
13
21
|
const _envBackup = {};
|
|
@@ -488,6 +496,133 @@ test('SH-TRAIL-2 two sequential spawns append two parseable trail lines (jsonl i
|
|
|
488
496
|
for (const l of lines) JSON.parse(l);
|
|
489
497
|
});
|
|
490
498
|
|
|
499
|
+
test('SH-GUARD-1 refuses to spawn when NUBOS_PILOT_HEADLESS=1 (reentrancy guard)', () => {
|
|
500
|
+
const r = _mkRoot();
|
|
501
|
+
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
502
|
+
const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
|
|
503
|
+
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
504
|
+
_setEnv('NUBOS_PILOT_HEADLESS', '1');
|
|
505
|
+
const cap = _cap();
|
|
506
|
+
assert.throws(
|
|
507
|
+
() => spawnHeadless.run(
|
|
508
|
+
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
509
|
+
{ cwd: r, stdout: cap.stub },
|
|
510
|
+
),
|
|
511
|
+
(err) => err && err.code === 'spawn-headless-reentrant',
|
|
512
|
+
);
|
|
513
|
+
assert.equal(fs.existsSync(path.join(r, 'out.json')), false, 'no claude must be spawned inside a headless run');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('SH-GUARD-2 refuses to spawn when hook depth has reached the cap (depth guard)', () => {
|
|
517
|
+
const r = _mkRoot();
|
|
518
|
+
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
519
|
+
const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
|
|
520
|
+
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
521
|
+
_setEnv('NUBOS_PILOT_HOOK_DEPTH', '1');
|
|
522
|
+
const cap = _cap();
|
|
523
|
+
assert.throws(
|
|
524
|
+
() => spawnHeadless.run(
|
|
525
|
+
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
526
|
+
{ cwd: r, stdout: cap.stub },
|
|
527
|
+
),
|
|
528
|
+
(err) => err && err.code === 'spawn-headless-depth-exceeded',
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('SH-GUARD-3 child env carries NUBOS_PILOT_HEADLESS=1 and depth=1 (one level deep only)', () => {
|
|
533
|
+
const r = _mkRoot();
|
|
534
|
+
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
535
|
+
const mockBin = _mockClaude(r, 'mock.sh',
|
|
536
|
+
'#!/bin/sh\ncat > /dev/null\nprintf \'{"hl":"\'$NUBOS_PILOT_HEADLESS\'","depth":"\'$NUBOS_PILOT_HOOK_DEPTH\'"}\\n\'\n');
|
|
537
|
+
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
538
|
+
const cap = _cap();
|
|
539
|
+
const rc = spawnHeadless.run(
|
|
540
|
+
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
541
|
+
{ cwd: r, stdout: cap.stub },
|
|
542
|
+
);
|
|
543
|
+
assert.equal(rc, 0);
|
|
544
|
+
const child = JSON.parse(fs.readFileSync(path.join(r, 'out.json'), 'utf-8'));
|
|
545
|
+
assert.equal(child.hl, '1', 'child claude must run with NUBOS_PILOT_HEADLESS=1');
|
|
546
|
+
assert.equal(child.depth, '1', 'child claude must run at hook depth 1');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('SH-GUARD-4 refuses to spawn while a live lock for the same agent is held (concurrency guard)', () => {
|
|
550
|
+
const r = _mkRoot();
|
|
551
|
+
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
552
|
+
const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
|
|
553
|
+
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
554
|
+
const held = headlessGuard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
555
|
+
assert.equal(held.acquired, true);
|
|
556
|
+
const cap = _cap();
|
|
557
|
+
try {
|
|
558
|
+
assert.throws(
|
|
559
|
+
() => spawnHeadless.run(
|
|
560
|
+
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
561
|
+
{ cwd: r, stdout: cap.stub },
|
|
562
|
+
),
|
|
563
|
+
(err) => err && err.code === 'spawn-headless-locked',
|
|
564
|
+
);
|
|
565
|
+
} finally {
|
|
566
|
+
held.release();
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('SH-GUARD-5 lock is released after a successful spawn (re-spawnable)', () => {
|
|
571
|
+
const r = _mkRoot();
|
|
572
|
+
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
573
|
+
const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
|
|
574
|
+
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
575
|
+
const cap = _cap();
|
|
576
|
+
for (let i = 0; i < 2; i++) {
|
|
577
|
+
const rc = spawnHeadless.run(
|
|
578
|
+
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out' + i + '.json'],
|
|
579
|
+
{ cwd: r, stdout: cap.stub },
|
|
580
|
+
);
|
|
581
|
+
assert.equal(rc, 0, 'sequential spawns must each acquire and release the lock');
|
|
582
|
+
}
|
|
583
|
+
assert.equal(fs.existsSync(headlessGuard._lockPath(r, 'np-test-critic')), false, 'no lock residue after spawns');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('SH-GUARD-6 a held lock for one agent does NOT block a different agent (per-agent scope)', () => {
|
|
587
|
+
const r = _mkRoot();
|
|
588
|
+
fs.writeFileSync(
|
|
589
|
+
path.join(r, '.nubos-pilot', 'agents', 'np-other-critic.md'),
|
|
590
|
+
'---\nname: np-other-critic\n---\n\n# Role\n',
|
|
591
|
+
'utf-8',
|
|
592
|
+
);
|
|
593
|
+
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
594
|
+
const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
|
|
595
|
+
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
596
|
+
const held = headlessGuard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
597
|
+
assert.equal(held.acquired, true);
|
|
598
|
+
const cap = _cap();
|
|
599
|
+
try {
|
|
600
|
+
const rc = spawnHeadless.run(
|
|
601
|
+
['--agent', 'np-other-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
602
|
+
{ cwd: r, stdout: cap.stub },
|
|
603
|
+
);
|
|
604
|
+
assert.equal(rc, 0, 'a different agent must spawn while np-test-critic is locked');
|
|
605
|
+
} finally {
|
|
606
|
+
held.release();
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('SH-GUARD-7 lock is released even when the spawn errors (claude-not-found)', () => {
|
|
611
|
+
const r = _mkRoot();
|
|
612
|
+
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
613
|
+
_setEnv('NUBOS_PILOT_CLAUDE_BIN', path.join(r, 'no-such-binary'));
|
|
614
|
+
const cap = _cap();
|
|
615
|
+
assert.throws(
|
|
616
|
+
() => spawnHeadless.run(
|
|
617
|
+
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
618
|
+
{ cwd: r, stdout: cap.stub },
|
|
619
|
+
),
|
|
620
|
+
(err) => err && err.code === 'spawn-headless-claude-not-found',
|
|
621
|
+
);
|
|
622
|
+
assert.equal(fs.existsSync(headlessGuard._lockPath(r, 'np-test-critic')), false,
|
|
623
|
+
'the per-agent lock must not leak when the spawn fails');
|
|
624
|
+
});
|
|
625
|
+
|
|
491
626
|
test('SH-ENV-4 NUBOS_PILOT_/CLAUDE_/ANTHROPIC_ prefixed vars pass through (whitelisted prefix)', () => {
|
|
492
627
|
const parent = {
|
|
493
628
|
PATH: '/usr/bin',
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const { atomicCreateExclusiveSync } = require('./core.cjs');
|
|
9
|
+
|
|
10
|
+
const HEADLESS_ENV = 'NUBOS_PILOT_HEADLESS';
|
|
11
|
+
const DEPTH_ENV = 'NUBOS_PILOT_HOOK_DEPTH';
|
|
12
|
+
const MAX_DEPTH_ENV = 'NUBOS_PILOT_MAX_HOOK_DEPTH';
|
|
13
|
+
const DEFAULT_MAX_DEPTH = 1;
|
|
14
|
+
const DEFAULT_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
function isHeadless(env) {
|
|
17
|
+
const e = env || process.env;
|
|
18
|
+
return e[HEADLESS_ENV] === '1';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function currentDepth(env) {
|
|
22
|
+
const e = env || process.env;
|
|
23
|
+
const n = parseInt(e[DEPTH_ENV], 10);
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function maxDepth(env) {
|
|
28
|
+
const e = env || process.env;
|
|
29
|
+
const n = parseInt(e[MAX_DEPTH_ENV], 10);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_MAX_DEPTH;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function depthExceeded(env) {
|
|
34
|
+
return currentDepth(env) >= maxDepth(env);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function childSpawnEnv(env) {
|
|
38
|
+
const out = Object.create(null);
|
|
39
|
+
out[HEADLESS_ENV] = '1';
|
|
40
|
+
out[DEPTH_ENV] = String(currentDepth(env) + 1);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function _isPidAlive(pid) {
|
|
45
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
46
|
+
try { process.kill(pid, 0); return true; }
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err && err.code === 'ESRCH') return false;
|
|
49
|
+
if (err && err.code === 'EPERM') return true;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _lockPath(root, agent) {
|
|
55
|
+
return path.join(root, '.nubos-pilot', 'run', 'headless-' + agent + '.lock');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _reclaimStaleLock(lockPath) {
|
|
59
|
+
const aside = lockPath + '.stale.' + process.pid + '.' + crypto.randomBytes(4).toString('hex');
|
|
60
|
+
try { fs.renameSync(lockPath, aside); }
|
|
61
|
+
catch { return; }
|
|
62
|
+
try { fs.unlinkSync(aside); } catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tryAcquireSpawnLock(root, agent, opts) {
|
|
66
|
+
const o = opts || {};
|
|
67
|
+
const staleMs = Number.isFinite(o.staleMs) ? o.staleMs : DEFAULT_LOCK_STALE_MS;
|
|
68
|
+
const lockPath = _lockPath(root, agent);
|
|
69
|
+
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch {}
|
|
70
|
+
const payload = JSON.stringify({
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
agent,
|
|
73
|
+
hostname: os.hostname(),
|
|
74
|
+
acquiredAt: new Date().toISOString(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
atomicCreateExclusiveSync(lockPath, payload);
|
|
80
|
+
let released = false;
|
|
81
|
+
return {
|
|
82
|
+
acquired: true,
|
|
83
|
+
lockPath,
|
|
84
|
+
release() {
|
|
85
|
+
if (released) return;
|
|
86
|
+
released = true;
|
|
87
|
+
let meta = null;
|
|
88
|
+
try { meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch {}
|
|
89
|
+
if (meta && meta.pid !== process.pid) return;
|
|
90
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (!err || err.code !== 'EEXIST') {
|
|
95
|
+
return { acquired: false, error: (err && err.code) || 'unknown' };
|
|
96
|
+
}
|
|
97
|
+
let meta = null;
|
|
98
|
+
let stat = null;
|
|
99
|
+
try { meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch {}
|
|
100
|
+
try { stat = fs.statSync(lockPath); } catch {}
|
|
101
|
+
const ageStale = !!stat && (Date.now() - stat.mtimeMs > staleMs);
|
|
102
|
+
const pidDead = !!meta && _isPidAlive(meta.pid) === false;
|
|
103
|
+
if (ageStale || pidDead) {
|
|
104
|
+
_reclaimStaleLock(lockPath);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
return { acquired: false, holder: meta };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { acquired: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
HEADLESS_ENV,
|
|
115
|
+
DEPTH_ENV,
|
|
116
|
+
MAX_DEPTH_ENV,
|
|
117
|
+
DEFAULT_MAX_DEPTH,
|
|
118
|
+
DEFAULT_LOCK_STALE_MS,
|
|
119
|
+
isHeadless,
|
|
120
|
+
currentDepth,
|
|
121
|
+
maxDepth,
|
|
122
|
+
depthExceeded,
|
|
123
|
+
childSpawnEnv,
|
|
124
|
+
tryAcquireSpawnLock,
|
|
125
|
+
_isPidAlive,
|
|
126
|
+
_lockPath,
|
|
127
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { test, afterEach } = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
|
|
9
|
+
const guard = require('./headless-guard.cjs');
|
|
10
|
+
|
|
11
|
+
const _sandboxes = [];
|
|
12
|
+
|
|
13
|
+
function _mkRoot() {
|
|
14
|
+
const r = fs.mkdtempSync(path.join(os.tmpdir(), 'np-headless-guard-'));
|
|
15
|
+
fs.mkdirSync(path.join(r, '.nubos-pilot'), { recursive: true });
|
|
16
|
+
_sandboxes.push(r);
|
|
17
|
+
return r;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
while (_sandboxes.length) {
|
|
22
|
+
const r = _sandboxes.pop();
|
|
23
|
+
try { fs.rmSync(r, { recursive: true, force: true }); } catch {}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('HG-1: isHeadless is true only when NUBOS_PILOT_HEADLESS=1', () => {
|
|
28
|
+
assert.equal(guard.isHeadless({}), false);
|
|
29
|
+
assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: '0' }), false);
|
|
30
|
+
assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: 'yes' }), false);
|
|
31
|
+
assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: '1' }), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('HG-2: currentDepth parses NUBOS_PILOT_HOOK_DEPTH, defaults to 0', () => {
|
|
35
|
+
assert.equal(guard.currentDepth({}), 0);
|
|
36
|
+
assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: 'x' }), 0);
|
|
37
|
+
assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: '0' }), 0);
|
|
38
|
+
assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: '2' }), 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('HG-3: depthExceeded honours default cap of 1 and the env override', () => {
|
|
42
|
+
assert.equal(guard.depthExceeded({}), false);
|
|
43
|
+
assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '1' }), true);
|
|
44
|
+
assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '1', NUBOS_PILOT_MAX_HOOK_DEPTH: '2' }), false);
|
|
45
|
+
assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '2', NUBOS_PILOT_MAX_HOOK_DEPTH: '2' }), true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('HG-4: childSpawnEnv marks headless and increments depth', () => {
|
|
49
|
+
assert.deepEqual({ ...guard.childSpawnEnv({}) }, { NUBOS_PILOT_HEADLESS: '1', NUBOS_PILOT_HOOK_DEPTH: '1' });
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
{ ...guard.childSpawnEnv({ NUBOS_PILOT_HOOK_DEPTH: '1' }) },
|
|
52
|
+
{ NUBOS_PILOT_HEADLESS: '1', NUBOS_PILOT_HOOK_DEPTH: '2' },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('HG-5: tryAcquireSpawnLock acquires, then refuses a live concurrent holder', () => {
|
|
57
|
+
const r = _mkRoot();
|
|
58
|
+
const first = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
59
|
+
assert.equal(first.acquired, true);
|
|
60
|
+
assert.ok(fs.existsSync(first.lockPath));
|
|
61
|
+
|
|
62
|
+
const second = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
63
|
+
assert.equal(second.acquired, false, 'second concurrent acquire must be refused');
|
|
64
|
+
assert.ok(second.holder && second.holder.pid === process.pid);
|
|
65
|
+
|
|
66
|
+
first.release();
|
|
67
|
+
assert.equal(fs.existsSync(first.lockPath), false, 'release removes the lock');
|
|
68
|
+
|
|
69
|
+
const third = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
70
|
+
assert.equal(third.acquired, true, 'lock is re-acquirable after release');
|
|
71
|
+
third.release();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('HG-6: different agents get independent locks', () => {
|
|
75
|
+
const r = _mkRoot();
|
|
76
|
+
const a = guard.tryAcquireSpawnLock(r, 'np-security-reviewer');
|
|
77
|
+
const b = guard.tryAcquireSpawnLock(r, 'np-learnings-extractor');
|
|
78
|
+
assert.equal(a.acquired, true);
|
|
79
|
+
assert.equal(b.acquired, true, 'a second agent must not be blocked by the first');
|
|
80
|
+
a.release();
|
|
81
|
+
b.release();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('HG-7: a stale lock (old mtime) is reclaimed', () => {
|
|
85
|
+
const r = _mkRoot();
|
|
86
|
+
const held = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
87
|
+
assert.equal(held.acquired, true);
|
|
88
|
+
const past = new Date(Date.now() - 60 * 60 * 1000);
|
|
89
|
+
fs.utimesSync(held.lockPath, past, past);
|
|
90
|
+
|
|
91
|
+
const next = guard.tryAcquireSpawnLock(r, 'np-test-critic', { staleMs: 1000 });
|
|
92
|
+
assert.equal(next.acquired, true, 'a lock older than staleMs must be reclaimed');
|
|
93
|
+
next.release();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('HG-8: a dead-pid lock is reclaimed even when fresh', () => {
|
|
97
|
+
const r = _mkRoot();
|
|
98
|
+
const lockPath = guard._lockPath(r, 'np-test-critic');
|
|
99
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
100
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: 2147483646, hostname: os.hostname(), acquiredAt: new Date().toISOString() }), 'utf-8');
|
|
101
|
+
assert.equal(guard._isPidAlive(2147483646), false);
|
|
102
|
+
|
|
103
|
+
const next = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
104
|
+
assert.equal(next.acquired, true, 'a lock owned by a dead pid must be reclaimed');
|
|
105
|
+
next.release();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('HG-9: stale reclaim leaves no .stale residue behind', () => {
|
|
109
|
+
const r = _mkRoot();
|
|
110
|
+
const held = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
111
|
+
const past = new Date(Date.now() - 60 * 60 * 1000);
|
|
112
|
+
fs.utimesSync(held.lockPath, past, past);
|
|
113
|
+
const next = guard.tryAcquireSpawnLock(r, 'np-test-critic', { staleMs: 1000 });
|
|
114
|
+
assert.equal(next.acquired, true);
|
|
115
|
+
next.release();
|
|
116
|
+
const runDir = path.join(r, '.nubos-pilot', 'run');
|
|
117
|
+
const residue = fs.readdirSync(runDir).filter((n) => n.includes('.stale.'));
|
|
118
|
+
assert.deepEqual(residue, [], 'rename-aside reclaim must clean up its temp file');
|
|
119
|
+
});
|
package/package.json
CHANGED