@yemi33/minions 0.1.1984 → 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/.nojekyll +0 -0
- package/docs/README.md +2 -0
- package/docs/constellation-bridge.md +94 -0
- package/docs/security.md +177 -0
- package/engine/ado-git-auth.js +206 -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 +169 -12
- package/engine/dispatch.js +26 -11
- package/engine/github.js +79 -26
- package/engine/issues.js +14 -3
- package/engine/lifecycle.js +55 -14
- package/engine/llm.js +16 -9
- package/engine/meeting.js +16 -5
- package/engine/queries.js +123 -52
- package/engine/recovery.js +6 -0
- package/engine/shared.js +281 -9
- package/engine/spawn-agent.js +13 -5
- package/engine/timeout.js +4 -2
- package/engine.js +242 -52
- package/package.json +1 -1
package/engine/recovery.js
CHANGED
|
@@ -28,6 +28,12 @@ const RECOVERY_RECIPES = new Map([
|
|
|
28
28
|
freshSession: false,
|
|
29
29
|
description: 'Permission/trust gate blocked — requires human intervention',
|
|
30
30
|
}],
|
|
31
|
+
[FAILURE_CLASS.AUTH, {
|
|
32
|
+
maxAttempts: 0,
|
|
33
|
+
escalation: ESCALATION_POLICY.NO_RETRY,
|
|
34
|
+
freshSession: false,
|
|
35
|
+
description: 'Git/network authentication failed (missing az login, expired token, GCM prompt) — requires human credential fix before retry',
|
|
36
|
+
}],
|
|
31
37
|
[FAILURE_CLASS.MERGE_CONFLICT, {
|
|
32
38
|
maxAttempts: 2,
|
|
33
39
|
escalation: ESCALATION_POLICY.RETRY_SAME,
|
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,
|
|
@@ -1232,8 +1455,15 @@ function shellSafeGit(args, opts = {}) {
|
|
|
1232
1455
|
if (!Array.isArray(args)) {
|
|
1233
1456
|
return Promise.reject(new TypeError('shellSafeGit: args must be an array'));
|
|
1234
1457
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1458
|
+
// W-mpcuc8i80003a7b3 — `gitExtraArgs` is prepended to the git argv so callers
|
|
1459
|
+
// can inject per-invocation `-c key=value` flags (e.g. ADO bearer-token
|
|
1460
|
+
// auth header) without rewriting every shellSafeGit call site. Strip the
|
|
1461
|
+
// key before delegating so it never reaches Node's execFile options.
|
|
1462
|
+
const { timeout, gitExtraArgs, ...rest } = opts;
|
|
1463
|
+
const finalArgs = (Array.isArray(gitExtraArgs) && gitExtraArgs.length > 0)
|
|
1464
|
+
? [...gitExtraArgs, ...args]
|
|
1465
|
+
: args;
|
|
1466
|
+
return _execFileAsync('git', finalArgs, {
|
|
1237
1467
|
windowsHide: true,
|
|
1238
1468
|
encoding: 'utf8',
|
|
1239
1469
|
shell: false,
|
|
@@ -1249,9 +1479,13 @@ function shellSafeGitSync(args, opts = {}) {
|
|
|
1249
1479
|
if (!Array.isArray(args)) {
|
|
1250
1480
|
throw new TypeError('shellSafeGitSync: args must be an array');
|
|
1251
1481
|
}
|
|
1252
|
-
|
|
1482
|
+
// W-mpcuc8i80003a7b3 — mirror the gitExtraArgs plumbing from the async variant.
|
|
1483
|
+
const { timeout, gitExtraArgs, ...rest } = opts;
|
|
1484
|
+
const finalArgs = (Array.isArray(gitExtraArgs) && gitExtraArgs.length > 0)
|
|
1485
|
+
? [...gitExtraArgs, ...args]
|
|
1486
|
+
: args;
|
|
1253
1487
|
const { execFileSync: _execFileSync } = require('child_process');
|
|
1254
|
-
return _execFileSync('git',
|
|
1488
|
+
return _execFileSync('git', finalArgs, {
|
|
1255
1489
|
windowsHide: true,
|
|
1256
1490
|
encoding: 'utf8',
|
|
1257
1491
|
shell: false,
|
|
@@ -1653,6 +1887,29 @@ const ENGINE_DEFAULTS = {
|
|
|
1653
1887
|
// knows which subkeys to flag as deprecated. Do not consume `claude.*` in new code — use the runtime
|
|
1654
1888
|
// adapter system (engine/runtimes/) and the resolveAgent*/resolveCc* helpers instead.
|
|
1655
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
|
+
},
|
|
1656
1913
|
};
|
|
1657
1914
|
|
|
1658
1915
|
// ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
|
|
@@ -2307,6 +2564,7 @@ const AGENT_STATUS = {
|
|
|
2307
2564
|
const FAILURE_CLASS = {
|
|
2308
2565
|
CONFIG_ERROR: 'config-error', // Exit code 78, CLI not found, bad config
|
|
2309
2566
|
PERMISSION_BLOCKED: 'permission-blocked', // Trust gate, permission denied, auth failure
|
|
2567
|
+
AUTH: 'auth', // W-mpcuc8i80003a7b3: git/network credential failure (e.g. ADO bearer-token acquire or GCM prompt) — structural, never retryable mechanically
|
|
2310
2568
|
MERGE_CONFLICT: 'merge-conflict', // Git merge conflict in worktree or dependency
|
|
2311
2569
|
BUILD_FAILURE: 'build-failure', // Compilation, lint, or test failure
|
|
2312
2570
|
TIMEOUT: 'timeout', // Hard runtime timeout or stale-orphan timeout
|
|
@@ -4379,6 +4637,8 @@ module.exports = {
|
|
|
4379
4637
|
PR_LINKS_PATH,
|
|
4380
4638
|
PINNED_ITEMS_PATH,
|
|
4381
4639
|
LOG_PATH,
|
|
4640
|
+
CONSTELLATION_BRIDGE_MARKER_PATH,
|
|
4641
|
+
CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
|
|
4382
4642
|
currentLogPath: _currentLogPath,
|
|
4383
4643
|
ts,
|
|
4384
4644
|
logTs,
|
|
@@ -4390,6 +4650,13 @@ module.exports = {
|
|
|
4390
4650
|
safeWrite,
|
|
4391
4651
|
mutateTextFileLocked,
|
|
4392
4652
|
safeUnlink,
|
|
4653
|
+
createDispatchTmpDir,
|
|
4654
|
+
removeDispatchTmpDir,
|
|
4655
|
+
validateDispatchTmpDir,
|
|
4656
|
+
readFileNoFollow,
|
|
4657
|
+
dispatchPidCandidates,
|
|
4658
|
+
findDispatchPidFile,
|
|
4659
|
+
forEachPidFile,
|
|
4393
4660
|
resolveMinionsHome,
|
|
4394
4661
|
saveMinionsRootPointer,
|
|
4395
4662
|
neutralizeJsonBackupSidecar,
|
|
@@ -4424,6 +4691,11 @@ module.exports = {
|
|
|
4424
4691
|
shellSafeGitSync,
|
|
4425
4692
|
validateGitRef,
|
|
4426
4693
|
validateGhSlug,
|
|
4694
|
+
validateGhEndpoint,
|
|
4695
|
+
validateGhNumericId,
|
|
4696
|
+
validatePrNum,
|
|
4697
|
+
validateReviewId,
|
|
4698
|
+
validateJobId,
|
|
4427
4699
|
isValidGitWorktree,
|
|
4428
4700
|
resolveMainBranch,
|
|
4429
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; }
|