@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 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
- const target = path.resolve(body.path);
3666
- if (!fs.existsSync(target)) return jsonReply(res, 400, { error: 'Directory not found: ' + target });
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 name = body.name || detected.name;
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/add', desc: 'Auto-discover and add a project to config', params: 'path, name?', handler: handleProjectsAdd },
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 },
@@ -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 window.
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 again if already ran within the last 55 seconds
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 (Date.now() - last.getTime() < 55000) return false;
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: `sched-${sched.id}-${Date.now()}`,
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 inside the lock — preserve existing fields (lastWorkItemId, lastResult, etc.)
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.1120",
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"