@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.
@@ -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
- 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,
@@ -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
- const { timeout, ...rest } = opts;
1236
- return _execFileAsync('git', args, {
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
- const { timeout, ...rest } = opts;
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', args, {
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,
@@ -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; }