@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/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 30s TTL so /api/status doesn't shell
1531
- // out to git on every poll. Mirrors the cache shape used by getDiskVersion in
1532
- // dashboard.js (TTL + cached map). All git invocations pipe stderr to suppress
1533
- // the `fatal: not a git repository` noise on non-git project paths — same
1534
- // requirement enforced for install/boot paths in
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 = 30000; // 30s
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 { execFileSync } = require('child_process');
1541
- return execFileSync('git', ['-C', localPath, ...args], {
1542
- encoding: 'utf8',
1543
- timeout: 10000,
1544
- windowsHide: true,
1545
- stdio: ['pipe', 'pipe', 'pipe'],
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 { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
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
- let value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
1557
- try {
1558
- if (!fs.existsSync(localPath)) {
1559
- // missing — cache the negative result
1560
- } else {
1561
- let isRepo = false;
1562
- try {
1563
- const out = _gitExec(localPath, ['rev-parse', '--is-inside-work-tree']).trim();
1564
- isRepo = out === 'true';
1565
- } catch { isRepo = false; }
1566
- if (!isRepo) {
1567
- value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'non-git' };
1568
- } else {
1569
- let branch = null;
1570
- let detached = false;
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
- _projectGitStatusCache.set(key, { ts: now, value });
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() { _projectGitStatusCache.clear(); }
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
- if (err.code !== 'EEXIST') throw err;
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
- if (unlinkErr.code !== 'ENOENT') throw unlinkErr;
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
- if (staleErr.code !== 'ENOENT') throw staleErr;
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,
@@ -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, resolveEngineCacheDir } = require('./shared');
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
- const promptText = fs.readFileSync(promptFile, 'utf8');
361
- const sysPromptText = fs.readFileSync(sysPromptFile, 'utf8');
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
- const tmpDir = path.join(resolveEngineCacheDir(__dirname), 'tmp');
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 safeId = String(itemId || '').replace(/[:\\/*?"<>|]/g, '-');
189
- const pidPath = path.join(ENGINE_DIR, 'tmp', `pid-${safeId}.pid`);
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; }