@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/engine/github.js CHANGED
@@ -35,6 +35,29 @@ function _setExecAsyncForTest(fn) {
35
35
  _execAsyncOverride = (typeof fn === 'function') ? fn : null;
36
36
  }
37
37
 
38
+ // P-f2-gh-shell (F2): parallel test seam for argv-form (shellSafeGh) shell-outs.
39
+ // The legacy `_runExec` seam intercepts shell-string `gh api ...` invocations,
40
+ // but the F2 conversion routes ghApi / build-log fetch / dismiss-review through
41
+ // shared.shellSafeGh (execFile, shell:false). New tests can mock at the argv
42
+ // level via `_setShellSafeGhForTest`; pre-existing tests that mock the shell-string
43
+ // layer keep working because `_runShellSafeGh` falls back to the legacy override
44
+ // by synthesizing a cmd string from argv (quoting any non-trivial argument so
45
+ // existing `/"repos\/owner\/repo"$/`-style route patterns still match).
46
+ let _shellSafeGhOverride = null;
47
+ function _runShellSafeGh(args, opts) {
48
+ if (_shellSafeGhOverride) return _shellSafeGhOverride(args, opts);
49
+ if (_execAsyncOverride) {
50
+ const cmd = ['gh', ...args]
51
+ .map(a => /^[A-Za-z0-9_=.-]+$/.test(String(a)) ? String(a) : `"${String(a)}"`)
52
+ .join(' ');
53
+ return _execAsyncOverride(cmd, opts);
54
+ }
55
+ return shared.shellSafeGh(args, opts);
56
+ }
57
+ function _setShellSafeGhForTest(fn) {
58
+ _shellSafeGhOverride = (typeof fn === 'function') ? fn : null;
59
+ }
60
+
38
61
  // ─── Constants ──────────────────────────────────────────────────────────────
39
62
 
40
63
  // 10 MB — prevents maxBuffer exceeded errors on repos with many open PRs.
@@ -270,11 +293,15 @@ const GH_NOT_FOUND = Object.freeze({ _notFound: true });
270
293
  */
