@yemi33/minions 0.1.1120 → 0.1.1122
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/CHANGELOG.md +46 -0
- package/dashboard.js +64 -4
- package/engine/scheduler.js +33 -7
- package/engine/shared.js +78 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1122 (2026-04-18)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- validate project name and path on POST /api/projects/add (SEC-04, SEC-05) (#1298)
|
|
7
|
+
- seed realActivityMap at spawn time, stamp pid in live-output (#1200)
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
- scheduler double-fire within same cron minute (#1277)
|
|
11
|
+
- gate auto-fix dispatch on throttle state to prevent stale-data spurious fixes
|
|
12
|
+
- preserve buildErrorLog through transient build states (#1232) (#1274)
|
|
13
|
+
- annotate fast-exit empty-output failures with diagnostic hint (#1276)
|
|
14
|
+
- invalidate PRD cache on pr-links.json change + guard aggregate PRs (#1220) (#1272)
|
|
15
|
+
- pass --add-dir for minions + ~/.claude to agents (#1271)
|
|
16
|
+
- preserve VERDICT marker by tail-slicing agent output (#1234) (#1270)
|
|
17
|
+
- PRD info cache staleness and aggregate PR bleed-through (#1222)
|
|
18
|
+
- avoid no-op work item writes
|
|
19
|
+
- resilient claude binary resolution + surface spawn errors
|
|
20
|
+
- resolve native claude.exe from npm wrapper on Windows
|
|
21
|
+
- cap temp-agent creation at maxConcurrent per tick (#1219)
|
|
22
|
+
- reassign pending items from unspawned temp agents to idle named agents (#1204) (#1212)
|
|
23
|
+
- guard undefined agent in pending dispatch loop (closes #1206) (#1210)
|
|
24
|
+
- improve fallback meeting conclusion
|
|
25
|
+
- remove command center chevron
|
|
26
|
+
- stamp live-output.log stub before spawn (#1198)
|
|
27
|
+
- harden settings save and migrate pr poll config
|
|
28
|
+
|
|
29
|
+
### Other
|
|
30
|
+
- docs(skill): add substitute-scheduler-template-vars (#1278)
|
|
31
|
+
- Clarify PR poll labels
|
|
32
|
+
- Prevent modal opens on text selection
|
|
33
|
+
- refactor: clarify settings page section structure and PR polling dependencies
|
|
34
|
+
- refactor: extract _probeClaudePackage helper, use shared.log for spawn errors
|
|
35
|
+
- Make work item descriptions scrollable
|
|
36
|
+
- Use PAT for publish merges
|
|
37
|
+
- test(queries): add unit tests for invalidateDispatchCache/getInbox/getAgentCharter (#1214)
|
|
38
|
+
- test(shared): add unit tests for truncateTextBytes/tailTextBytes/execSilent/trackReviewMetric/parseCanonicalPrId (#1215)
|
|
39
|
+
- Fix publish workflow merge
|
|
40
|
+
- chore: raise default meeting round timeout
|
|
41
|
+
- Harden prompt context handling
|
|
42
|
+
- Harden loop watch conversion
|
|
43
|
+
- Add watches sidebar activity badge
|
|
44
|
+
- test(cli): add unit tests for handleCommand, start, stop, kill, spawn (#1191)
|
|
45
|
+
- chore: untrack pipeline files — local config only
|
|
46
|
+
- restore: recover daily-arch-improvement and weekly-dead-code-cleanup pipelines
|
|
47
|
+
- Harden CC stream resilience
|
|
48
|
+
|
|
3
49
|
## 0.1.1120 (2026-04-18)
|
|
4
50
|
|
|
5
51
|
### Features
|
package/dashboard.js
CHANGED
|
@@ -3658,12 +3658,58 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3658
3658
|
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
3659
3659
|
}
|
|
3660
3660
|
|
|
3661
|
+
// ── Non-repo confirmation tokens (SEC-05) ───────────────────────────────
|
|
3662
|
+
// Single-use, short-TTL tokens that the client must obtain from
|
|
3663
|
+
// POST /api/projects/confirm-token before a non-repo path can be added.
|
|
3664
|
+
// This forces an explicit round-trip — a single forged POST to /add
|
|
3665
|
+
// can no longer silently register a non-repo path as a project.
|
|
3666
|
+
const _projectConfirmTokens = new Map(); // token → expiresAt (ms epoch)
|
|
3667
|
+
const PROJECT_CONFIRM_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
3668
|
+
|
|
3669
|
+
function _sweepProjectConfirmTokens() {
|
|
3670
|
+
const now = Date.now();
|
|
3671
|
+
for (const [t, exp] of _projectConfirmTokens) {
|
|
3672
|
+
if (exp <= now) _projectConfirmTokens.delete(t);
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
function _consumeProjectConfirmToken(token) {
|
|
3677
|
+
if (typeof token !== 'string' || !token) return false;
|
|
3678
|
+
_sweepProjectConfirmTokens();
|
|
3679
|
+
const exp = _projectConfirmTokens.get(token);
|
|
3680
|
+
if (!exp) return false;
|
|
3681
|
+
_projectConfirmTokens.delete(token); // single-use
|
|
3682
|
+
return exp > Date.now();
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
async function handleProjectsConfirmToken(req, res) {
|
|
3686
|
+
_sweepProjectConfirmTokens();
|
|
3687
|
+
const token = require('crypto').randomUUID();
|
|
3688
|
+
_projectConfirmTokens.set(token, Date.now() + PROJECT_CONFIRM_TOKEN_TTL_MS);
|
|
3689
|
+
return jsonReply(res, 200, { confirmToken: token, ttlMs: PROJECT_CONFIRM_TOKEN_TTL_MS });
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3661
3692
|
async function handleProjectsAdd(req, res) {
|
|
3662
3693
|
try {
|
|
3663
3694
|
const body = await readBody(req);
|
|
3664
3695
|
if (!body.path) return jsonReply(res, 400, { error: 'path required' });
|
|
3665
|
-
|
|
3666
|
-
|
|
3696
|
+
|
|
3697
|
+
// SEC-05: validate path (must be a git repo, unless caller supplies
|
|
3698
|
+
// allowNonRepo + a valid single-use confirmation token). Runs BEFORE any
|
|
3699
|
+
// mutation of config.json so a rejected path leaves no side effects.
|
|
3700
|
+
let target;
|
|
3701
|
+
try {
|
|
3702
|
+
target = shared.validateProjectPath(body.path, {
|
|
3703
|
+
allowNonRepo: body.allowNonRepo === true,
|
|
3704
|
+
confirmToken: body.confirmToken,
|
|
3705
|
+
isValidToken: _consumeProjectConfirmToken,
|
|
3706
|
+
});
|
|
3707
|
+
} catch (e) {
|
|
3708
|
+
return jsonReply(res, e.statusCode || 400, {
|
|
3709
|
+
error: e.message,
|
|
3710
|
+
...(e.needsConfirmation ? { needsConfirmation: true } : {}),
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
3667
3713
|
|
|
3668
3714
|
const configPath = path.join(MINIONS_DIR, 'config.json');
|
|
3669
3715
|
const config = safeJsonObj(configPath);
|
|
@@ -3711,7 +3757,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3711
3757
|
}
|
|
3712
3758
|
} catch { /* optional */ }
|
|
3713
3759
|
|
|
3714
|
-
const
|
|
3760
|
+
const rawName = body.name || detected.name;
|
|
3761
|
+
|
|
3762
|
+
// SEC-04: validate project name — rejects path traversal, shell
|
|
3763
|
+
// metacharacters, whitespace, overly long names. Runs BEFORE any
|
|
3764
|
+
// mutation of config.json. Auto-detected names (from package.json /
|
|
3765
|
+
// directory basename) also go through this check so a maliciously
|
|
3766
|
+
// named repo on disk can't inject metacharacters either.
|
|
3767
|
+
let name;
|
|
3768
|
+
try {
|
|
3769
|
+
name = shared.validateProjectName(rawName);
|
|
3770
|
+
} catch (e) {
|
|
3771
|
+
return jsonReply(res, e.statusCode || 400, { error: e.message });
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3715
3774
|
const prUrlBase = detected.repoHost === 'github'
|
|
3716
3775
|
? (detected.org && detected.repoName ? `https://github.com/${detected.org}/${detected.repoName}/pull/` : '')
|
|
3717
3776
|
: (detected.org && detected.project && detected.repoName
|
|
@@ -5007,7 +5066,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5007
5066
|
// Projects
|
|
5008
5067
|
{ method: 'POST', path: '/api/projects/browse', desc: 'Open folder picker dialog, return selected path', handler: handleProjectsBrowse },
|
|
5009
5068
|
{ method: 'POST', path: '/api/projects/scan', desc: 'Scan a directory for git repos', params: 'path?, depth?', handler: handleProjectsScan },
|
|
5010
|
-
{ method: 'POST', path: '/api/projects/
|
|
5069
|
+
{ method: 'POST', path: '/api/projects/confirm-token', desc: 'Mint a single-use UUID token required to add a non-repo path (SEC-05)', handler: handleProjectsConfirmToken },
|
|
5070
|
+
{ method: 'POST', path: '/api/projects/add', desc: 'Auto-discover and add a project to config (name validated SEC-04; path validated SEC-05)', params: 'path, name?, allowNonRepo?, confirmToken?', handler: handleProjectsAdd },
|
|
5011
5071
|
|
|
5012
5072
|
// Bug Filing
|
|
5013
5073
|
{ method: 'POST', path: '/api/issues/create', desc: 'File a bug on the Minions repo (yemi33/minions)', params: 'title, description?, labels?', handler: handleFileBug },
|
package/engine/scheduler.js
CHANGED
|
@@ -78,7 +78,21 @@ function parseCronExpr(expr) {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Check if a schedule should fire now, given its last run time.
|
|
81
|
-
* Prevents double-firing within the same minute
|
|
81
|
+
* Prevents double-firing within the same cron minute.
|
|
82
|
+
*
|
|
83
|
+
* Uses a calendar-minute comparison (year/month/date/hour/minute) instead of a
|
|
84
|
+
* fixed elapsed-time threshold. A cron minute window spans a full 60 seconds
|
|
85
|
+
* (e.g. 05:00:00 → 05:00:59), so an elapsed-time guard must also be ≥ 60s —
|
|
86
|
+
* but then two fires at 04:59:58 and 05:00:00 (different cron windows) would
|
|
87
|
+
* collapse incorrectly. Calendar-minute comparison handles both cases cleanly:
|
|
88
|
+
* any two fires in the same wall-clock minute are the same cron window, and
|
|
89
|
+
* any two fires in different wall-clock minutes are distinct cron windows.
|
|
90
|
+
*
|
|
91
|
+
* Regression note (W-mo3zu273f8tm): the old 55s threshold let two fires 58s
|
|
92
|
+
* apart inside the same cron minute (05:00:01, 05:00:59) both pass the guard
|
|
93
|
+
* when the first work item failed fast and cleared engine.js's active-dedup
|
|
94
|
+
* check.
|
|
95
|
+
*
|
|
82
96
|
* @param {{ cron: string }} schedule
|
|
83
97
|
* @param {string|null} lastRunAt -- ISO timestamp of last run
|
|
84
98
|
* @returns {boolean}
|
|
@@ -90,11 +104,17 @@ function shouldRunNow(schedule, lastRunAt) {
|
|
|
90
104
|
const now = new Date();
|
|
91
105
|
if (!cron.matches(now)) return false;
|
|
92
106
|
|
|
93
|
-
// Don't fire
|
|
94
|
-
// (uses elapsed time instead of field comparison to handle DST/clock adjustments)
|
|
107
|
+
// Don't fire twice in the same calendar minute (same cron window).
|
|
95
108
|
if (lastRunAt) {
|
|
96
109
|
const last = new Date(lastRunAt);
|
|
97
|
-
if (
|
|
110
|
+
if (!isNaN(last.getTime()) &&
|
|
111
|
+
last.getFullYear() === now.getFullYear() &&
|
|
112
|
+
last.getMonth() === now.getMonth() &&
|
|
113
|
+
last.getDate() === now.getDate() &&
|
|
114
|
+
last.getHours() === now.getHours() &&
|
|
115
|
+
last.getMinutes() === now.getMinutes()) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
98
118
|
}
|
|
99
119
|
|
|
100
120
|
return true;
|
|
@@ -121,8 +141,9 @@ function discoverScheduledWork(config) {
|
|
|
121
141
|
const lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || null);
|
|
122
142
|
if (!shouldRunNow(sched, lastRun)) continue;
|
|
123
143
|
|
|
144
|
+
const workItemId = `sched-${sched.id}-${Date.now()}`;
|
|
124
145
|
work.push({
|
|
125
|
-
id:
|
|
146
|
+
id: workItemId,
|
|
126
147
|
title: sched.title,
|
|
127
148
|
type: sched.type || 'implement',
|
|
128
149
|
priority: sched.priority || 'medium',
|
|
@@ -135,9 +156,14 @@ function discoverScheduledWork(config) {
|
|
|
135
156
|
_scheduleId: sched.id,
|
|
136
157
|
});
|
|
137
158
|
|
|
138
|
-
// Record run time
|
|
159
|
+
// Record run time AND work-item ID at dispatch time — preserve existing
|
|
160
|
+
// completion fields (lastResult, lastCompletedAt). Writing lastWorkItemId
|
|
161
|
+
// here (not only on completion) keeps the schedule-runs entry durable if
|
|
162
|
+
// the dispatched work item crashes or the engine restarts before
|
|
163
|
+
// lifecycle.runPostCompletionHooks runs. This is the fix that closes
|
|
164
|
+
// the double-fire window alongside the same-minute guard (W-mo3zu273f8tm).
|
|
139
165
|
const existing = typeof runs[sched.id] === 'object' && runs[sched.id] ? runs[sched.id] : {};
|
|
140
|
-
runs[sched.id] = { ...existing, lastRun: ts() };
|
|
166
|
+
runs[sched.id] = { ...existing, lastRun: ts(), lastWorkItemId: workItemId };
|
|
141
167
|
}
|
|
142
168
|
}, { defaultValue: {} });
|
|
143
169
|
|
package/engine/shared.js
CHANGED
|
@@ -1029,6 +1029,82 @@ function sanitizeBranch(name) {
|
|
|
1029
1029
|
return String(name).replace(/[^a-zA-Z0-9._\-\/]/g, '-').slice(0, 200);
|
|
1030
1030
|
}
|
|
1031
1031
|
|
|
1032
|
+
// ── Project Name / Path Validation (SEC-04 / SEC-05) ─────────────────────────
|
|
1033
|
+
// Enforced at API boundaries (e.g. POST /api/projects/add). Callers that skip
|
|
1034
|
+
// these validators leak caller-controlled strings into worktree paths, config
|
|
1035
|
+
// keys, and shell invocations — never bypass them for "internal" callers.
|
|
1036
|
+
|
|
1037
|
+
const PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;
|
|
1038
|
+
|
|
1039
|
+
function _httpError(status, message, extra) {
|
|
1040
|
+
const err = new Error(message);
|
|
1041
|
+
err.statusCode = status;
|
|
1042
|
+
if (extra) Object.assign(err, extra);
|
|
1043
|
+
return err;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Validate a project name against a strict allowlist before it ever reaches
|
|
1048
|
+
* filesystem paths, config keys, or shell arguments.
|
|
1049
|
+
*
|
|
1050
|
+
* Allowlist: `/^[a-zA-Z0-9_\-]{1,64}$/` (letters, digits, underscore, hyphen).
|
|
1051
|
+
* Rejects anything else — path separators, dots, whitespace, shell
|
|
1052
|
+
* metacharacters, null bytes. Returns the validated name; throws a 400 Error.
|
|
1053
|
+
*/
|
|
1054
|
+
function validateProjectName(name) {
|
|
1055
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
1056
|
+
throw _httpError(400, 'Invalid project name: name is required and must be a string');
|
|
1057
|
+
}
|
|
1058
|
+
if (name.length > 64) {
|
|
1059
|
+
throw _httpError(400, `Invalid project name: "${name}" is ${name.length} characters (max 64)`);
|
|
1060
|
+
}
|
|
1061
|
+
if (!PROJECT_NAME_RE.test(name)) {
|
|
1062
|
+
throw _httpError(400, `Invalid project name: "${name}". Must match /^[a-zA-Z0-9_\\-]{1,64}$/ (letters, digits, underscore, hyphen; no path separators, spaces, or shell metacharacters)`);
|
|
1063
|
+
}
|
|
1064
|
+
return name;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Validate a project path before it is persisted to config.json or used as a
|
|
1069
|
+
* worktree parent.
|
|
1070
|
+
*
|
|
1071
|
+
* Default requires `fs.existsSync(path.join(pathStr, '.git'))` — accepts either
|
|
1072
|
+
* a `.git` directory (normal repo) or a `.git` file (worktree pointer).
|
|
1073
|
+
*
|
|
1074
|
+
* To register a non-repo path, the caller must pass BOTH
|
|
1075
|
+
* `allowNonRepo: true`
|
|
1076
|
+
* `confirmToken: <uuid>`
|
|
1077
|
+
* and supply an `isValidToken(token)` callback that consumes/validates the
|
|
1078
|
+
* token against a freshly generated server-side token. This prevents a
|
|
1079
|
+
* single POST from silently creating a broken project entry and forces the
|
|
1080
|
+
* client through an explicit confirmation step (D-5: Rebecca / UUID vote).
|
|
1081
|
+
*
|
|
1082
|
+
* Returns the resolved absolute path; throws a 400 Error (with
|
|
1083
|
+
* `needsConfirmation: true` when the only problem is the missing `.git`).
|
|
1084
|
+
*/
|
|
1085
|
+
function validateProjectPath(pathStr, options = {}) {
|
|
1086
|
+
if (typeof pathStr !== 'string' || pathStr.length === 0) {
|
|
1087
|
+
throw _httpError(400, 'Invalid project path: path is required and must be a string');
|
|
1088
|
+
}
|
|
1089
|
+
const resolved = path.resolve(pathStr);
|
|
1090
|
+
if (!fs.existsSync(resolved)) {
|
|
1091
|
+
throw _httpError(400, `Invalid project path: directory does not exist: ${resolved}`);
|
|
1092
|
+
}
|
|
1093
|
+
const gitMarker = path.join(resolved, '.git');
|
|
1094
|
+
if (fs.existsSync(gitMarker)) return resolved; // .git dir OR worktree .git file
|
|
1095
|
+
|
|
1096
|
+
// Not a git repo — only accept with explicit confirmation.
|
|
1097
|
+
const { allowNonRepo, confirmToken, isValidToken } = options;
|
|
1098
|
+
if (allowNonRepo === true && typeof confirmToken === 'string' && typeof isValidToken === 'function' && isValidToken(confirmToken)) {
|
|
1099
|
+
return resolved;
|
|
1100
|
+
}
|
|
1101
|
+
throw _httpError(
|
|
1102
|
+
400,
|
|
1103
|
+
`Invalid project path: "${resolved}" is not a git repository (no .git directory or file). Retry with allowNonRepo:true and a confirmToken from POST /api/projects/confirm-token to override.`,
|
|
1104
|
+
{ needsConfirmation: true },
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1032
1108
|
// ── Skill Frontmatter Parser ─────────────────────────────────────────────────
|
|
1033
1109
|
|
|
1034
1110
|
function parseSkillFrontmatter(content, filename) {
|
|
@@ -1631,6 +1707,8 @@ module.exports = {
|
|
|
1631
1707
|
getAdoOrgBase,
|
|
1632
1708
|
sanitizePath,
|
|
1633
1709
|
sanitizeBranch,
|
|
1710
|
+
validateProjectName,
|
|
1711
|
+
validateProjectPath,
|
|
1634
1712
|
validatePid,
|
|
1635
1713
|
parseSkillFrontmatter,
|
|
1636
1714
|
sleepMs,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1122",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|