@yemi33/minions 0.1.1985 → 0.1.1986
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/bin/minions.js +3 -1
- package/dashboard/js/qa.js +53 -0
- package/dashboard/js/refresh.js +4 -2
- package/dashboard/js/render-managed.js +43 -9
- package/dashboard/js/render-other.js +41 -11
- package/dashboard/layout.html +1 -0
- package/dashboard/pages/qa.html +23 -0
- package/dashboard-build.js +2 -2
- package/dashboard.js +135 -24
- package/docs/README.md +2 -0
- package/docs/constellation-bridge.md +94 -0
- package/docs/security.md +177 -0
- package/engine/bridge.js +124 -0
- package/engine/cc-worker-pool.js +48 -1
- package/engine/cleanup.js +72 -23
- package/engine/cli.js +126 -12
- package/engine/dispatch.js +24 -11
- package/engine/github.js +79 -26
- package/engine/issues.js +14 -3
- package/engine/lifecycle.js +47 -11
- package/engine/llm.js +16 -9
- package/engine/meeting.js +16 -5
- package/engine/queries.js +123 -52
- package/engine/shared.js +265 -5
- package/engine/spawn-agent.js +13 -5
- package/engine/timeout.js +4 -2
- package/engine.js +59 -15
- package/package.json +1 -1
package/engine/queries.js
CHANGED
|
@@ -1527,73 +1527,142 @@ function resetPrdInfoCache() {
|
|
|
1527
1527
|
}
|
|
1528
1528
|
|
|
1529
1529
|
// ── Project git status (current branch + dirty/detached) ──────────────────
|
|
1530
|
-
// Cached per resolved localPath with a
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
1530
|
+
// Cached per resolved localPath with a 5-minute TTL. `getProjectGitStatus` is
|
|
1531
|
+
// synchronous (returns the cached value or a pending placeholder) and NEVER
|
|
1532
|
+
// blocks the event loop — the actual git probes run asynchronously via
|
|
1533
|
+
// child_process.execFile and write back into the cache when they settle. This
|
|
1534
|
+
// is critical for /api/status responsiveness: under the previous synchronous
|
|
1535
|
+
// implementation, four projects × three git invocations was ~12 sync shell-outs
|
|
1536
|
+
// per slow-state rebuild, which on Windows + Defender real-time scanning could
|
|
1537
|
+
// block the event loop long enough to starve SSE heartbeats and drop the CC
|
|
1538
|
+
// drawer's "Dashboard connection lost". Mirrors the cache shape used by
|
|
1539
|
+
// getDiskVersion in dashboard.js (TTL + cached map). All git invocations pipe
|
|
1540
|
+
// stderr to suppress the `fatal: not a git repository` noise on non-git project
|
|
1541
|
+
// paths — same requirement enforced for install/boot paths in
|
|
1535
1542
|
// test/unit/runtime-fleet-helpers.test.js:465.
|
|
1543
|
+
// Single map keyed by normalized localPath. Each entry holds `{ts, value, promise}`:
|
|
1544
|
+
// ts: last-settled timestamp (Date.now()), or 0 if never settled
|
|
1545
|
+
// value: last-settled status object (or PROJECT_GIT_STATUS_PENDING placeholder)
|
|
1546
|
+
// promise: in-flight refresh Promise, or null when idle
|
|
1547
|
+
// Folding state + in-flight into one map keeps reads/writes atomic and removes
|
|
1548
|
+
// the second-Map sync hazard.
|
|
1536
1549
|
const _projectGitStatusCache = new Map();
|
|
1537
|
-
const PROJECT_GIT_STATUS_TTL =
|
|
1538
|
-
|
|
1550
|
+
const PROJECT_GIT_STATUS_TTL = 300000; // 5 minutes
|
|
1551
|
+
const PROJECT_GIT_STATUS_PENDING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'pending' });
|
|
1552
|
+
const PROJECT_GIT_STATUS_MISSING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' });
|
|
1553
|
+
const PROJECT_GIT_STATUS_NON_GIT = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'non-git' });
|
|
1554
|
+
|
|
1555
|
+
// Async git invocation. Promise-returning so getProjectGitStatus can fire
|
|
1556
|
+
// background refreshes without blocking the event loop. Pipes stderr to keep
|
|
1557
|
+
// `fatal: not a git repository` from leaking to the dashboard console.
|
|
1539
1558
|
function _gitExec(localPath, args) {
|
|
1540
|
-
const {
|
|
1541
|
-
return
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1559
|
+
const { execFile } = require('child_process');
|
|
1560
|
+
return new Promise((resolve, reject) => {
|
|
1561
|
+
execFile('git', ['-C', localPath, ...args], {
|
|
1562
|
+
encoding: 'utf8',
|
|
1563
|
+
timeout: 10000,
|
|
1564
|
+
windowsHide: true,
|
|
1565
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1566
|
+
}, (err, stdout) => {
|
|
1567
|
+
if (err) reject(err);
|
|
1568
|
+
else resolve(stdout);
|
|
1569
|
+
});
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Probe a single project. Returns the resolved status value. Used both by the
|
|
1574
|
+
// background refresh path inside getProjectGitStatus and by test code that
|
|
1575
|
+
// wants to drive a probe to completion deterministically.
|
|
1576
|
+
async function _probeProjectGitStatus(localPath) {
|
|
1577
|
+
try {
|
|
1578
|
+
if (!fs.existsSync(localPath)) return PROJECT_GIT_STATUS_MISSING;
|
|
1579
|
+
let isRepo = false;
|
|
1580
|
+
try {
|
|
1581
|
+
const out = (await _gitExec(localPath, ['rev-parse', '--is-inside-work-tree'])).trim();
|
|
1582
|
+
isRepo = out === 'true';
|
|
1583
|
+
} catch { isRepo = false; }
|
|
1584
|
+
if (!isRepo) return PROJECT_GIT_STATUS_NON_GIT;
|
|
1585
|
+
let branch = null;
|
|
1586
|
+
let detached = false;
|
|
1587
|
+
try {
|
|
1588
|
+
branch = (await _gitExec(localPath, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || null;
|
|
1589
|
+
} catch { branch = null; }
|
|
1590
|
+
if (branch === 'HEAD') {
|
|
1591
|
+
detached = true;
|
|
1592
|
+
try { branch = (await _gitExec(localPath, ['rev-parse', '--short', 'HEAD'])).trim() || null; }
|
|
1593
|
+
catch { branch = null; }
|
|
1594
|
+
}
|
|
1595
|
+
let dirty = false;
|
|
1596
|
+
try {
|
|
1597
|
+
const status = await _gitExec(localPath, ['status', '--porcelain', '--untracked-files=no']);
|
|
1598
|
+
dirty = status.length > 0;
|
|
1599
|
+
} catch { dirty = false; }
|
|
1600
|
+
return { gitBranch: branch, gitDetached: detached, gitDirty: dirty, gitState: 'ok' };
|
|
1601
|
+
} catch {
|
|
1602
|
+
// Defensive — never let a git probe failure escape this helper.
|
|
1603
|
+
return PROJECT_GIT_STATUS_MISSING;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function _scheduleProjectGitStatusRefresh(localPath, key) {
|
|
1608
|
+
const existing = _projectGitStatusCache.get(key);
|
|
1609
|
+
if (existing && existing.promise) return existing.promise;
|
|
1610
|
+
const entry = existing || { ts: 0, value: PROJECT_GIT_STATUS_PENDING, promise: null };
|
|
1611
|
+
entry.promise = _probeProjectGitStatus(localPath).then(value => {
|
|
1612
|
+
entry.ts = Date.now();
|
|
1613
|
+
entry.value = value;
|
|
1614
|
+
entry.promise = null;
|
|
1615
|
+
return value;
|
|
1616
|
+
}, () => {
|
|
1617
|
+
entry.promise = null;
|
|
1618
|
+
return null;
|
|
1546
1619
|
});
|
|
1620
|
+
_projectGitStatusCache.set(key, entry);
|
|
1621
|
+
return entry.promise;
|
|
1547
1622
|
}
|
|
1548
1623
|
|
|
1549
1624
|
function getProjectGitStatus(localPath) {
|
|
1550
1625
|
const key = String(localPath || '').replace(/\\/g, '/');
|
|
1551
|
-
if (!key) return
|
|
1626
|
+
if (!key) return PROJECT_GIT_STATUS_MISSING;
|
|
1552
1627
|
const now = Date.now();
|
|
1553
1628
|
const cached = _projectGitStatusCache.get(key);
|
|
1554
|
-
if (cached && (now - cached.ts) < PROJECT_GIT_STATUS_TTL) return cached.value;
|
|
1629
|
+
if (cached && cached.ts && (now - cached.ts) < PROJECT_GIT_STATUS_TTL) return cached.value;
|
|
1630
|
+
// Cheap synchronous existsSync — short-circuits a path that just disappeared
|
|
1631
|
+
// (project removed) without scheduling a useless git probe.
|
|
1632
|
+
if (!fs.existsSync(localPath)) {
|
|
1633
|
+
_projectGitStatusCache.set(key, { ts: now, value: PROJECT_GIT_STATUS_MISSING, promise: null });
|
|
1634
|
+
return PROJECT_GIT_STATUS_MISSING;
|
|
1635
|
+
}
|
|
1636
|
+
// Stale or never-populated — kick off a background refresh and return the
|
|
1637
|
+
// previous value (or pending placeholder on the very first call). The next
|
|
1638
|
+
// /api/status response after the refresh settles will have fresh data.
|
|
1639
|
+
_scheduleProjectGitStatusRefresh(localPath, key);
|
|
1640
|
+
return cached ? cached.value : PROJECT_GIT_STATUS_PENDING;
|
|
1641
|
+
}
|
|
1555
1642
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
try {
|
|
1572
|
-
branch = _gitExec(localPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim() || null;
|
|
1573
|
-
} catch { branch = null; }
|
|
1574
|
-
if (branch === 'HEAD') {
|
|
1575
|
-
detached = true;
|
|
1576
|
-
try { branch = _gitExec(localPath, ['rev-parse', '--short', 'HEAD']).trim() || null; }
|
|
1577
|
-
catch { branch = null; }
|
|
1578
|
-
}
|
|
1579
|
-
let dirty = false;
|
|
1580
|
-
try {
|
|
1581
|
-
const status = _gitExec(localPath, ['status', '--porcelain', '--untracked-files=no']);
|
|
1582
|
-
dirty = status.length > 0;
|
|
1583
|
-
} catch { dirty = false; }
|
|
1584
|
-
value = { gitBranch: branch, gitDetached: detached, gitDirty: dirty, gitState: 'ok' };
|
|
1585
|
-
}
|
|
1586
|
-
}
|
|
1587
|
-
} catch {
|
|
1588
|
-
// Defensive — never let a git probe failure break /api/status
|
|
1589
|
-
value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
|
|
1643
|
+
// Force a refresh now and wait for completion. Called from engine boot to
|
|
1644
|
+
// pre-warm the cache for every configured project so the first /api/status
|
|
1645
|
+
// after restart already has data. Also used by tests to settle the async
|
|
1646
|
+
// probe deterministically.
|
|
1647
|
+
function warmProjectGitStatus(localPath) {
|
|
1648
|
+
const key = String(localPath || '').replace(/\\/g, '/');
|
|
1649
|
+
if (!key) return Promise.resolve();
|
|
1650
|
+
return _scheduleProjectGitStatusRefresh(localPath, key);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Wait for every in-flight probe to settle. Test helper.
|
|
1654
|
+
function _awaitPendingProjectGitStatusProbes() {
|
|
1655
|
+
const promises = [];
|
|
1656
|
+
for (const entry of _projectGitStatusCache.values()) {
|
|
1657
|
+
if (entry && entry.promise) promises.push(entry.promise);
|
|
1590
1658
|
}
|
|
1591
|
-
|
|
1592
|
-
return value;
|
|
1659
|
+
return Promise.all(promises);
|
|
1593
1660
|
}
|
|
1594
1661
|
|
|
1595
1662
|
/** Reset project git-status cache — exported for testing */
|
|
1596
|
-
function resetProjectGitStatusCache() {
|
|
1663
|
+
function resetProjectGitStatusCache() {
|
|
1664
|
+
_projectGitStatusCache.clear();
|
|
1665
|
+
}
|
|
1597
1666
|
|
|
1598
1667
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
1599
1668
|
|
|
@@ -1610,6 +1679,8 @@ module.exports = {
|
|
|
1610
1679
|
resetProjectGitStatusCache,
|
|
1611
1680
|
invalidateKnowledgeBaseCache,
|
|
1612
1681
|
getProjectGitStatus,
|
|
1682
|
+
warmProjectGitStatus,
|
|
1683
|
+
_awaitPendingProjectGitStatusProbes,
|
|
1613
1684
|
|
|
1614
1685
|
// Core state
|
|
1615
1686
|
getConfig, getControl, getDispatch, getDispatchQueue, getDispatchCompletionReport, invalidateDispatchCache,
|
package/engine/shared.js
CHANGED
|
@@ -68,6 +68,12 @@ const ENGINE_STATE_PATH = path.join(ENGINE_DIR, 'state.json');
|
|
|
68
68
|
const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
|
|
69
69
|
const PINNED_ITEMS_PATH = path.join(MINIONS_DIR, 'engine', 'kb-pins.json');
|
|
70
70
|
const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
|
|
71
|
+
// Constellation-bridge cross-repo marker (P-wi1-bridge-readonly). Written by the
|
|
72
|
+
// Constellation agent's bridge on every successful poll; read by `minions bridge
|
|
73
|
+
// status` to surface the last-seen timestamp. The engine itself never writes
|
|
74
|
+
// this file — it is a one-way breadcrumb from Constellation → Minions.
|
|
75
|
+
const CONSTELLATION_BRIDGE_MARKER_PATH = path.join(MINIONS_DIR, 'engine', 'constellation-bridge.json');
|
|
76
|
+
const CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION = 1;
|
|
71
77
|
|
|
72
78
|
// ── Timestamps & Logging ────────────────────────────────────────────────────
|
|
73
79
|
// Extracted from engine.js so engine/* modules can import directly without
|
|
@@ -477,6 +483,156 @@ function safeUnlink(p) {
|
|
|
477
483
|
try { fs.unlinkSync(p); } catch { /* cleanup */ }
|
|
478
484
|
}
|
|
479
485
|
|
|
486
|
+
// ─── Per-dispatch temp dirs (P-f6-tmp-toctou) ────────────────────────────────
|
|
487
|
+
//
|
|
488
|
+
// Background: agent dispatches share `engine/tmp/` with predictable prefixes
|
|
489
|
+
// (`prompt-<id>.md`, `pid-<id>.pid`, `sysprompt-<id>.md.tmp`). A sibling agent
|
|
490
|
+
// running under the same engine identity could plant a symlink at one of those
|
|
491
|
+
// paths before the engine wrote there, redirecting reads/writes to attacker-
|
|
492
|
+
// controlled content. Per-dispatch unique dirs (mkdtempSync + 0o700) close the
|
|
493
|
+
// path-prediction window; readFileNoFollow() closes the symlink-traversal
|
|
494
|
+
// window on platforms that support O_NOFOLLOW.
|
|
495
|
+
|
|
496
|
+
const DISPATCH_TMP_PREFIX = 'dispatch-';
|
|
497
|
+
|
|
498
|
+
function _dispatchTmpRoot() {
|
|
499
|
+
return path.join(MINIONS_DIR, 'engine', 'tmp');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function _safeDispatchId(dispatchId) {
|
|
503
|
+
return String(dispatchId || '').replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Create a unique tmp dir for a single dispatch.
|
|
507
|
+
// engine/tmp/dispatch-<safeId>-XXXXXX/ (6-char random suffix from mkdtemp)
|
|
508
|
+
// POSIX: chmod to 0o700 (owner-only). Windows: ACL inherits from MINIONS_DIR
|
|
509
|
+
// (typically owner-only on a single-user devbox); chmod is a no-op for ACLs.
|
|
510
|
+
function createDispatchTmpDir(dispatchId) {
|
|
511
|
+
const safeId = _safeDispatchId(dispatchId);
|
|
512
|
+
if (!safeId) throw new Error('createDispatchTmpDir: dispatchId required');
|
|
513
|
+
const tmpRoot = _dispatchTmpRoot();
|
|
514
|
+
if (!fs.existsSync(tmpRoot)) fs.mkdirSync(tmpRoot, { recursive: true });
|
|
515
|
+
const dir = fs.mkdtempSync(path.join(tmpRoot, `${DISPATCH_TMP_PREFIX}${safeId}-`));
|
|
516
|
+
if (process.platform !== 'win32') {
|
|
517
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
518
|
+
}
|
|
519
|
+
return dir;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Validate a tmpDir path before destructive ops (rmSync). Defends against a
|
|
523
|
+
// corrupted/poisoned dispatch.json `entry.tmpDir` field pointing at an
|
|
524
|
+
// arbitrary directory: must resolve under <MINIONS_DIR>/engine/tmp/, basename
|
|
525
|
+
// must match `dispatch-*`, and lstat must be a real directory (not a symlink).
|
|
526
|
+
function validateDispatchTmpDir(dirPath) {
|
|
527
|
+
if (!dirPath || typeof dirPath !== 'string') return false;
|
|
528
|
+
let resolved;
|
|
529
|
+
try { resolved = path.resolve(dirPath); }
|
|
530
|
+
catch { return false; }
|
|
531
|
+
const root = path.resolve(_dispatchTmpRoot());
|
|
532
|
+
const rel = path.relative(root, resolved);
|
|
533
|
+
if (rel.startsWith('..') || path.isAbsolute(rel) || rel === '') return false;
|
|
534
|
+
if (!path.basename(resolved).startsWith(DISPATCH_TMP_PREFIX)) return false;
|
|
535
|
+
let stat;
|
|
536
|
+
try { stat = fs.lstatSync(resolved); }
|
|
537
|
+
catch { return false; }
|
|
538
|
+
if (!stat.isDirectory()) return false;
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function removeDispatchTmpDir(dirPath) {
|
|
543
|
+
if (!validateDispatchTmpDir(dirPath)) return false;
|
|
544
|
+
try { fs.rmSync(dirPath, { recursive: true, force: true }); return true; }
|
|
545
|
+
catch { return false; }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Read a file using O_NOFOLLOW where the platform supports it. On Windows
|
|
549
|
+
// fs.constants.O_NOFOLLOW is undefined, so the bitwise-or below is a no-op
|
|
550
|
+
// (Windows non-junction reads don't follow symlinks the way POSIX does).
|
|
551
|
+
function readFileNoFollow(filepath, encoding = 'utf8') {
|
|
552
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
553
|
+
let fd;
|
|
554
|
+
try {
|
|
555
|
+
fd = fs.openSync(filepath, fs.constants.O_RDONLY | NOFOLLOW);
|
|
556
|
+
const stat = fs.fstatSync(fd);
|
|
557
|
+
const buf = Buffer.alloc(stat.size);
|
|
558
|
+
let read = 0;
|
|
559
|
+
while (read < stat.size) {
|
|
560
|
+
const n = fs.readSync(fd, buf, read, stat.size - read, read);
|
|
561
|
+
if (n <= 0) break;
|
|
562
|
+
read += n;
|
|
563
|
+
}
|
|
564
|
+
return encoding ? buf.toString(encoding) : buf;
|
|
565
|
+
} finally {
|
|
566
|
+
if (fd !== undefined) {
|
|
567
|
+
try { fs.closeSync(fd); } catch { /* close-after-error */ }
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Returns absolute paths to candidate PID files for a dispatch id. Resolution
|
|
573
|
+
// order: (1) entry.tmpDir/pid-<safeId>.pid if `entryOrId` is a dispatch entry
|
|
574
|
+
// with a validated tmpDir; (2) engine/tmp/dispatch-<safeId>-*/pid-<safeId>.pid
|
|
575
|
+
// (post-P-f6-tmp-toctou layout); (3) engine/tmp/pid-<safeId>.pid (legacy flat
|
|
576
|
+
// layout — pre-P-f6-tmp-toctou and during the transition window).
|
|
577
|
+
function dispatchPidCandidates(entryOrId) {
|
|
578
|
+
const entry = (entryOrId && typeof entryOrId === 'object') ? entryOrId : null;
|
|
579
|
+
const id = entry?.id || entryOrId;
|
|
580
|
+
const safeId = _safeDispatchId(id);
|
|
581
|
+
if (!safeId) return [];
|
|
582
|
+
const candidates = [];
|
|
583
|
+
if (entry?.tmpDir && validateDispatchTmpDir(entry.tmpDir)) {
|
|
584
|
+
candidates.push(path.join(entry.tmpDir, `pid-${safeId}.pid`));
|
|
585
|
+
}
|
|
586
|
+
const tmpRoot = _dispatchTmpRoot();
|
|
587
|
+
try {
|
|
588
|
+
for (const name of fs.readdirSync(tmpRoot)) {
|
|
589
|
+
if (!name.startsWith(`${DISPATCH_TMP_PREFIX}${safeId}-`)) continue;
|
|
590
|
+
const dir = path.join(tmpRoot, name);
|
|
591
|
+
if (!validateDispatchTmpDir(dir)) continue;
|
|
592
|
+
const candidate = path.join(dir, `pid-${safeId}.pid`);
|
|
593
|
+
if (!candidates.includes(candidate)) candidates.push(candidate);
|
|
594
|
+
}
|
|
595
|
+
} catch { /* tmp root may not exist yet */ }
|
|
596
|
+
const legacy = path.join(tmpRoot, `pid-${safeId}.pid`);
|
|
597
|
+
if (!candidates.includes(legacy)) candidates.push(legacy);
|
|
598
|
+
return candidates;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Resolve the first existing PID file path for a dispatch (entry preferred,
|
|
602
|
+
// then per-dispatch dir, then legacy). Returns null when none exist.
|
|
603
|
+
function findDispatchPidFile(entryOrId) {
|
|
604
|
+
for (const candidate of dispatchPidCandidates(entryOrId)) {
|
|
605
|
+
try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ }
|
|
606
|
+
}
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Iterate every PID file under engine/tmp/ — both the new per-dispatch layout
|
|
611
|
+
// and the legacy flat layout — invoking callback(absPath, basename, layout).
|
|
612
|
+
// `layout` is 'dispatch-dir' or 'legacy'. Used by orphan-reap and `kill`.
|
|
613
|
+
function forEachPidFile(callback) {
|
|
614
|
+
const tmpRoot = _dispatchTmpRoot();
|
|
615
|
+
let entries;
|
|
616
|
+
try { entries = fs.readdirSync(tmpRoot, { withFileTypes: true }); }
|
|
617
|
+
catch { return; }
|
|
618
|
+
for (const entry of entries) {
|
|
619
|
+
const full = path.join(tmpRoot, entry.name);
|
|
620
|
+
if (entry.isDirectory() && entry.name.startsWith(DISPATCH_TMP_PREFIX)) {
|
|
621
|
+
if (!validateDispatchTmpDir(full)) continue;
|
|
622
|
+
let inner;
|
|
623
|
+
try { inner = fs.readdirSync(full); }
|
|
624
|
+
catch { continue; }
|
|
625
|
+
for (const f of inner) {
|
|
626
|
+
if (f.startsWith('pid-') && f.endsWith('.pid')) {
|
|
627
|
+
callback(path.join(full, f), f, 'dispatch-dir');
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
} else if (entry.isFile() && entry.name.startsWith('pid-') && entry.name.endsWith('.pid')) {
|
|
631
|
+
callback(full, entry.name, 'legacy');
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
480
636
|
function neutralizeJsonBackupSidecar(filePath, inertData = { status: 'archived' }) {
|
|
481
637
|
const backupPath = filePath + '.backup';
|
|
482
638
|
try {
|
|
@@ -739,7 +895,17 @@ function withFileLock(lockPath, fn, {
|
|
|
739
895
|
} catch { /* payload is advisory; lock semantics unaffected */ }
|
|
740
896
|
break;
|
|
741
897
|
} catch (err) {
|
|
742
|
-
|
|
898
|
+
// EEXIST is the cross-platform exclusive-create collision.
|
|
899
|
+
// On Windows, the loser of an unlink/create race can surface as EPERM
|
|
900
|
+
// (errno -4048: "file is marked for deletion but not yet gone" — common
|
|
901
|
+
// when AV/Search Indexer holds an opportunistic handle on the file the
|
|
902
|
+
// prior holder just unlinked). Without this branch, the loser child
|
|
903
|
+
// dies mid-retry-loop with EPERM and the dispatch fails. Linux/macOS
|
|
904
|
+
// EPERM still throws to preserve real permission-error visibility.
|
|
905
|
+
// See W-mpd6lm1g000x195e.
|
|
906
|
+
const isLockRaceErr = err.code === 'EEXIST' ||
|
|
907
|
+
(process.platform === 'win32' && err.code === 'EPERM');
|
|
908
|
+
if (!isLockRaceErr) throw err;
|
|
743
909
|
// P-b7d4e8f2 — Stale-lock check combines mtime age with PID liveness:
|
|
744
910
|
// 1. If mtime <= LOCK_STALE_MS → never reap (recently active).
|
|
745
911
|
// 2. If JSON-parsable {pid, ts}:
|
|
@@ -773,15 +939,23 @@ function withFileLock(lockPath, fn, {
|
|
|
773
939
|
try {
|
|
774
940
|
fs.unlinkSync(lockPath);
|
|
775
941
|
} catch (unlinkErr) {
|
|
776
|
-
// ENOENT: another process deleted the lock between stat and unlink — safe to retry
|
|
777
|
-
|
|
942
|
+
// ENOENT: another process deleted the lock between stat and unlink — safe to retry.
|
|
943
|
+
// EPERM on Windows: file is in 'pending delete' state from a concurrent
|
|
944
|
+
// reaper — the OS will finalize the delete shortly; retry will succeed.
|
|
945
|
+
const tolerable = unlinkErr.code === 'ENOENT' ||
|
|
946
|
+
(process.platform === 'win32' && unlinkErr.code === 'EPERM');
|
|
947
|
+
if (!tolerable) throw unlinkErr;
|
|
778
948
|
}
|
|
779
949
|
continue; // lock just removed — retry immediately
|
|
780
950
|
}
|
|
781
951
|
}
|
|
782
952
|
} catch (staleErr) {
|
|
783
|
-
// ENOENT from statSync: lock file disappeared between EEXIST and stat — retry will succeed
|
|
784
|
-
|
|
953
|
+
// ENOENT from statSync: lock file disappeared between EEXIST and stat — retry will succeed.
|
|
954
|
+
// EPERM on Windows: file is in 'pending delete' state — statSync itself
|
|
955
|
+
// can fail; fall through to the normal retry sleep (W-mpd6lm1g000x195e).
|
|
956
|
+
const tolerable = staleErr.code === 'ENOENT' ||
|
|
957
|
+
(process.platform === 'win32' && staleErr.code === 'EPERM');
|
|
958
|
+
if (!tolerable) throw staleErr;
|
|
785
959
|
}
|
|
786
960
|
sleepMs(retryDelayMs);
|
|
787
961
|
}
|
|
@@ -1089,6 +1263,55 @@ function validateGhSlug(slug) {
|
|
|
1089
1263
|
return slug;
|
|
1090
1264
|
}
|
|
1091
1265
|
|
|
1266
|
+
// P-f2-gh-shell (F2): validators for `gh api` endpoint paths and numeric IDs.
|
|
1267
|
+
// Used by engine/github.js to gate argv-form shell-out calls (shellSafeGh)
|
|
1268
|
+
// against poisoned PR JSON that could carry shell metacharacters or non-numeric
|
|
1269
|
+
// IDs through to child_process. All validators throw a structured Error on
|
|
1270
|
+
// rejection — callers must let it propagate so the shell-out never happens.
|
|
1271
|
+
|
|
1272
|
+
// Allowlist mirrors the spec: only path chars + query-string punctuation that
|
|
1273
|
+
// the gh API legitimately uses. Specifically EXCLUDES shell metacharacters
|
|
1274
|
+
// (`;`, `$`, backtick, `|`, newlines, quotes, spaces, brackets). Leading `-`
|
|
1275
|
+
// is rejected separately to block argv injection (e.g. `--upload-pack=evil`).
|
|
1276
|
+
// `%` is allowed because callers URL-encode ref names via encodeURIComponent
|
|
1277
|
+
// (e.g. ghApi(`/compare/${encodeURIComponent(...)}...`)). Empty string is
|
|
1278
|
+
// explicitly allowed: the base-repo probe (`ghApi('', slug)`) hits
|
|
1279
|
+
// `repos/owner/repo` with no path suffix.
|
|
1280
|
+
function validateGhEndpoint(endpoint) {
|
|
1281
|
+
const fail = (why) => {
|
|
1282
|
+
throw new Error(`Invalid gh API endpoint (${why}): ${JSON.stringify(String(endpoint).slice(0, 128))}`);
|
|
1283
|
+
};
|
|
1284
|
+
if (typeof endpoint !== 'string') fail('not a string');
|
|
1285
|
+
if (endpoint.length === 0) return endpoint; // base-repo probe — no path suffix
|
|
1286
|
+
if (endpoint.length > 512) fail('too long');
|
|
1287
|
+
if (!/^[/A-Za-z0-9._%?=&\-]+$/.test(endpoint)) fail('disallowed character');
|
|
1288
|
+
if (endpoint.startsWith('-')) fail('leading dash (argv injection)');
|
|
1289
|
+
return endpoint;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Generic positive-integer ID validator. `name` is interpolated into the
|
|
1293
|
+
// thrown error so call sites can be diagnosed from logs alone.
|
|
1294
|
+
function validateGhNumericId(value, name = 'id') {
|
|
1295
|
+
const fail = (why) => {
|
|
1296
|
+
throw new Error(`Invalid GitHub ${name} (${why}): ${JSON.stringify(String(value).slice(0, 64))}`);
|
|
1297
|
+
};
|
|
1298
|
+
if (typeof value === 'string' && /^[0-9]+$/.test(value)) {
|
|
1299
|
+
const n = Number(value);
|
|
1300
|
+
if (!Number.isSafeInteger(n) || n <= 0) fail('not a positive safe integer');
|
|
1301
|
+
return n;
|
|
1302
|
+
}
|
|
1303
|
+
if (typeof value !== 'number') fail('not a number');
|
|
1304
|
+
if (!Number.isInteger(value)) fail('not an integer');
|
|
1305
|
+
if (!Number.isSafeInteger(value)) fail('not a safe integer');
|
|
1306
|
+
if (value <= 0) fail('not positive');
|
|
1307
|
+
return value;
|
|
1308
|
+
}
|
|
1309
|
+
// Thin aliases match the call-site naming in the threat model so greps for
|
|
1310
|
+
// `validatePrNum` / `validateReviewId` / `validateJobId` land on real code.
|
|
1311
|
+
const validatePrNum = (value) => validateGhNumericId(value, 'PR number');
|
|
1312
|
+
const validateReviewId = (value) => validateGhNumericId(value, 'review id');
|
|
1313
|
+
const validateJobId = (value) => validateGhNumericId(value, 'job id');
|
|
1314
|
+
|
|
1092
1315
|
// W-mp6k7ywi000fa33c — pure helper. Returns:
|
|
1093
1316
|
// { ok: boolean,
|
|
1094
1317
|
// reason?: string,
|
|
@@ -1664,6 +1887,29 @@ const ENGINE_DEFAULTS = {
|
|
|
1664
1887
|
// knows which subkeys to flag as deprecated. Do not consume `claude.*` in new code — use the runtime
|
|
1665
1888
|
// adapter system (engine/runtimes/) and the resolveAgent*/resolveCc* helpers instead.
|
|
1666
1889
|
_deprecatedConfigClaudeFields: ['binary', 'outputFormat', 'allowedTools', 'maxTurns', 'effort', 'budgetCap'],
|
|
1890
|
+
// ── Constellation bridge (P-wi1-bridge-readonly) ────────────────────────────
|
|
1891
|
+
// Read-only cross-repo bridge that lets the Constellation dashboard project
|
|
1892
|
+
// Minions engine state (agents, dispatch queue, PR pipeline) into its HUD.
|
|
1893
|
+
// Bridge logic itself lives in the Constellation repo
|
|
1894
|
+
// (packages/agent/src/bridges/) — Minions only owns the on/off flag and the
|
|
1895
|
+
// marker-file contract used by `minions bridge status`.
|
|
1896
|
+
//
|
|
1897
|
+
// Strict semantics: ONLY the literal boolean `true` enables the bridge. Any
|
|
1898
|
+
// missing, malformed, or non-boolean value is treated as disabled. The
|
|
1899
|
+
// Constellation reader MUST mirror this check (no truthy coercion) so a
|
|
1900
|
+
// typo'd `"enabled": "false"` does not silently turn the bridge on.
|
|
1901
|
+
//
|
|
1902
|
+
// Marker-file contract (`engine/constellation-bridge.json`, written by the
|
|
1903
|
+
// Constellation agent's bridge on every poll, never by the engine itself):
|
|
1904
|
+
// { "schemaVersion": 1, "lastSeenAt": "<ISO8601>",
|
|
1905
|
+
// "agentVersion": "<semver>", "source": "constellation-agent" }
|
|
1906
|
+
// See docs/constellation-bridge.md and bin/minions.js (`bridge` subcommand).
|
|
1907
|
+
//
|
|
1908
|
+
// Default OFF so the Minions-side PR can land independently of (and ahead
|
|
1909
|
+
// of) the Constellation-side PR per the WI acceptance criterion.
|
|
1910
|
+
constellationBridge: {
|
|
1911
|
+
enabled: false,
|
|
1912
|
+
},
|
|
1667
1913
|
};
|
|
1668
1914
|
|
|
1669
1915
|
// ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
|
|
@@ -4391,6 +4637,8 @@ module.exports = {
|
|
|
4391
4637
|
PR_LINKS_PATH,
|
|
4392
4638
|
PINNED_ITEMS_PATH,
|
|
4393
4639
|
LOG_PATH,
|
|
4640
|
+
CONSTELLATION_BRIDGE_MARKER_PATH,
|
|
4641
|
+
CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
|
|
4394
4642
|
currentLogPath: _currentLogPath,
|
|
4395
4643
|
ts,
|
|
4396
4644
|
logTs,
|
|
@@ -4402,6 +4650,13 @@ module.exports = {
|
|
|
4402
4650
|
safeWrite,
|
|
4403
4651
|
mutateTextFileLocked,
|
|
4404
4652
|
safeUnlink,
|
|
4653
|
+
createDispatchTmpDir,
|
|
4654
|
+
removeDispatchTmpDir,
|
|
4655
|
+
validateDispatchTmpDir,
|
|
4656
|
+
readFileNoFollow,
|
|
4657
|
+
dispatchPidCandidates,
|
|
4658
|
+
findDispatchPidFile,
|
|
4659
|
+
forEachPidFile,
|
|
4405
4660
|
resolveMinionsHome,
|
|
4406
4661
|
saveMinionsRootPointer,
|
|
4407
4662
|
neutralizeJsonBackupSidecar,
|
|
@@ -4436,6 +4691,11 @@ module.exports = {
|
|
|
4436
4691
|
shellSafeGitSync,
|
|
4437
4692
|
validateGitRef,
|
|
4438
4693
|
validateGhSlug,
|
|
4694
|
+
validateGhEndpoint,
|
|
4695
|
+
validateGhNumericId,
|
|
4696
|
+
validatePrNum,
|
|
4697
|
+
validateReviewId,
|
|
4698
|
+
validateJobId,
|
|
4439
4699
|
isValidGitWorktree,
|
|
4440
4700
|
resolveMainBranch,
|
|
4441
4701
|
run,
|
package/engine/spawn-agent.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
const fs = require('fs');
|
|
36
36
|
const os = require('os');
|
|
37
37
|
const path = require('path');
|
|
38
|
-
const { runFile, cleanChildEnv, killGracefully, killImmediate, killByPidsImmediate, listProcessDescendants, ts,
|
|
38
|
+
const { runFile, cleanChildEnv, killGracefully, killImmediate, killByPidsImmediate, listProcessDescendants, ts, readFileNoFollow } = require('./shared');
|
|
39
39
|
const { resolveRuntime } = require('./runtimes');
|
|
40
40
|
const { acquireAdoTokenSync, isLikelyAdoToken } = require('./ado-token');
|
|
41
41
|
const keepProcessSweep = require('./keep-process-sweep');
|
|
@@ -357,8 +357,13 @@ function main() {
|
|
|
357
357
|
process.exit(78);
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
|
|
361
|
-
|
|
360
|
+
// P-f6-tmp-toctou: read prompt/sysprompt with O_NOFOLLOW on POSIX so a
|
|
361
|
+
// sibling agent that planted a symlink at the prompt path cannot redirect
|
|
362
|
+
// our read to an attacker-controlled file. Windows: fs.constants.O_NOFOLLOW
|
|
363
|
+
// is undefined → the bitwise-or is a no-op and the open still succeeds
|
|
364
|
+
// (Windows non-junction reads don't follow symlinks the same way POSIX does).
|
|
365
|
+
const promptText = readFileNoFollow(promptFile, 'utf8');
|
|
366
|
+
const sysPromptText = readFileNoFollow(sysPromptFile, 'utf8');
|
|
362
367
|
|
|
363
368
|
// Sys prompt tmp file — only when (a) NOT resuming and (b) the adapter
|
|
364
369
|
// accepts a system prompt as a separate file. For runtimes that bake the
|
|
@@ -397,8 +402,11 @@ function main() {
|
|
|
397
402
|
opts, passthrough, addDirs,
|
|
398
403
|
});
|
|
399
404
|
|
|
400
|
-
// Debug log (async — not on critical path).
|
|
401
|
-
|
|
405
|
+
// Debug log (async — not on critical path). Lives inside the per-dispatch
|
|
406
|
+
// dir (path.dirname(promptFile)) so it inherits the unguessable parent
|
|
407
|
+
// (P-f6-tmp-toctou) instead of sharing engine/tmp/spawn-debug.log across all
|
|
408
|
+
// concurrent dispatches.
|
|
409
|
+
const tmpDir = path.dirname(promptFile);
|
|
402
410
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
403
411
|
const debugPath = path.join(tmpDir, 'spawn-debug.log');
|
|
404
412
|
fs.writeFile(
|
package/engine/timeout.js
CHANGED
|
@@ -184,9 +184,11 @@ function isTrackedProcessAlive(procInfo) {
|
|
|
184
184
|
// Read the on-disk PID for a dispatch ID, or null if missing/invalid. Shared by
|
|
185
185
|
// `isOsPidAliveForDispatch` and the recycled-PID command-line cross-check below
|
|
186
186
|
// so both paths agree on which integer PID to interrogate.
|
|
187
|
+
// P-f6-tmp-toctou: walks both legacy flat layout (engine/tmp/pid-<safeId>.pid)
|
|
188
|
+
// and the per-dispatch dir layout (engine/tmp/dispatch-<safeId>-*/pid-<safeId>.pid).
|
|
187
189
|
function _readDispatchPid(itemId) {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
+
const pidPath = shared.findDispatchPidFile(itemId);
|
|
191
|
+
if (!pidPath) return null;
|
|
190
192
|
let raw;
|
|
191
193
|
try { raw = fs.readFileSync(pidPath, 'utf8'); }
|
|
192
194
|
catch { return null; }
|