271
294
  async function ghApi(endpoint, slug, opts = {}) {
272
295
  try {
273
- const paginateFlag = opts.paginate ? ' --paginate' : '';
274
- const cmd = `gh api${paginateFlag} "repos/${slug}${endpoint}"`;
296
+ // P-f2-gh-shell (F2): argv form via shellSafeGh eliminates shell
297
+ // interpolation of slug/endpoint. validateGhSlug + validateGhEndpoint
298
+ // reject shell metacharacters before they reach child_process.
299
+ const args = ['api'];
300
+ if (opts.paginate) args.push('--paginate');
301
+ args.push(`repos/${shared.validateGhSlug(slug)}${shared.validateGhEndpoint(endpoint)}`);
275
302
  const token = ghToken.resolveTokenForSlug(slug);
276
303
  const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
277
- const result = await _runExec(cmd, { timeout: opts.timeout || 30000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
304
+ const result = await _runShellSafeGh(args, { timeout: opts.timeout || 30000, maxBuffer: GH_MAX_BUFFER, env });
278
305
  const parsed = JSON.parse(result);
279
306
  _ghThrottle.recordSuccess();
280
307
  return parsed;
@@ -340,12 +367,18 @@ async function fetchGhBuildErrorLog(slug, failedRuns) {
340
367
  }
341
368
  } catch { /* fall through to job log */ }
342
369
 
343
- // Always fetch job log — annotations alone often lack test failure details
370
+ // Always fetch job log — annotations alone often lack test failure details.
371
+ // P-f2-gh-shell (F2): argv form via shellSafeGh. validateJobId rejects
372
+ // non-integer run.id values before they reach child_process. Stderr is
373
+ // captured via shellSafeGh's execFile path (stderr surfaces on the thrown
374
+ // Error in the catch block) instead of a `2>&1` shell redirect, which is
375
+ // moot once shell:false is in effect.
344
376
  try {
345
- const cmd = `gh api "repos/${slug}/actions/jobs/${run.id}/logs" 2>&1`;
377
+ const jobId = shared.validateJobId(run.id);
378
+ const args = ['api', `repos/${shared.validateGhSlug(slug)}/actions/jobs/${jobId}/logs`];
346
379
  const token = ghToken.resolveTokenForSlug(slug);
347
380
  const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
348
- const result = await _runExec(cmd, { timeout: 15000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
381
+ const result = await _runShellSafeGh(args, { timeout: 15000, maxBuffer: GH_MAX_BUFFER, env });
349
382
  if (result && !result.includes('Not Found')) {
350
383
  logParts.push(`--- ${run.name || 'Check'} (log) ---\n${result}`);
351
384
  }
@@ -1235,26 +1268,45 @@ async function dismissPriorViewerChangesRequestedReviews(pr, project) {
1235
1268
  const os = require('os');
1236
1269
  let dismissed = 0;
1237
1270
  let errors = 0;
1238
- for (const reviewId of targets) {
1239
- tmpFile = path.join(os.tmpdir(), `gh-review-dismiss-${process.pid}-${Date.now()}-${reviewId}.json`);
1240
- const body = JSON.stringify({
1241
- message: 'Superseded by Minions re-review (verdict flipped to APPROVE).',
1242
- event: 'DISMISS',
1243
- });
1244
- try {
1245
- fs.writeFileSync(tmpFile, body);
1246
- const cmd = `gh api -X PUT --input "${tmpFile}" "repos/${slug}/pulls/${prNum}/reviews/${reviewId}/dismissals"`;
1247
- const token = ghToken.resolveTokenForSlug(slug);
1248
- const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
1249
- await _runExec(cmd, { timeout: 10000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
1250
- dismissed += 1;
1251
- log('info', `PR ${pr.id}: dismissed prior CHANGES_REQUESTED review ${reviewId} by ${viewerLogin}`);
1252
- } catch (e) {
1253
- errors += 1;
1254
- log('warn', `PR ${pr.id}: dismiss review ${reviewId} failed: ${e?.message || e}`);
1255
- } finally {
1256
- try { fs.unlinkSync(tmpFile); } catch {}
1257
- tmpFile = null;
1271
+ // P-f6-tmp-toctou: per-call mkdtempSync dir (random 6-char suffix) closes
1272
+ // the predictable-prefix window on <os.tmpdir>/gh-review-dismiss-* that
1273
+ // an attacker could otherwise plant as a symlink.
1274
+ // P-f2-gh-shell: argv form via shellSafeGh validates slug, prNum, and
1275
+ // reviewId so a poisoned PR record can't smuggle shell metacharacters.
1276
+ let scopedDir = null;
1277
+ try {
1278
+ scopedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-review-dismiss-'));
1279
+ if (process.platform !== 'win32') {
1280
+ try { fs.chmodSync(scopedDir, 0o700); } catch { /* best-effort */ }
1281
+ }
1282
+ for (const reviewId of targets) {
1283
+ tmpFile = path.join(scopedDir, `dismiss-${reviewId}.json`);
1284
+ const body = JSON.stringify({
1285
+ message: 'Superseded by Minions re-review (verdict flipped to APPROVE).',
1286
+ event: 'DISMISS',
1287
+ });
1288
+ try {
1289
+ fs.writeFileSync(tmpFile, body);
1290
+ const args = [
1291
+ 'api', '-X', 'PUT', '--input', tmpFile,
1292
+ `repos/${shared.validateGhSlug(slug)}/pulls/${shared.validatePrNum(prNum)}/reviews/${shared.validateReviewId(reviewId)}/dismissals`,
1293
+ ];
1294
+ const token = ghToken.resolveTokenForSlug(slug);
1295
+ const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
1296
+ await _runShellSafeGh(args, { timeout: 10000, maxBuffer: GH_MAX_BUFFER, env });
1297
+ dismissed += 1;
1298
+ log('info', `PR ${pr.id}: dismissed prior CHANGES_REQUESTED review ${reviewId} by ${viewerLogin}`);
1299
+ } catch (e) {
1300
+ errors += 1;
1301
+ log('warn', `PR ${pr.id}: dismiss review ${reviewId} failed: ${e?.message || e}`);
1302
+ } finally {
1303
+ try { fs.unlinkSync(tmpFile); } catch {}
1304
+ tmpFile = null;
1305
+ }
1306
+ }
1307
+ } finally {
1308
+ if (scopedDir) {
1309
+ try { fs.rmSync(scopedDir, { recursive: true, force: true }); } catch {}
1258
1310
  }
1259
1311
  }
1260
1312
  return { attempted: true, dismissed, errors };
@@ -1515,5 +1567,6 @@ module.exports = {
1515
1567
  _setCachedViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1516
1568
  _backfillViewerDidAuthor, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1517
1569
  _setExecAsyncForTest, // W-mp5trwh60008386d: test seam to mock `gh api` shell-outs
1570
+ _setShellSafeGhForTest, // P-f2-gh-shell (F2): test seam to mock argv-form gh api calls
1518
1571
  GH_NOT_FOUND, // W-mp5trwh60008386d: exported so tests can assert sentinel propagation
1519
1572
  };
package/engine/issues.js CHANGED
@@ -327,9 +327,19 @@ function createGitHubIssue({
327
327
  const safeTitle = _redactIssueContent(title, { repo, projects: redactionProjects });
328
328
  const safeDescription = _redactIssueContent(description || '', { repo, projects: redactionProjects });
329
329
  const issueBody = `${safeDescription}\n\n---\n_Filed via Minions dashboard_`;
330
- const dir = tmpDir || path.join(__dirname, 'tmp');
331
- fs.mkdirSync(dir, { recursive: true });
332
- const bodyFile = path.join(dir, `bug-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
330
+ // P-f6-tmp-toctou: per-call mkdtempSync dir (under engine/tmp/ for
331
+ // collocation with other engine tmp state). Random suffix closes the
332
+ // predictable-prefix window on engine/tmp/bug-body-* that an attacker could
333
+ // otherwise plant as a symlink.
334
+ let scopedDir = null;
335
+ let bodyFile;
336
+ if (tmpDir) {
337
+ fs.mkdirSync(tmpDir, { recursive: true });
338
+ bodyFile = path.join(tmpDir, `bug-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
339
+ } else {
340
+ scopedDir = shared.createDispatchTmpDir(`issues-${process.pid}-${Date.now()}`);
341
+ bodyFile = path.join(scopedDir, 'bug-body.md');
342
+ }
333
343
  fs.writeFileSync(bodyFile, issueBody);
334
344
 
335
345
  let resolved;
@@ -377,6 +387,7 @@ function createGitHubIssue({
377
387
  throw new GitHubIssueError(`GitHub issue creation failed: ${conciseGhMessage(e)}`);
378
388
  } finally {
379
389
  try { fs.unlinkSync(bodyFile); } catch {}
390
+ if (scopedDir) shared.removeDispatchTmpDir(scopedDir);
380
391
  }
381
392
  }
382
393
 
@@ -12,6 +12,7 @@ const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
14
14
  const { resolveRuntime } = require('./runtimes');
15
+ const adoGitAuth = require('./ado-git-auth');
15
16
  const queries = require('./queries');
16
17
  const { isBranchActive } = require('./cooldown');
17
18
  const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
@@ -271,30 +272,66 @@ function checkPlanCompletion(meta, config) {
271
272
  continue;
272
273
  }
273
274
 
274
- // Build per-project setup block
275
+ // Build per-project setup block.
276
+ // P-f3-verify-prompt — validate every branch ref before splicing into
277
+ // the bash setup commands the verify agent will execute. Without this,
278
+ // a crafted `plan.feature_branch` (e.g. `main; rm -rf ~`) or PR
279
+ // `branch` field reaches the Bash tool via template literal
280
+ // interpolation. github.js validates pr.branch on API persistence
281
+ // (engine/github.js:612-622), but feature_branch has no upstream
282
+ // validator and mainBranch can fall through unvalidated from project
283
+ // config when `git rev-parse` can't verify it (shared.js:1328). Defense
284
+ // in depth lives here, at the actual interpolation site.
275
285
  let checkoutBlock;
276
286
  let wtPath;
277
287
  if (isSharedBranch) {
288
+ let safeFeatureBranch;
289
+ try {
290
+ safeFeatureBranch = shared.validateGitRef(plan.feature_branch);
291
+ } catch (refErr) {
292
+ log('error', `Plan ${planFile}: verify dispatch rejected for ${projName} — invalid feature_branch ref: ${refErr.message}. Skipping verify WI creation.`);
293
+ continue;
294
+ }
278
295
  wtPath = `${p.localPath}/../worktrees/verify-${projName}-${planSlug}`;
279
- const featureBranch = plan.feature_branch;
280
296
  checkoutBlock = [
281
297
  `# ${projName} — shared-branch: use existing feature branch directly`,
282
298
  `cd "${p.localPath.replace(/\\/g, '/')}"`,
283
- `git fetch origin "${featureBranch}"`,
284
- `git worktree add "${wtPath}" "origin/${featureBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${featureBranch}" && git pull origin "${featureBranch}")`,
299
+ `git fetch origin "${safeFeatureBranch}"`,
300
+ `git worktree add "${wtPath}" "origin/${safeFeatureBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${safeFeatureBranch}" && git pull origin "${safeFeatureBranch}")`,
285
301
  ].join('\n');
286
302
  } else {
303
+ let safeMainBranch;
304
+ try {
305
+ safeMainBranch = shared.validateGitRef(mainBranch);
306
+ } catch (refErr) {
307
+ log('error', `Plan ${planFile}: verify dispatch rejected for ${projName} — invalid mainBranch ref: ${refErr.message}. Skipping verify WI creation.`);
308
+ continue;
309
+ }
310
+ const safeBranches = [];
311
+ let badPrBranch = null;
312
+ for (const pr of prs) {
313
+ if (!pr.branch) continue;
314
+ try {
315
+ safeBranches.push({ id: pr.id || pr.branch, branch: shared.validateGitRef(pr.branch) });
316
+ } catch (refErr) {
317
+ badPrBranch = { id: pr.id || '(unknown)', raw: pr.branch, err: refErr };
318
+ break;
319
+ }
320
+ }
321
+ if (badPrBranch) {
322
+ log('error', `Plan ${planFile}: verify dispatch rejected for ${projName} — invalid pr.branch[${badPrBranch.id}] ref: ${badPrBranch.err.message}. Skipping verify WI creation.`);
323
+ continue;
324
+ }
287
325
  wtPath = `${p.localPath}/../worktrees/verify-${projName}-${planSlug}-${shared.uid()}`;
288
- const branches = prs.map(pr => pr.branch).filter(Boolean);
289
326
  checkoutBlock = [
290
- `# ${projName} — merge ${branches.length} PR branch(es) into one worktree`,
327
+ `# ${projName} — merge ${safeBranches.length} PR branch(es) into one worktree`,
291
328
  `cd "${p.localPath.replace(/\\/g, '/')}"`,
292
- branches.length > 0
293
- ? `git fetch origin ${branches.map(b => `"${b}"`).join(' ')} "${mainBranch}"`
294
- : `git fetch origin "${mainBranch}"`,
295
- `git worktree add "${wtPath}" "origin/${mainBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${mainBranch}" && git pull origin "${mainBranch}")`,
329
+ safeBranches.length > 0
330
+ ? `git fetch origin ${safeBranches.map(({ branch }) => `"${branch}"`).join(' ')} "${safeMainBranch}"`
331
+ : `git fetch origin "${safeMainBranch}"`,
332
+ `git worktree add "${wtPath}" "origin/${safeMainBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${safeMainBranch}" && git pull origin "${safeMainBranch}")`,
296
333
  `cd "${wtPath}"`,
297
- ...branches.map(b => `git merge "origin/${b}" --no-edit # ${prs.find(pr => pr.branch === b)?.id || b}`),
334
+ ...safeBranches.map(({ id, branch }) => `git merge "origin/${branch}" --no-edit # ${id}`),
298
335
  ].join('\n');
299
336
  }
300
337
 
@@ -2104,7 +2141,11 @@ async function rebaseBranchOntoMain(pr, project, config) {
2104
2141
  }
2105
2142
  const wtRoot = path.resolve(root, config.engine?.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot);
2106
2143
  const tmpWt = path.join(wtRoot, `rebase-${shared.sanitizeBranch(branch)}-${Date.now()}`).replace(/\\/g, '/');
2107
- const _gitOpts = { cwd: root, timeout: 30000, windowsHide: true };
2144
+ // W-mpcuc8i80003a7b3 thread ADO bearer-token args through every remote-touching
2145
+ // git op in the rebase flow so headless post-merge rebase against ADO origin
2146
+ // survives missing GCM creds. For GitHub/local projects this is a no-op.
2147
+ const _gitExtraArgs = adoGitAuth.getAdoGitExtraArgs(project);
2148
+ const _gitOpts = { cwd: root, timeout: 30000, windowsHide: true, gitExtraArgs: _gitExtraArgs };
2108
2149
 
2109
2150
  // Refuse to create a worktree nested in the project root — would cause
2110
2151
  // glob/grep tools running with cwd=root to match both copies of every file
@@ -2135,8 +2176,8 @@ async function rebaseBranchOntoMain(pr, project, config) {
2135
2176
  }
2136
2177
 
2137
2178
  try {
2138
- await shared.shellSafeGit(['rebase', `origin/${validatedMain}`], { cwd: tmpWt, timeout: 120000, windowsHide: true });
2139
- await shared.shellSafeGit(['push', '--force-with-lease', 'origin', validatedBranch], { cwd: tmpWt, timeout: 30000, windowsHide: true });
2179
+ await shared.shellSafeGit(['rebase', `origin/${validatedMain}`], { cwd: tmpWt, timeout: 120000, windowsHide: true, gitExtraArgs: _gitExtraArgs });
2180
+ await shared.shellSafeGit(['push', '--force-with-lease', 'origin', validatedBranch], { cwd: tmpWt, timeout: 30000, windowsHide: true, gitExtraArgs: _gitExtraArgs });
2140
2181
  log('info', `Post-merge rebase: rebased ${branch} onto ${mainBranch} and force-pushed`);
2141
2182
  return { success: true };
2142
2183
  } catch (err) {
package/engine/llm.js CHANGED
@@ -319,10 +319,13 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
319
319
  } = callOpts;
320
320
 
321
321
  const id = uid();
322
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
323
- if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
322
+ // P-f6-tmp-toctou: each direct/indirect LLM call gets its own unguessable
323
+ // tmp dir (mkdtempSync + chmod 0o700 on POSIX). The whole dir is rm'd by
324
+ // cleanupFiles consumers via shared.removeDispatchTmpDir.
325
+ const llmTmpDir = shared.createDispatchTmpDir(`llm-${label || 'call'}-${id}`);
324
326
 
325
327
  const cleanupFiles = [];
328
+ const cleanupDirs = [llmTmpDir];
326
329
  const caps = (runtime && runtime.capabilities) || {};
327
330
  const adapterOpts = {
328
331
  model, maxTurns, allowedTools, effort, sessionId,
@@ -346,7 +349,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
346
349
  // via --system-prompt-file (Claude) AND we're not resuming (resumed sessions
347
350
  // already have the sys prompt baked in).
348
351
  if (!sessionId && sysPromptText && caps.systemPromptFile) {
349
- sysTmpPath = path.join(tmpDir, `direct-sys-${id}.md`);
352
+ sysTmpPath = path.join(llmTmpDir, `direct-sys-${id}.md`);
350
353
  fs.writeFileSync(sysTmpPath, sysPromptText);
351
354
  cleanupFiles.push(sysTmpPath);
352
355
  adapterOpts.sysPromptFile = sysTmpPath;
@@ -369,12 +372,12 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
369
372
  } else {
370
373
  try { proc.stdin.write(finalPrompt); proc.stdin.end(); } catch { /* broken pipe */ }
371
374
  }
372
- return { proc, cleanupFiles };
375
+ return { proc, cleanupFiles, cleanupDirs };
373
376
  }
374
377
 
375
378
  // Indirect: use spawn-agent.js (when direct=false or binary cache miss)
376
- const promptPath = path.join(tmpDir, `${label}-prompt-${id}.md`);
377
- const sysPath = path.join(tmpDir, `${label}-sys-${id}.md`);
379
+ const promptPath = path.join(llmTmpDir, `${label}-prompt-${id}.md`);
380
+ const sysPath = path.join(llmTmpDir, `${label}-sys-${id}.md`);
378
381
  // The wrapper merges sys prompt into the user prompt for runtimes without
379
382
  // --system-prompt-file (Copilot) — write the user prompt as `finalPrompt`
380
383
  // (system block already prepended by buildPrompt) for those, and just the
@@ -402,7 +405,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
402
405
  const proc = runFile(process.execPath, args, {
403
406
  cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv(),
404
407
  });
405
- return { proc, cleanupFiles };
408
+ return { proc, cleanupFiles, cleanupDirs };
406
409
  }
407
410
 
408
411
  // ─── Streaming Accumulator ───────────────────────────────────────────────────
@@ -621,7 +624,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
621
624
  let _abort = null;
622
625
  const promise = new Promise((resolve) => {
623
626
  const _startMs = Date.now();
624
- const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
627
+ const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
625
628
  direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
626
629
  maxBudget, bare, fallbackModel,
627
630
  ...runtimeFeatureOpts,
@@ -657,6 +660,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
657
660
  clearTimeout(timer);
658
661
  if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
659
662
  for (const f of cleanupFiles) safeUnlink(f);
663
+ for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
660
664
  const parsed = acc.finalize();
661
665
  const durationMs = Date.now() - _startMs;
662
666
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
@@ -694,6 +698,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
694
698
  clearTimeout(timer);
695
699
  if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
696
700
  for (const f of cleanupFiles) safeUnlink(f);
701
+ for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
697
702
  shared.log('error', `LLM spawn error (${label}): ${err.message}`);
698
703
  resolve({
699
704
  text: '', usage: null, sessionId: null, code: 1,
@@ -733,7 +738,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
733
738
  let _abort = null;
734
739
  const promise = new Promise((resolve) => {
735
740
  const _startMs = Date.now();
736
- const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
741
+ const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
737
742
  direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
738
743
  maxBudget, bare, fallbackModel,
739
744
  ...runtimeFeatureOpts,
@@ -772,6 +777,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
772
777
  clearTimeout(timer);
773
778
  if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
774
779
  for (const f of cleanupFiles) safeUnlink(f);
780
+ for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
775
781
  const parsed = acc.finalize();
776
782
  const durationMs = Date.now() - _startMs;
777
783
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
@@ -806,6 +812,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
806
812
  clearTimeout(timer);
807
813
  if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
808
814
  for (const f of cleanupFiles) safeUnlink(f);
815
+ for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
809
816
  shared.log('error', `LLM-stream spawn error (${label}): ${err.message}`);
810
817
  resolve({
811
818
  text: '', usage: null, sessionId: null, code: 1,
package/engine/meeting.js CHANGED
@@ -694,6 +694,7 @@ function _killMeetingDispatches(meetingId) {
694
694
  const tmpDir = path.join(shared.MINIONS_DIR, 'engine', 'tmp');
695
695
  const entriesToStop = [];
696
696
  const filesToDelete = [];
697
+ const dispatchDirsToRemove = [];
697
698
  shared.mutateJsonFileLocked(DISPATCH_PATH, (dp) => {
698
699
  dp.pending = Array.isArray(dp.pending) ? dp.pending : [];
699
700
  dp.active = Array.isArray(dp.active) ? dp.active : [];
@@ -707,10 +708,17 @@ function _killMeetingDispatches(meetingId) {
707
708
  continue;
708
709
  }
709
710
  entriesToStop.push(d);
710
- filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
711
- filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
712
- filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
713
- filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
711
+ // P-f6-tmp-toctou: prefer per-dispatch dir cleanup. Fall back to
712
+ // legacy individual-file cleanup for entries that pre-date the
713
+ // per-dispatch-dir layout.
714
+ if (d.tmpDir && shared.validateDispatchTmpDir(d.tmpDir)) {
715
+ dispatchDirsToRemove.push(d.tmpDir);
716
+ } else {
717
+ filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
718
+ filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
719
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
720
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
721
+ }
714
722
  }
715
723
  dp[queue] = kept;
716
724
  }
@@ -725,7 +733,7 @@ function _killMeetingDispatches(meetingId) {
725
733
  const pidsToKill = [];
726
734
  for (const d of entriesToStop) {
727
735
  try {
728
- const pidFile = path.join(tmpDir, `pid-${d.id}.pid`);
736
+ const pidFile = shared.findDispatchPidFile(d) || path.join(tmpDir, `pid-${d.id}.pid`);
729
737
  const pid = shared.validatePid(fs.readFileSync(pidFile, 'utf8').trim());
730
738
  pidsToKill.push(pid);
731
739
  } catch { /* pending entries and already-finished agents may not have PID files */ }
@@ -736,6 +744,9 @@ function _killMeetingDispatches(meetingId) {
736
744
  for (const fp of filesToDelete) {
737
745
  try { fs.unlinkSync(fp); } catch { /* sidecar may not exist */ }
738
746
  }
747
+ for (const dir of dispatchDirsToRemove) {
748
+ shared.removeDispatchTmpDir(dir);
749
+ }
739
750
 
740
751
  if (entriesToStop.length > 0) log('info', `Killed ${entriesToStop.length} meeting dispatch(es) for ${meetingId}`);
741
752
  return entriesToStop.length;
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,