@yemi33/minions 0.1.1975 → 0.1.1977

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.
@@ -51,6 +51,15 @@ function wiRow(item) {
51
51
  (item._reopened ? ' <span style="font-size:9px;color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') +
52
52
  (item._pendingReason && item.status === 'pending' && item._pendingReason !== 'already_dispatched' ? ' <span style="font-size:9px;color:var(--muted);margin-left:4px" title="Pending reason: ' + escapeHtml(item._pendingReason) + '">' + escapeHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
53
53
  (item._pendingReason === 'already_dispatched' && item.status === 'pending' ? ' <span style="font-size:9px;color:var(--blue);margin-left:4px" title="In dispatch queue, waiting to be assigned">queued</span>' : '') +
54
+ (item._managedSpawnPartial && Array.isArray(item._managedSpawnPartial.failed) && item._managedSpawnPartial.failed.length
55
+ ? ' <span style="font-size:9px;color:var(--yellow);margin-left:4px;border:1px solid var(--yellow);padding:0 4px;border-radius:6px" title="managed-spawn: '
56
+ + escapeHtml(((item._managedSpawnPartial.healthy || []).length) + '/' + (((item._managedSpawnPartial.healthy || []).length) + item._managedSpawnPartial.failed.length))
57
+ + ' healthy — failed: '
58
+ + escapeHtml(item._managedSpawnPartial.failed.map(function(f){ return (f && f.name) || '?'; }).join(', '))
59
+ + '. Click row for details.">&#x26A0; managed-spawn: '
60
+ + escapeHtml(((item._managedSpawnPartial.healthy || []).length) + '/' + (((item._managedSpawnPartial.healthy || []).length) + item._managedSpawnPartial.failed.length))
61
+ + ' healthy</span>'
62
+ : '') +
54
63
  (item._skipReason && item.status === 'pending' ? ' <span style="font-size:9px;color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escapeHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escapeHtml(item._blockedBy) + ')' : '') + '">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escapeHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
55
64
  (item.status === 'failed' ? ' ' + wiRetryBtn(item) : '') +
56
65
  '</td>' +
@@ -472,6 +481,26 @@ function openWorkItemDetail(id) {
472
481
  if (item.completedAt) html += field('Completed', escapeHtml(formatLocalDateTime(item.completedAt)));
473
482
  if (item.failReason) html += field('Failure Reason', '<span style="color:var(--red)">' + escapeHtml(item.failReason) + '</span>');
474
483
  if (item._pendingReason && item.status === 'pending') html += field('Pending Reason', item._pendingReason === 'already_dispatched' ? 'Queued — waiting for available agent slot' : escapeHtml(item._pendingReason.replace(/_/g, ' ')));
484
+ if (item._managedSpawnPartial && Array.isArray(item._managedSpawnPartial.failed) && item._managedSpawnPartial.failed.length) {
485
+ var _msp = item._managedSpawnPartial;
486
+ var _mspHealthy = Array.isArray(_msp.healthy) ? _msp.healthy : [];
487
+ var _mspTotal = _mspHealthy.length + _msp.failed.length;
488
+ var _mspBody = '<div style="color:var(--yellow);font-size:11px;margin-bottom:6px">'
489
+ + escapeHtml(_mspHealthy.length + '/' + _mspTotal) + ' specs healthy. Failed: '
490
+ + escapeHtml(_msp.failed.map(function(f){ return (f && f.name) || '?'; }).join(', '))
491
+ + (_msp.evaluated_at ? ' <span style="color:var(--muted)">(evaluated ' + escapeHtml(_msp.evaluated_at) + ')</span>' : '')
492
+ + '</div>';
493
+ _mspBody += _msp.failed.map(function(f) {
494
+ var name = escapeHtml((f && f.name) || '?');
495
+ var reason = escapeHtml((f && f.reason) || 'unknown');
496
+ var tail = (f && f.log_tail) || '';
497
+ return '<details style="margin-bottom:6px"><summary style="cursor:pointer;font-size:11px"><strong>' + name + '</strong> — ' + reason + '</summary>'
498
+ + '<pre style="font-size:10px;max-height:240px;overflow:auto;padding:6px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);margin:4px 0 0 0">'
499
+ + escapeHtml(tail || '(no log tail captured)')
500
+ + '</pre></details>';
501
+ }).join('');
502
+ html += field('Managed-spawn partial failure', _mspBody);
503
+ }
475
504
  if (item._skipReason && item.status === 'pending') html += field('Dispatch Blocked', '<span style="color:var(--yellow)">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + '</span>' + (item._blockedBy ? ' — blocked by <strong>' + escapeHtml(item._blockedBy) + '</strong>' : ''));
476
505
  // Defensive: CC dispatches can land here with these fields as strings
477
506
  // (e.g. acceptanceCriteria: "fix the login bug"). Coerce to arrays so
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "id": "managed-spawn-env-allowlist",
4
+ "removedAt": "2026-05-18",
5
+ "reason": "ENGINE_DEFAULTS.managedSpawn.envKeyAllowlist + envKeyAllowlistPrefixes removed; replaced by envKeyDenyPatterns + envKeyDenyOverrides. The allowlist shape required an engine PR for every new framework/project env prefix (W-mpbpa09c000rd513 tried per-project allowlist extension; user steered away — 'make sure that we are not hardcoding any env variables or being so rigid about it'). The denylist shape matches the actual credential-leakage threat model and lets plain project vars like CONSTELLATION_SERVER, DATABASE_URL, REDIS_HOST work with zero engine config while still blocking credential-shaped keys (AWS_*, *_TOKEN, *_SECRET, etc.). Per-project tightening is supported via project.managedSpawnExtraDenyPatterns (additive only, no per-project override list).",
6
+ "removedLocations": [
7
+ "engine/shared.js ENGINE_DEFAULTS.managedSpawn.envKeyAllowlist (15 keys)",
8
+ "engine/shared.js ENGINE_DEFAULTS.managedSpawn.envKeyAllowlistPrefixes (8 prefixes)",
9
+ "engine/managed-spawn.js _envKeyAllowed (rewritten to deny+override+shape model)",
10
+ "engine/managed-spawn.js buildManagedSpawnHint (env-key guidance rewritten)",
11
+ "PR #2624 (closed, superseded — added per-project allowlist union; replaced here by per-project deny tightening)",
12
+ "test/unit/managed-spawn-validator.test.js 4e/4f/11a (rewritten for denylist semantics)"
13
+ ],
14
+ "notes": "Already removed in this PR; entry exists to track the breaking shape change in the deprecation log. Delete entry after 3 days per /cleanup-deprecated."
15
+ },
2
16
  {
3
17
  "id": "config-poll-key-migration",
4
18
  "location": "engine/queries.js:123-162",
@@ -36,7 +36,7 @@ The sidecar lives at `<MINIONS_DIR>/agents/<agentId>/managed-spawn.json` and is
36
36
  "cmd": "bun", // must be on engine.managedSpawn.executableAllowlist
37
37
  "args": ["run", "dev"], // ≤64 entries
38
38
  "cwd": "D:/repos/constellation", // must be a real git worktree (requireGitWorkdir: true)
39
- "env": { "VITE_HOST": "127.0.0.1" }, // ≤32 keys; allowlist + prefix-allowlist enforced
39
+ "env": { "CONSTELLATION_SERVER": "http://localhost:3000" }, // ≤32 keys; POSIX-shape + denylist enforced
40
40
  "ports": [3001], // 1024-65535; ≤20 per spec; advisory only (engine doesn't bind)
41
41
  "ttl_minutes": 240, // ≤1440 (24h hard cap); defaults to 240 (4h)
42
42
  "attrs": { // opaque per-spec metadata, ≤2048 bytes serialized
@@ -59,6 +59,14 @@ The sidecar lives at `<MINIONS_DIR>/agents/<agentId>/managed-spawn.json` and is
59
59
 
60
60
  The renderer in [`buildManagedSpawnHint`](../engine/managed-spawn.js) at `engine/managed-spawn.js:419` emits this exact shape (with allowlist + cap reminders) into the agent's prompt whenever the work item has `meta.managed_spawn: true`. Treat the rendered hint as the source of truth — if this doc and the hint drift, the hint wins.
61
61
 
62
+ ### Smoke-test before writing the sidecar (mandatory, W-mpbpexrg00110661)
63
+
64
+ The hint mandates that agents run each `cmd args` in the declared `cwd` for at least 5 seconds AND confirm the healthcheck endpoint returns the expected status BEFORE writing `managed-spawn.json`. Sidecars written from guessed-at commands are how dispatches silently lose specs: the engine spawns what the agent declared; if the command crashes on first launch, the engine kills the spec, removes it from state, and (for partial failures) marks the WI with `_managedSpawnPartial` rather than demoting it — the agent's primary work succeeded by validator standards. The cost of guessing wrong is the operator finding out hours later that half the stack is down.
65
+
66
+ Real failure class to internalize: workspace-filter resolution (`bun -F <name>`, `pnpm --filter <name>`) failing because workspace deps were never installed in the worktree. Exits 1 in <1s with `No packages matched the filter`. Smoke-test catches it in seconds; an operator finding it catches it in hours (incident W-mpbolwvt000gb9b1: 1 of 3 Constellation specs survived, WI showed `done`).
67
+
68
+ Both a PowerShell (Windows) and bash (macOS/Linux) example are inlined in the hint — see `buildManagedSpawnHint` for the canonical patterns.
69
+
62
70
  ## Healthcheck examples
63
71
 
64
72
  ### HTTP — most common
@@ -211,8 +219,8 @@ All knobs live under `engine.managedSpawn` in `engine/shared.js:1500` (`ENGINE_D
211
219
  | `promptContextMaxBytes` | `2048` | Auto-injected `## Live managed processes` block cap. |
212
220
  | `requireGitWorkdir` | `true` | Reject specs whose `cwd` isn't a git worktree. |
213
221
  | `executableAllowlist` | `[node, bun, npm, …]` | Single global. Applies to `spec.cmd` AND `command` healthcheck `cmd`. |
214
- | `envKeyAllowlist` | `[NODE_ENV, PORT, …]` | Exact-match env keys. |
215
- | `envKeyAllowlistPrefixes` | `[VITE_, NEXT_, ]` | Prefix-match env keys. |
222
+ | `envKeyDenyPatterns` | `[^AWS_, ^AZURE_, _SECRET, _TOKEN, _API_KEY, …]` | Regex source strings, matched case-insensitively. Keys matching ANY pattern are rejected unless exact-listed in `envKeyDenyOverrides`. Threat model: credential leakage, not env-key enumeration — plain project vars (`CONSTELLATION_SERVER`, `DATABASE_URL`, …) pass with no config. |
223
+ | `envKeyDenyOverrides` | `[AWS_REGION, AWS_DEFAULT_REGION, AZURE_REGION, GCP_REGION, AWS_PROFILE]` | Exact-match exemptions for known-safe keys that would otherwise be caught by a broad prefix pattern. Case-sensitive. |
216
224
 
217
225
  ## Failure modes
218
226
 
@@ -220,6 +228,7 @@ All knobs live under `engine.managedSpawn` in `engine/shared.js:1500` (`ENGINE_D
220
228
  |---|---|---|
221
229
  | Dispatch ERROR `failure_class: invalid-managed-spawn` | Sidecar schema/allowlist violation | Read inbox alert; the validator includes a precise reason. Non-retryable — fix and re-dispatch. |
222
230
  | Dispatch ERROR `failure_class: managed-spawn-healthcheck` | `timeout_s` elapsed before any spec became healthy | Check `engine/managed-logs/<name>.log` for the child's crash output. Sibling spawns are left alive. Retryable. |
231
+ | WI shows yellow `⚠ managed-spawn: N/M healthy` chip on dashboard | Partial healthcheck failure: ≥1 spec passed, ≥1 failed. WI keeps `status: done` (the agent's primary work — getting an accepted sidecar — succeeded); annotation `_managedSpawnPartial = { healthy, failed[{name, reason, log_tail}], evaluated_at }` lives on the WI. Click the row to see per-spec failure reasons + last 20 log lines. Dispatch is still recorded ERROR with `failure_class: managed-spawn-healthcheck-failed`. Restart the failing spec via `POST /api/managed-processes/restart` once you've fixed the root cause (often: workspace deps not installed; see smoke-test rule in the hint). W-mpbpexrg00110661. |
223
232
  | Spec gone after `minions restart` | Bun child died with parent (the original failure mode) | Should be fixed by item 2's detached-spawn pattern. If it recurs, verify `bin/minions.js spawnDashboard` semantics still work for the runtime — that's the canonical reference. |
224
233
  | Spec listed `alive: true, healthy: false` for >30s | Healthcheck loop self-detected service degradation | The spec did not pass a subsequent healthcheck. Inspect the service; restart via API once recovered. |
225
234
  | Stale row sticks around with dead PID | Spec killed outside Minions | Wait one sweep cycle (~30 min) or call `POST /api/managed-processes/kill` manually. |
@@ -64,15 +64,78 @@ function _isOnAllowlist(name, allowlist) {
64
64
  return false;
65
65
  }
66
66
 
67
- function _envKeyAllowed(key, limits) {
68
- if (typeof key !== 'string' || key.length === 0) return false;
69
- const allowlist = Array.isArray(limits.envKeyAllowlist) ? limits.envKeyAllowlist : [];
70
- if (allowlist.indexOf(key) >= 0) return true;
71
- const prefixes = Array.isArray(limits.envKeyAllowlistPrefixes) ? limits.envKeyAllowlistPrefixes : [];
72
- for (const p of prefixes) {
73
- if (typeof p === 'string' && p.length > 0 && key.indexOf(p) === 0) return true;
67
+ // Env-key shape guard. Bash/POSIX-style identifier: leading letter or
68
+ // underscore, followed by letters / digits / underscores. Rejects keys with
69
+ // shell metachars ('FOO;BAR', 'FOO BAR', '$FOO') regardless of deny rules —
70
+ // argv smuggling protection, separate from the credential-shape threat
71
+ // model below.
72
+ const _ENV_KEY_SHAPE_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
73
+
74
+ // Compile envKeyDenyPatterns once per validator invocation and cache. The
75
+ // returned object also carries the override Set for fast exact-match lookup.
76
+ // All patterns are compiled case-insensitively so '_secret' / '_SECRET' are
77
+ // equivalent; overrides remain case-sensitive (exact match).
78
+ function _compileDenyRules(limits, projectExtraPatterns) {
79
+ const globalPatterns = Array.isArray(limits.envKeyDenyPatterns) ? limits.envKeyDenyPatterns : [];
80
+ const extra = Array.isArray(projectExtraPatterns) ? projectExtraPatterns : [];
81
+ const overridesArr = Array.isArray(limits.envKeyDenyOverrides) ? limits.envKeyDenyOverrides : [];
82
+ const sources = [];
83
+ const compiled = [];
84
+ for (const src of globalPatterns.concat(extra)) {
85
+ if (typeof src !== 'string' || src.length === 0) continue;
86
+ let re;
87
+ try { re = new RegExp(src, 'i'); }
88
+ catch (_e) { continue; }
89
+ sources.push(src);
90
+ compiled.push(re);
74
91
  }
75
- return false;
92
+ const overrides = new Set();
93
+ for (const k of overridesArr) {
94
+ if (typeof k === 'string' && k.length > 0) overrides.add(k);
95
+ }
96
+ return { sources, compiled, overrides };
97
+ }
98
+
99
+ // Check an env key against compiled deny rules. Returns
100
+ // { allowed: true } when the key passes, or
101
+ // { allowed: false, reason: 'env-key-denied (matched pattern X)' }
102
+ // when blocked. Does NOT enforce the shape guard — callers do that
103
+ // separately so shape-failures get a distinct reason.
104
+ function _envKeyAllowed(key, denyRules) {
105
+ if (typeof key !== 'string' || key.length === 0) {
106
+ return { allowed: false, reason: 'env-key-empty' };
107
+ }
108
+ if (denyRules.overrides.has(key)) return { allowed: true };
109
+ for (let i = 0; i < denyRules.compiled.length; i++) {
110
+ if (denyRules.compiled[i].test(key)) {
111
+ return { allowed: false, reason: 'env-key-denied (matched pattern ' + denyRules.sources[i] + ')' };
112
+ }
113
+ }
114
+ return { allowed: true };
115
+ }
116
+
117
+ // Find the configured project whose `localPath` contains the spec's cwd.
118
+ // Returns the most-specific match (longest matching localPath) so nested
119
+ // project layouts (e.g. /repos/parent and /repos/parent/sub) prefer the
120
+ // inner one. Returns null when no project matches or when inputs are
121
+ // missing. Pure — does not touch disk.
122
+ function _resolveProjectForCwd(cwd, projects) {
123
+ if (typeof cwd !== 'string' || cwd.length === 0) return null;
124
+ if (!Array.isArray(projects) || projects.length === 0) return null;
125
+ let best = null;
126
+ let bestLen = -1;
127
+ for (const p of projects) {
128
+ if (!p || typeof p !== 'object') continue;
129
+ const lp = typeof p.localPath === 'string' ? p.localPath : '';
130
+ if (!lp) continue;
131
+ if (!shared.isPathInsideOrEqual(cwd, lp)) continue;
132
+ const len = path.resolve(lp).length;
133
+ if (len > bestLen) {
134
+ best = p;
135
+ bestLen = len;
136
+ }
137
+ }
138
+ return best;
76
139
  }
77
140
 
78
141
  // Extract the first executable-like token from a shell-style healthcheck cmd
@@ -229,7 +292,16 @@ function _validateSpec(spec, index, limits, opts) {
229
292
  }
230
293
  }
231
294
 
232
- // env (optional)
295
+ // env (optional). Validation has three independent layers:
296
+ // 1. Shape guard: key matches /^[A-Za-z_][A-Za-z0-9_]*$/ (argv-smuggling
297
+ // protection). Reason: env-key-invalid-shape.
298
+ // 2. Denylist: key does NOT match any credential-shaped pattern unless
299
+ // explicitly listed in envKeyDenyOverrides. Per-project tightening
300
+ // (project.managedSpawnExtraDenyPatterns) can add MORE patterns
301
+ // when spec.cwd resolves under that project's localPath — projects
302
+ // can only tighten, never loosen (asymmetric on purpose; no
303
+ // per-project override list).
304
+ // 3. Value sanity: string, ≤1000 chars.
233
305
  const envRaw = spec.env == null ? {} : spec.env;
234
306
  if (typeof envRaw !== 'object' || Array.isArray(envRaw)) {
235
307
  return { ok: false, reason: 'env-not-object' };
@@ -239,10 +311,22 @@ function _validateSpec(spec, index, limits, opts) {
239
311
  if (envKeys.length > maxEnv) {
240
312
  return { ok: false, reason: 'env-too-many (>' + maxEnv + ')' };
241
313
  }
314
+ const projectForSpec = _resolveProjectForCwd(
315
+ typeof spec.cwd === 'string' ? spec.cwd : '',
316
+ opts && Array.isArray(opts.projects) ? opts.projects : null,
317
+ );
318
+ const projectExtraPatterns = projectForSpec && Array.isArray(projectForSpec.managedSpawnExtraDenyPatterns)
319
+ ? projectForSpec.managedSpawnExtraDenyPatterns
320
+ : null;
321
+ const denyRules = _compileDenyRules(limits, projectExtraPatterns);
242
322
  const env = {};
243
323
  for (const k of envKeys) {
244
- if (!_envKeyAllowed(k, limits)) {
245
- return { ok: false, reason: 'env-key-not-on-allowlist (' + k + ')' };
324
+ if (!_ENV_KEY_SHAPE_RE.test(k)) {
325
+ return { ok: false, reason: 'env-key-invalid-shape (' + k + ')' };
326
+ }
327
+ const decision = _envKeyAllowed(k, denyRules);
328
+ if (!decision.allowed) {
329
+ return { ok: false, reason: decision.reason };
246
330
  }
247
331
  const v = envRaw[k];
248
332
  if (typeof v !== 'string') return { ok: false, reason: 'env-value-not-string (' + k + ')' };
@@ -323,10 +407,25 @@ function validateManagedSpawnRecord(parsed, opts) {
323
407
  return { ok: false, reason: 'specs-too-many (>' + maxSpecs + ')' };
324
408
  }
325
409
 
410
+ // Resolve `projects` for per-project env-deny tightening. Callers (tests,
411
+ // future engine wiring) may pre-supply `opts.projects` for determinism;
412
+ // when undefined we lazy-load from MINIONS_DIR/config.json so the engine
413
+ // close-handler doesn't have to change signature. A read failure or a
414
+ // config without `projects` falls back to `[]` — unchanged behavior, the
415
+ // global deny patterns remain the only source.
416
+ let projects;
417
+ if (Array.isArray(opts.projects)) {
418
+ projects = opts.projects;
419
+ } else {
420
+ try { projects = shared.getProjects(); }
421
+ catch (_e) { projects = []; }
422
+ }
423
+ const specOpts = Object.assign({}, opts, { projects: projects });
424
+
326
425
  const seen = new Set();
327
426
  const out = [];
328
427
  for (let i = 0; i < parsed.specs.length; i++) {
329
- const v = _validateSpec(parsed.specs[i], i, limits, opts);
428
+ const v = _validateSpec(parsed.specs[i], i, limits, specOpts);
330
429
  if (!v.ok) {
331
430
  // Preserve workdir-rejection prefix at the top level so the engine
332
431
  // close-handler gate can key off it the same way it does for
@@ -480,7 +579,7 @@ function buildManagedSpawnHint(opts) {
480
579
  '1. Reads your sidecar after you exit.',
481
580
  '2. Spawns each spec detached (the working Windows pattern is centralised in the engine — you do **not** need to write `Start-Process` or `spawn({ detached: true })` yourself).',
482
581
  '3. Drives the healthcheck loop until each spec passes its first check (within `timeout_s`).',
483
- '4. **Fails this dispatch (ERROR) if any spec fails its healthcheck.** Surviving siblings stay alive; failing PIDs are killed.',
582
+ '4. **Fails this dispatch (ERROR) if any spec fails its healthcheck.** Surviving siblings stay alive; failing PIDs are killed. When at least one spec stays healthy (partial failure) the engine also writes a `_managedSpawnPartial` annotation onto the WI (status stays `done`) so the dashboard surfaces a warning chip instead of silently swallowing the failure. When ALL specs fail the WI is demoted to FAILED via the normal force-demote path.',
484
583
  '5. Auto-injects a `## Live managed processes` block into downstream agents\' prompts (scoped to your project) so the next dispatch can find the service without you telling it.',
485
584
  '6. Sweeps dead PIDs / TTL-expired specs every ' + (limits.sweepEvery || 30) + ' ticks; kills + unlinks at TTL.',
486
585
  '',
@@ -489,13 +588,63 @@ function buildManagedSpawnHint(opts) {
489
588
  '- Specs per file: ≤ ' + maxSpecs,
490
589
  '- Name: kebab-case, ≤ 64 chars, unique within file',
491
590
  '- Executable (`cmd` and any `command` healthcheck cmd): on the engine\'s allowlist (node, bun, npm, npx, python, docker, adb, gradle, mvn, pwsh, …)',
492
- '- Env keys: on the engine\'s allowlist or matching a known prefix (e.g. `VITE_`, `NEXT_`, `REACT_APP_`, `npm_config_`)',
591
+ '- Env keys: any well-formed POSIX identifier (`/^[A-Za-z_][A-Za-z0-9_]*$/`) is accepted EXCEPT keys matching credential-shaped deny patterns (`*_SECRET`, `*_TOKEN`, `*_API_KEY`, `*_PASSWORD`, `*_PRIVATE_KEY`, `*_CREDENTIALS`, `*_AUTH`, `*_PAT`, `AWS_*`, `AZURE_*`, `GCP_*`, `GH_TOKEN`, `GITHUB_TOKEN`, `OPENAI_*`, `ANTHROPIC_*`, `COPILOT_*`, `DOCKER_AUTH*`, `NPM_TOKEN`). Region/profile names (`AWS_REGION`, `AWS_PROFILE`, …) are exempt. If your service genuinely needs a credential-looking var, rename it (e.g. `DATABASE_URL` not `DB_API_KEY`) or stash the value in `attrs` and have the child read it from there. **The engine forwards env values verbatim — the deny shape is a tripwire, not a scrubber. Do not put real credentials in env even if the key passes.** Projects may tighten by adding patterns to `config.projects[N].managedSpawnExtraDenyPatterns`; projects cannot loosen.',
493
592
  '- Ports: 1024–65535, ≤ 20 per spec',
494
593
  '- TTL: ≤ ' + maxTtl + ' minutes (hard cap), defaults to ' + defaultTtl + ' if omitted',
495
594
  '- `attrs` serialized: ≤ 2048 bytes (opaque blob the engine surfaces to downstream agents)',
496
595
  '',
497
596
  'If your file is invalid the engine marks this dispatch ERROR with `failure_class: invalid-managed-spawn` (non-retryable) — fix the file shape, don\'t retry blindly.',
498
597
  '',
598
+ '### Mandatory: smoke-test before writing the sidecar',
599
+ '',
600
+ 'Before writing `managed-spawn.json`, run each `cmd args` in the declared `cwd` for at least 5 seconds. Confirm the process stays alive AND its healthcheck endpoint returns the expected status. Kill the test process. THEN write the sidecar. Sidecars written from guessed-at commands are how dispatches silently lose specs — the engine spawns what you declared; if you declared a command that crashes, the engine kills it and removes it from state without alerting the WI as failed. The cost of guessing wrong is the user finding out hours later that half the stack is down.',
601
+ '',
602
+ 'Concrete failure class to avoid: workspace-filter resolution (e.g. `bun -F <name>` or `pnpm --filter <name>`) failing because the workspace deps were never installed in the worktree. The command exits 1 in <1s with `No packages matched the filter` and the engine kills the spec without surfacing anything beyond a partial-healthcheck annotation. Smoke-test catches this in seconds; the user finding out catches it in hours.',
603
+ '',
604
+ 'Smoke-test pattern (PowerShell — Windows):',
605
+ '',
606
+ '```powershell',
607
+ '# Replace <cwd>, <cmd>, <args...>, <healthcheck-url>, <expected-status> with your spec values.',
608
+ 'Push-Location <cwd>',
609
+ '$proc = Start-Process -FilePath <cmd> -ArgumentList \'<arg1>\',\'<arg2>\' -PassThru -WindowStyle Hidden -RedirectStandardOutput smoke.out -RedirectStandardError smoke.err',
610
+ 'try {',
611
+ ' $deadline = (Get-Date).AddSeconds(15)',
612
+ ' $healthy = $false',
613
+ ' while ((Get-Date) -lt $deadline) {',
614
+ ' if ($proc.HasExited) { throw "spec exited early with code $($proc.ExitCode); tail smoke.err for the reason" }',
615
+ ' try {',
616
+ ' $r = Invoke-WebRequest -Uri \'<healthcheck-url>\' -UseBasicParsing -TimeoutSec 2',
617
+ ' if ($r.StatusCode -eq <expected-status>) { $healthy = $true; break }',
618
+ ' } catch { Start-Sleep -Milliseconds 500 }',
619
+ ' }',
620
+ ' if (-not $healthy) { throw "spec stayed alive but healthcheck never returned <expected-status> within 15s" }',
621
+ ' Write-Host "smoke-test OK — spec stayed alive AND healthcheck passed"',
622
+ '} finally {',
623
+ ' if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }',
624
+ ' Pop-Location',
625
+ '}',
626
+ '```',
627
+ '',
628
+ 'Smoke-test pattern (bash — macOS/Linux):',
629
+ '',
630
+ '```bash',
631
+ '# Replace <cwd>, <cmd>, <args...>, <healthcheck-url>, <expected-status> with your spec values.',
632
+ 'cd <cwd>',
633
+ '<cmd> <arg1> <arg2> >smoke.out 2>smoke.err &',
634
+ 'pid=$!',
635
+ 'healthy=0',
636
+ 'for _ in $(seq 1 15); do',
637
+ ' if ! kill -0 "$pid" 2>/dev/null; then echo "spec exited early; tail smoke.err for the reason" >&2; break; fi',
638
+ ' if [ "$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 2 \'<healthcheck-url>\')" = "<expected-status>" ]; then healthy=1; break; fi',
639
+ ' sleep 1',
640
+ 'done',
641
+ 'kill "$pid" 2>/dev/null || true',
642
+ 'wait "$pid" 2>/dev/null || true',
643
+ '[ "$healthy" = 1 ] && echo "smoke-test OK — spec stayed alive AND healthcheck passed" || { echo "smoke-test FAILED" >&2; exit 1; }',
644
+ '```',
645
+ '',
646
+ 'A passing smoke-test is the entry gate to writing the sidecar — not a nice-to-have. If you skip it, you are betting the WI completion against a command you never ran.',
647
+ '',
499
648
  '### Verify before exit',
500
649
  '',
501
650
  'After you write the file, query the engine to confirm acceptance:',
package/engine/shared.js CHANGED
@@ -1526,24 +1526,36 @@ const ENGINE_DEFAULTS = {
1526
1526
  'curl', 'wget',
1527
1527
  'git',
1528
1528
  ],
1529
- // Env-key allowlist (exact match). Tight by default so a managed spec
1530
- // can't leak credentials (AWS_*, AZURE_*, GH_TOKEN, etc.). Anything not
1531
- // here must match one of the allowed prefixes below.
1532
- envKeyAllowlist: [
1533
- 'NODE_ENV', 'PORT', 'HOST', 'PATH',
1534
- 'DEBUG', 'LOG_LEVEL',
1535
- 'HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP',
1536
- 'LANG', 'LC_ALL',
1537
- 'JAVA_HOME', 'ANDROID_HOME', 'ANDROID_SDK_ROOT',
1529
+ // Env-key denylist (regex source strings, matched case-insensitively).
1530
+ // The threat model is credential leakage, not env-key enumeration —
1531
+ // any key NOT matching one of these patterns is accepted, so project
1532
+ // vars like CONSTELLATION_SERVER / DATABASE_URL / REDIS_HOST work with
1533
+ // zero engine config. Patterns are compiled lazily by the validator
1534
+ // and tested in order; the first match wins for the reject reason, so
1535
+ // the more specific prefix patterns are listed before the broad
1536
+ // suffix patterns (a key like AWS_SECRET_ACCESS_KEY should report the
1537
+ // narrower `^AWS_` cause, not the generic `_SECRET` cause). Extend
1538
+ // with caution; broad patterns belong here, ecosystem allow
1539
+ // exemptions belong in `envKeyDenyOverrides`.
1540
+ envKeyDenyPatterns: [
1541
+ // Prefix patterns first (more specific cause for vendor keys).
1542
+ '^AWS_', '^AZURE_', '^GCP_', '^GOOGLE_APPLICATION_CREDENTIALS',
1543
+ '^GH_TOKEN', '^GITHUB_TOKEN',
1544
+ '^OPENAI_', '^ANTHROPIC_', '^COPILOT_',
1545
+ '^DOCKER_AUTH', '^NPM_TOKEN',
1546
+ // Suffix / substring patterns (generic credential shapes).
1547
+ '_SECRET', '_TOKEN', '_API_KEY', '_APIKEY',
1548
+ '_PASSWORD', '_PASSWD',
1549
+ '_PRIVATE_KEY', '_PRIVKEY',
1550
+ '_CREDENTIALS', '_AUTH', '_PAT',
1538
1551
  ],
1539
- // Env-key prefix allowlist. Standard ecosystem prefixes that frontends
1540
- // and tooling depend on (Vite, Next.js, CRA, npm scripts). Extend with
1541
- // caution; broad prefixes (`AWS_`, `AZURE_`) belong on a deny-list, not
1542
- // an allow-list.
1543
- envKeyAllowlistPrefixes: [
1544
- 'VITE_', 'NEXT_', 'REACT_APP_', 'NUXT_', 'GATSBY_',
1545
- 'npm_config_', 'NPM_CONFIG_',
1546
- 'MINIONS_',
1552
+ // Exact-match exemption list for keys that would otherwise be caught
1553
+ // by a broad deny pattern (region/profile names aren't secrets).
1554
+ // Match is case-sensitive the override key must equal the spec key
1555
+ // exactly.
1556
+ envKeyDenyOverrides: [
1557
+ 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AZURE_REGION', 'GCP_REGION',
1558
+ 'AWS_PROFILE',
1547
1559
  ],
1548
1560
  },
1549
1561
  // Backward-compat: keep `engine.claude.*` field family deprecation tracker. Listed here so preflight
package/engine.js CHANGED
@@ -2393,12 +2393,35 @@ async function spawnAgent(dispatchItem, config) {
2393
2393
  try { managedSpawn.removeManagedSpec(f.name); }
2394
2394
  catch (e) { log('warn', `managed-spawn healthcheck: cleanup failed for ${f.name}: ${e.message}`); }
2395
2395
  }
2396
+ // W-mpbpexrg00110661 — capture per-spec log tails (last 20 lines) so
2397
+ // the WI annotation can surface them in the dashboard chip without
2398
+ // re-reading the log files later. Use the same tailManagedLog helper
2399
+ // the inbox alert uses; cap each tail to 2KB to bound the WI write.
2400
+ const _failedDetails = failed.map(f => {
2401
+ let tail = '';
2402
+ try { tail = (managedSpawn.tailManagedLog(f.name, 20) || '').slice(-2048); }
2403
+ catch (_e) { tail = ''; }
2404
+ return { name: f.name, reason: f.error, log_tail: tail };
2405
+ });
2406
+ const _survivedNames = items.filter(it => !failed.some(f => f.name === it.name)).map(it => it.name);
2396
2407
  managedSpawnHealthcheckFailure = {
2397
2408
  failed: failed,
2398
- survivedNames: items.filter(it => !failed.some(f => f.name === it.name)).map(it => it.name),
2409
+ survivedNames: _survivedNames,
2410
+ // partial = at least one spec survived. Drives the
2411
+ // processWorkItemFailure: false override below so the WI stays
2412
+ // `done` (the agent's primary work — declaring an accepted sidecar
2413
+ // — succeeded; the dashboard surfaces the warning via
2414
+ // _managedSpawnPartial instead of demoting to FAILED).
2415
+ partial: _survivedNames.length > 0,
2416
+ annotation: {
2417
+ healthy: _survivedNames,
2418
+ failed: _failedDetails,
2419
+ evaluated_at: new Date().toISOString(),
2420
+ },
2399
2421
  };
2400
- log('warn', `managed-spawn healthcheck: ${failed.length}/${items.length} spec(s) failed for ${agentId} (${id}); ` +
2401
- failed.map(f => `${f.name}=${f.error}`).join('; '));
2422
+ log('warn', `managed-spawn healthcheck: ${failed.length}/${items.length} spec(s) failed for ${agentId} (${id})` +
2423
+ (managedSpawnHealthcheckFailure.partial ? ' (partial WI stays done, annotated)' : ' (total — WI will be demoted)') +
2424
+ `; ` + failed.map(f => `${f.name}=${f.error}`).join('; '));
2402
2425
  try {
2403
2426
  const wiId = dispatchItem.meta?.item?.id || '';
2404
2427
  const logTails = failed.map(f => {
@@ -2468,7 +2491,18 @@ async function spawnAgent(dispatchItem, config) {
2468
2491
  : FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA)
2469
2492
  : null;
2470
2493
  const completeOpts = managedSpawnHealthcheckFail
2471
- ? { ...completionOpts, failureClass: FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED, agentRetryable: false }
2494
+ ? {
2495
+ ...completionOpts,
2496
+ failureClass: FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED,
2497
+ agentRetryable: false,
2498
+ // W-mpbpexrg00110661 — partial healthcheck failure: at least one spec
2499
+ // stayed healthy. The agent's primary work (declaring + getting an
2500
+ // accepted sidecar) succeeded, so don't force-demote the WI. The
2501
+ // _managedSpawnPartial annotation written after completeDispatch is
2502
+ // the visibility lever. Total failure (no survivors) still
2503
+ // force-demotes through the normal FORCE_DEMOTE_FAILURE_CLASSES path.
2504
+ ...(managedSpawnHealthcheckFailure.partial ? { processWorkItemFailure: false } : {}),
2505
+ }
2472
2506
  : (managedSpawnAcceptanceFail
2473
2507
  ? { ...completionOpts, failureClass: FAILURE_CLASS.INVALID_MANAGED_SPAWN, agentRetryable: false }
2474
2508
  : (keepProcessesAcceptanceFail
@@ -2581,6 +2615,31 @@ async function spawnAgent(dispatchItem, config) {
2581
2615
 
2582
2616
  completeDispatch(id, effectiveResult, errorReason, resultSummary, completeOpts);
2583
2617
 
2618
+ // W-mpbpexrg00110661 — surface managed-spawn partial-healthcheck failures
2619
+ // on the WI so the dashboard renders a warning chip instead of silently
2620
+ // swallowing the loss. This runs regardless of partial vs total failure:
2621
+ // - Partial (survivors > 0): WI status stayed `done` because we passed
2622
+ // processWorkItemFailure:false above; the annotation tells operators
2623
+ // half the stack is down.
2624
+ // - Total (no survivors): WI was force-demoted to FAILED by the
2625
+ // FORCE_DEMOTE_FAILURE_CLASSES path; the annotation still attaches
2626
+ // for forensics so the dashboard can show which specs failed and
2627
+ // their log tails without operators having to dig through
2628
+ // engine/managed-logs/.
2629
+ if (managedSpawnHealthcheckFailure && managedSpawnHealthcheckFailure.annotation && dispatchItem.meta?.item?.id) {
2630
+ try {
2631
+ const wiPath = resolveWorkItemPath(dispatchItem.meta);
2632
+ if (wiPath) {
2633
+ mutateWorkItems(wiPath, items => {
2634
+ const wi = items.find(i => i.id === dispatchItem.meta.item.id);
2635
+ if (!wi) return items;
2636
+ wi._managedSpawnPartial = managedSpawnHealthcheckFailure.annotation;
2637
+ return items;
2638
+ });
2639
+ }
2640
+ } catch (e) { log('warn', `managed-spawn partial-healthcheck: failed to annotate WI: ${e.message}`); }
2641
+ }
2642
+
2584
2643
  // W-mp6k7ywi000fa33c / W-mp7i902u000l991f — surface the keep_processes
2585
2644
  // rejection on the WI so the dashboard pending-reason area shows the
2586
2645
  // missing structure instead of a bare failure_class label. _pendingReason
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1975",
3
+ "version": "0.1.1977",
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"