@yemi33/minions 0.1.1985 → 0.1.1986
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/minions.js +3 -1
- package/dashboard/js/qa.js +53 -0
- package/dashboard/js/refresh.js +4 -2
- package/dashboard/js/render-managed.js +43 -9
- package/dashboard/js/render-other.js +41 -11
- package/dashboard/layout.html +1 -0
- package/dashboard/pages/qa.html +23 -0
- package/dashboard-build.js +2 -2
- package/dashboard.js +135 -24
- package/docs/README.md +2 -0
- package/docs/constellation-bridge.md +94 -0
- package/docs/security.md +177 -0
- package/engine/bridge.js +124 -0
- package/engine/cc-worker-pool.js +48 -1
- package/engine/cleanup.js +72 -23
- package/engine/cli.js +126 -12
- package/engine/dispatch.js +24 -11
- package/engine/github.js +79 -26
- package/engine/issues.js +14 -3
- package/engine/lifecycle.js +47 -11
- package/engine/llm.js +16 -9
- package/engine/meeting.js +16 -5
- package/engine/queries.js +123 -52
- package/engine/shared.js +265 -5
- package/engine/spawn-agent.js +13 -5
- package/engine/timeout.js +4 -2
- package/engine.js +59 -15
- package/package.json +1 -1
package/engine/cli.js
CHANGED
|
@@ -57,7 +57,9 @@ function dispatchSafeId(itemId) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function readDispatchPid(itemId) {
|
|
60
|
-
|
|
60
|
+
// P-f6-tmp-toctou: prefer per-dispatch dir layout, fall back to legacy flat.
|
|
61
|
+
const pidFile = shared.findDispatchPidFile(itemId);
|
|
62
|
+
if (!pidFile) return null;
|
|
61
63
|
let raw;
|
|
62
64
|
try { raw = fs.readFileSync(pidFile, 'utf8').trim(); }
|
|
63
65
|
catch { return null; }
|
|
@@ -188,6 +190,7 @@ const CLI_COMMAND_DOCS = Object.freeze({
|
|
|
188
190
|
doctor: { args: '', summary: 'Check prerequisites and runtime health' },
|
|
189
191
|
config: { args: 'set-cli <R> [--model M]', summary: 'Persist defaultCli/defaultModel without starting' },
|
|
190
192
|
pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh' },
|
|
193
|
+
bridge: { args: 'status|health|enable|disable', summary: 'Constellation bridge: toggle and inspect the read-only cross-repo feed' },
|
|
191
194
|
});
|
|
192
195
|
|
|
193
196
|
function formatCliCommandHelpLines() {
|
|
@@ -1567,13 +1570,10 @@ const commands = {
|
|
|
1567
1570
|
const shared = require('./shared');
|
|
1568
1571
|
|
|
1569
1572
|
// Kill processes via PID files (expensive — outside dispatch lock).
|
|
1570
|
-
// PID files live in engine/tmp/
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
|
|
1574
|
-
const pidFiles = shared.safeReadDir(pidDir).filter(f => f.startsWith('pid-') && f.endsWith('.pid'));
|
|
1575
|
-
for (const f of pidFiles) {
|
|
1576
|
-
const pidPath = path.join(pidDir, f);
|
|
1573
|
+
// PID files live in engine/tmp/ — both legacy flat layout
|
|
1574
|
+
// (`pid-<id>.pid`) and per-dispatch dirs (`dispatch-<id>-XXX/pid-<id>.pid`)
|
|
1575
|
+
// post-P-f6-tmp-toctou. shared.forEachPidFile visits both.
|
|
1576
|
+
shared.forEachPidFile((pidPath, fileName, layout) => {
|
|
1577
1577
|
const raw = safeRead(pidPath).trim();
|
|
1578
1578
|
// Guard against falsy/zero/NaN PIDs. Empty pid files would resolve to
|
|
1579
1579
|
// Number('') === 0, and process.kill(0) on POSIX targets the entire
|
|
@@ -1581,13 +1581,17 @@ const commands = {
|
|
|
1581
1581
|
let pidNum = NaN;
|
|
1582
1582
|
try { pidNum = shared.validatePid(raw); } catch { /* invalid — skip */ }
|
|
1583
1583
|
if (pidNum > 0) {
|
|
1584
|
-
try { process.kill(pidNum); console.log(`Killed process ${pidNum} (${
|
|
1584
|
+
try { process.kill(pidNum); console.log(`Killed process ${pidNum} (${fileName})`); }
|
|
1585
1585
|
catch { console.log(`Process ${pidNum} already dead`); }
|
|
1586
1586
|
} else {
|
|
1587
|
-
console.log(`Skipping ${
|
|
1587
|
+
console.log(`Skipping ${fileName}: invalid or empty PID`);
|
|
1588
1588
|
}
|
|
1589
|
-
|
|
1590
|
-
|
|
1589
|
+
if (layout === 'dispatch-dir') {
|
|
1590
|
+
try { shared.removeDispatchTmpDir(path.dirname(pidPath)); } catch { /* may not exist */ }
|
|
1591
|
+
} else {
|
|
1592
|
+
try { fs.unlinkSync(pidPath); } catch { /* may not exist */ }
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1591
1595
|
|
|
1592
1596
|
// Atomically read and clear dispatch.active (locked read-modify-write)
|
|
1593
1597
|
let killed = [];
|
|
@@ -1815,6 +1819,116 @@ const commands = {
|
|
|
1815
1819
|
console.error(`error: ${e.message}`);
|
|
1816
1820
|
process.exit(1);
|
|
1817
1821
|
}
|
|
1822
|
+
},
|
|
1823
|
+
|
|
1824
|
+
// `minions bridge <subcommand>` — Constellation read-only bridge surface.
|
|
1825
|
+
// Owns the on/off flag and the marker-file projection used by the
|
|
1826
|
+
// Constellation agent. Bridge polling logic itself lives in the
|
|
1827
|
+
// Constellation repo (P-wi1-bridge-readonly).
|
|
1828
|
+
//
|
|
1829
|
+
// Subcommands:
|
|
1830
|
+
// status Print enabled flag + last-seen Constellation agent timestamp.
|
|
1831
|
+
// health Probe http://127.0.0.1:7331/api/status and print the same
|
|
1832
|
+
// curated projection the Constellation bridge would consume.
|
|
1833
|
+
// enable Atomically set engine.constellationBridge.enabled = true.
|
|
1834
|
+
// disable Atomically set engine.constellationBridge.enabled = false.
|
|
1835
|
+
bridge(subcmd, ...rest) {
|
|
1836
|
+
const bridge = require('./bridge');
|
|
1837
|
+
const BRIDGE_USAGE = 'Usage: minions bridge <status|health|enable|disable>';
|
|
1838
|
+
|
|
1839
|
+
if (!subcmd || subcmd === 'help' || subcmd === '--help' || subcmd === '-h') {
|
|
1840
|
+
console.log(BRIDGE_USAGE);
|
|
1841
|
+
console.log('');
|
|
1842
|
+
console.log(' status Show enabled flag + last-seen Constellation agent timestamp');
|
|
1843
|
+
console.log(' health Probe http://127.0.0.1:7331/api/status and print bridge projection');
|
|
1844
|
+
console.log(' enable Set engine.constellationBridge.enabled = true');
|
|
1845
|
+
console.log(' disable Set engine.constellationBridge.enabled = false');
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
if (rest.length > 0) {
|
|
1850
|
+
console.error(`error: unexpected arguments after bridge ${subcmd}: ${rest.join(' ')}`);
|
|
1851
|
+
process.exit(2);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
if (subcmd === 'enable' || subcmd === 'disable') {
|
|
1855
|
+
const enabled = subcmd === 'enable';
|
|
1856
|
+
const { previous, current } = bridge.setBridgeEnabled(enabled);
|
|
1857
|
+
const verb = previous === current ? 'already' : 'now';
|
|
1858
|
+
console.log(`bridge: ${verb} ${current ? 'enabled' : 'disabled'}`);
|
|
1859
|
+
console.log(' config: engine.constellationBridge.enabled = ' + current);
|
|
1860
|
+
if (!previous && current) {
|
|
1861
|
+
console.log(' note: Constellation-side bridge must also be enabled to project state.');
|
|
1862
|
+
}
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
if (subcmd === 'status') {
|
|
1867
|
+
const config = getConfig();
|
|
1868
|
+
const enabled = bridge.isBridgeEnabled(config);
|
|
1869
|
+
console.log(`bridge: ${enabled ? 'enabled' : 'disabled'}`);
|
|
1870
|
+
console.log(' config: engine.constellationBridge.enabled = ' + enabled);
|
|
1871
|
+
const marker = bridge.readBridgeMarker();
|
|
1872
|
+
if (!marker) {
|
|
1873
|
+
console.log(' marker: no Constellation agent has registered yet');
|
|
1874
|
+
console.log(` (expected at ${bridge.CONSTELLATION_BRIDGE_MARKER_PATH})`);
|
|
1875
|
+
} else {
|
|
1876
|
+
console.log(` last seen: ${bridge.formatRelativeAge(marker.lastSeenAt)} (${marker.lastSeenAt})`);
|
|
1877
|
+
if (marker.agentVersion) console.log(` agent version: ${marker.agentVersion}`);
|
|
1878
|
+
if (marker.source) console.log(` source: ${marker.source}`);
|
|
1879
|
+
}
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
if (subcmd === 'health') {
|
|
1884
|
+
const http = require('http');
|
|
1885
|
+
const req = http.get(
|
|
1886
|
+
{ hostname: '127.0.0.1', port: 7331, path: '/api/status', timeout: 5000 },
|
|
1887
|
+
(res) => {
|
|
1888
|
+
let body = '';
|
|
1889
|
+
res.setEncoding('utf8');
|
|
1890
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
1891
|
+
res.on('end', () => {
|
|
1892
|
+
if (res.statusCode !== 200) {
|
|
1893
|
+
console.error(`error: dashboard returned HTTP ${res.statusCode}`);
|
|
1894
|
+
process.exit(1);
|
|
1895
|
+
}
|
|
1896
|
+
let parsed;
|
|
1897
|
+
try { parsed = JSON.parse(body); }
|
|
1898
|
+
catch (e) {
|
|
1899
|
+
console.error(`error: dashboard response was not JSON: ${e.message}`);
|
|
1900
|
+
process.exit(1);
|
|
1901
|
+
}
|
|
1902
|
+
const projection = bridge.projectStatusForBridge(parsed);
|
|
1903
|
+
if (!projection) {
|
|
1904
|
+
console.error('error: dashboard /api/status returned an unexpected shape');
|
|
1905
|
+
process.exit(1);
|
|
1906
|
+
}
|
|
1907
|
+
console.log('bridge: dashboard reachable on http://127.0.0.1:7331');
|
|
1908
|
+
console.log(' projection (same fields the Constellation bridge would read):');
|
|
1909
|
+
for (const [k, v] of Object.entries(projection)) {
|
|
1910
|
+
console.log(` ${k}: ${v === null ? '(unknown)' : v}`);
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
);
|
|
1915
|
+
req.on('timeout', () => {
|
|
1916
|
+
req.destroy(new Error('connect timeout'));
|
|
1917
|
+
});
|
|
1918
|
+
req.on('error', (err) => {
|
|
1919
|
+
if (err.code === 'ECONNREFUSED') {
|
|
1920
|
+
console.error('error: dashboard not running on :7331 — start it with `minions dash`');
|
|
1921
|
+
} else {
|
|
1922
|
+
console.error(`error: ${err.message}`);
|
|
1923
|
+
}
|
|
1924
|
+
process.exit(1);
|
|
1925
|
+
});
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
console.error(`Unknown bridge subcommand: ${subcmd}`);
|
|
1930
|
+
console.error(BRIDGE_USAGE);
|
|
1931
|
+
process.exit(2);
|
|
1818
1932
|
}
|
|
1819
1933
|
};
|
|
1820
1934
|
|
package/engine/dispatch.js
CHANGED
|
@@ -823,6 +823,7 @@ function cleanDispatchEntries(matchFn) {
|
|
|
823
823
|
let removed = 0;
|
|
824
824
|
const pidsToKill = [];
|
|
825
825
|
const filesToDelete = [];
|
|
826
|
+
const dispatchDirsToRemove = [];
|
|
826
827
|
try {
|
|
827
828
|
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
828
829
|
for (const queue of ['pending', 'active', 'completed']) {
|
|
@@ -831,17 +832,26 @@ function cleanDispatchEntries(matchFn) {
|
|
|
831
832
|
if (queue === 'active') {
|
|
832
833
|
for (const d of dispatch[queue]) {
|
|
833
834
|
if (!matchFn(d)) continue;
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
835
|
+
// P-f6-tmp-toctou: prefer the dispatch's recorded tmpDir. Fall
|
|
836
|
+
// back to legacy flat layout for active dispatches that pre-date
|
|
837
|
+
// the per-dispatch-dir layout. shared.findDispatchPidFile honors
|
|
838
|
+
// the same resolution order so we agree on which PID to kill.
|
|
839
|
+
const pidPath = shared.findDispatchPidFile(d);
|
|
840
|
+
if (pidPath) {
|
|
841
|
+
try {
|
|
842
|
+
const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim());
|
|
843
|
+
if (pid) pidsToKill.push(pid);
|
|
844
|
+
} catch { /* PID file may not exist */ }
|
|
845
|
+
}
|
|
846
|
+
if (d.tmpDir && shared.validateDispatchTmpDir(d.tmpDir)) {
|
|
847
|
+
dispatchDirsToRemove.push(d.tmpDir);
|
|
848
|
+
} else {
|
|
849
|
+
// Legacy individual-file cleanup for pre-migration entries.
|
|
850
|
+
filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
|
|
851
|
+
filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
|
|
852
|
+
filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
|
|
853
|
+
filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
|
|
854
|
+
}
|
|
845
855
|
}
|
|
846
856
|
}
|
|
847
857
|
dispatch[queue] = dispatch[queue].filter(d => !matchFn(d));
|
|
@@ -865,6 +875,9 @@ function cleanDispatchEntries(matchFn) {
|
|
|
865
875
|
for (const fp of filesToDelete) {
|
|
866
876
|
try { fs.unlinkSync(fp); } catch { /* may not exist */ }
|
|
867
877
|
}
|
|
878
|
+
for (const dir of dispatchDirsToRemove) {
|
|
879
|
+
shared.removeDispatchTmpDir(dir);
|
|
880
|
+
}
|
|
868
881
|
return removed;
|
|
869
882
|
}
|
|
870
883
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
package/engine/lifecycle.js
CHANGED
|
@@ -272,30 +272,66 @@ function checkPlanCompletion(meta, config) {
|
|
|
272
272
|
continue;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
// 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.
|
|
276
285
|
let checkoutBlock;
|
|
277
286
|
let wtPath;
|
|
278
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
|
+
}
|
|
279
295
|
wtPath = `${p.localPath}/../worktrees/verify-${projName}-${planSlug}`;
|
|
280
|
-
const featureBranch = plan.feature_branch;
|
|
281
296
|
checkoutBlock = [
|
|
282
297
|
`# ${projName} — shared-branch: use existing feature branch directly`,
|
|
283
298
|
`cd "${p.localPath.replace(/\\/g, '/')}"`,
|
|
284
|
-
`git fetch origin "${
|
|
285
|
-
`git worktree add "${wtPath}" "origin/${
|
|
299
|
+
`git fetch origin "${safeFeatureBranch}"`,
|
|
300
|
+
`git worktree add "${wtPath}" "origin/${safeFeatureBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${safeFeatureBranch}" && git pull origin "${safeFeatureBranch}")`,
|
|
286
301
|
].join('\n');
|
|
287
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
|
+
}
|
|
288
325
|
wtPath = `${p.localPath}/../worktrees/verify-${projName}-${planSlug}-${shared.uid()}`;
|
|
289
|
-
const branches = prs.map(pr => pr.branch).filter(Boolean);
|
|
290
326
|
checkoutBlock = [
|
|
291
|
-
`# ${projName} — merge ${
|
|
327
|
+
`# ${projName} — merge ${safeBranches.length} PR branch(es) into one worktree`,
|
|
292
328
|
`cd "${p.localPath.replace(/\\/g, '/')}"`,
|
|
293
|
-
|
|
294
|
-
? `git fetch origin ${
|
|
295
|
-
: `git fetch origin "${
|
|
296
|
-
`git worktree add "${wtPath}" "origin/${
|
|
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}")`,
|
|
297
333
|
`cd "${wtPath}"`,
|
|
298
|
-
...
|
|
334
|
+
...safeBranches.map(({ id, branch }) => `git merge "origin/${branch}" --no-edit # ${id}`),
|
|
299
335
|
].join('\n');
|
|
300
336
|
}
|
|
301
337
|
|
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
|
-
|
|
323
|
-
|
|
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(
|
|
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(
|
|
377
|
-
const sysPath = path.join(
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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;
|