@yemi33/minions 0.1.2383 → 0.1.2384

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.
@@ -302,7 +302,22 @@ function _validateSpec(spec, index, limits, opts) {
302
302
  if (typeof spec.cwd === 'string' && spec.cwd.length > 500) {
303
303
  return { ok: false, reason: 'cwd-too-long' };
304
304
  }
305
- if (_resolveRequireGitWorkdir(opts) && typeof spec.cwd === 'string' && spec.cwd.length > 0) {
305
+ if (opts.allowedCwdRoot) {
306
+ if (typeof spec.cwd !== 'string' || spec.cwd.length === 0) {
307
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-required-for-dispatch-confinement' };
308
+ }
309
+ try {
310
+ const cwdReal = shared.realPathForComparison(spec.cwd);
311
+ const rootReal = shared.realPathForComparison(opts.allowedCwdRoot);
312
+ if (!shared.isPathInsideOrEqual(cwdReal, rootReal)) {
313
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-outside-dispatch-worktree' };
314
+ }
315
+ } catch (err) {
316
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-containment-check-failed (' + err.message + ')' };
317
+ }
318
+ }
319
+ let normalizedCwd = typeof spec.cwd === 'string' ? spec.cwd : '';
320
+ if (_resolveRequireGitWorkdir(opts) && normalizedCwd.length > 0) {
306
321
  const wt = shared.isValidGitWorktree(spec.cwd, { memo: opts._gitWorktreeMemo });
307
322
  if (!wt.ok) {
308
323
  // W-mpbpa01y000qcdc2 — enrich the reject reason so the dispatcher prompt
@@ -319,6 +334,12 @@ function _validateSpec(spec, index, limits, opts) {
319
334
  return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + detail };
320
335
  }
321
336
  }
337
+ if (normalizedCwd) {
338
+ try { normalizedCwd = shared.realPathForComparison(normalizedCwd); }
339
+ catch (err) {
340
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-canonicalization-failed (' + err.message + ')' };
341
+ }
342
+ }
322
343
 
323
344
  // env (optional). Validation has three independent layers:
324
345
  // 1. Shape guard: key matches /^[A-Za-z_][A-Za-z0-9_]*$/ (argv-smuggling
@@ -339,7 +360,7 @@ function _validateSpec(spec, index, limits, opts) {
339
360
  if (envKeys.length > maxEnv) {
340
361
  return { ok: false, reason: 'env-too-many (>' + maxEnv + ')' };
341
362
  }
342
- const projectForSpec = _resolveProjectForCwd(
363
+ const projectForSpec = opts.project || _resolveProjectForCwd(
343
364
  typeof spec.cwd === 'string' ? spec.cwd : '',
344
365
  opts && Array.isArray(opts.projects) ? opts.projects : null,
345
366
  );
@@ -411,7 +432,7 @@ function _validateSpec(spec, index, limits, opts) {
411
432
  name: spec.name,
412
433
  cmd: spec.cmd,
413
434
  args: args,
414
- cwd: typeof spec.cwd === 'string' ? spec.cwd : '',
435
+ cwd: normalizedCwd,
415
436
  env: env,
416
437
  ports: ports,
417
438
  ttl_minutes: ttlMinutes,
@@ -585,7 +606,7 @@ function buildManagedSpawnHint(opts) {
585
606
  ' "name": "constellation-host",',
586
607
  ' "cmd": "bun",',
587
608
  ' "args": ["run", "dev"],',
588
- ' "cwd": "D:/repos/constellation",',
609
+ ' "cwd": "<absolute path at or below MINIONS_AGENT_CWD>",',
589
610
  ' "env": { "VITE_HOST": "127.0.0.1" },',
590
611
  ' "ports": [3001],',
591
612
  ' "ttl_minutes": ' + ttl + ',',
@@ -624,6 +645,7 @@ function buildManagedSpawnHint(opts) {
624
645
  '- Ports: 1024–65535, ≤ 20 per spec',
625
646
  '- TTL: ≤ ' + maxTtl + ' minutes (hard cap), defaults to ' + defaultTtl + ' if omitted',
626
647
  '- `attrs` serialized: ≤ 2048 bytes (opaque blob the engine surfaces to downstream agents)',
648
+ '- `cwd`: must resolve at or below this dispatch\'s `MINIONS_AGENT_CWD`. A worktree-mode project\'s operator checkout is rejected even though it is a valid Git checkout.',
627
649
  '',
628
650
  '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.',
629
651
  '',
@@ -875,6 +897,9 @@ function _toStateRecord(spec, runtime, ctx) {
875
897
  owner_agent: (ctx && ctx.owner_agent) || '',
876
898
  owner_wi: (ctx && ctx.owner_wi) || '',
877
899
  owner_project: (ctx && ctx.owner_project) || '',
900
+ owner_work_type: (ctx && ctx.owner_work_type) || '',
901
+ dispatch_root: (ctx && ctx.dispatch_root) || '',
902
+ checkout_mode: (ctx && ctx.checkout_mode) || '',
878
903
  cmd: spec.cmd,
879
904
  args: Array.isArray(spec.args) ? spec.args.slice() : [],
880
905
  cwd: typeof spec.cwd === 'string' ? spec.cwd : '',
@@ -1265,7 +1290,69 @@ function computeManagedSpecsEtag(opts) {
1265
1290
  return crypto.createHash('sha1').update(json).digest('hex').slice(0, 16);
1266
1291
  }
1267
1292
 
1268
- function restartManagedSpec(name) {
1293
+ function validateManagedSpecExecutionContext(record, opts) {
1294
+ opts = opts || {};
1295
+ if (!record || typeof record.cwd !== 'string' || !record.cwd) {
1296
+ return { ok: false, reason: 'cwd-missing' };
1297
+ }
1298
+ let projects;
1299
+ try {
1300
+ projects = Array.isArray(opts.projects) ? opts.projects : shared.getProjects(opts.config);
1301
+ } catch {
1302
+ return { ok: false, reason: 'project-config-unavailable' };
1303
+ }
1304
+ const project = record.owner_project
1305
+ ? shared.findProjectByName(projects, record.owner_project)
1306
+ : null;
1307
+ for (const candidate of projects) {
1308
+ if (!candidate?.localPath) continue;
1309
+ const candidateMode = shared.resolveCheckoutMode(
1310
+ candidate,
1311
+ record.owner_work_type || shared.WORK_TYPE.IMPLEMENT,
1312
+ );
1313
+ if (candidateMode !== shared.CHECKOUT_MODES.WORKTREE) continue;
1314
+ try {
1315
+ if (shared.pathsOverlap(record.cwd, candidate.localPath)) {
1316
+ return { ok: false, reason: 'cwd-overlaps-worktree-mode-operator-checkout' };
1317
+ }
1318
+ } catch {
1319
+ return { ok: false, reason: 'cwd-overlap-check-failed' };
1320
+ }
1321
+ }
1322
+ const mode = project
1323
+ ? shared.resolveCheckoutMode(project, record.owner_work_type || shared.WORK_TYPE.IMPLEMENT)
1324
+ : shared.CHECKOUT_MODES.WORKTREE;
1325
+ if (project && mode === shared.CHECKOUT_MODES.LIVE) {
1326
+ if (record.dispatch_root && !shared.sameResolvedPath(record.dispatch_root, project.localPath)) {
1327
+ return { ok: false, reason: 'live-dispatch-root-mismatch' };
1328
+ }
1329
+ try {
1330
+ if (!shared.isPathInsideOrEqual(record.cwd, project.localPath)) {
1331
+ return { ok: false, reason: 'live-cwd-outside-project' };
1332
+ }
1333
+ } catch {
1334
+ return { ok: false, reason: 'live-cwd-unavailable' };
1335
+ }
1336
+ return { ok: true, project, mode };
1337
+ }
1338
+
1339
+ if (!record.dispatch_root) {
1340
+ return record.checkout_mode
1341
+ ? { ok: false, reason: 'dispatch-root-missing' }
1342
+ : { ok: true, legacy: true, project, mode };
1343
+ }
1344
+ try {
1345
+ shared.assertWorktreeOutsideProjects(record.dispatch_root, projects);
1346
+ if (!shared.isPathInsideOrEqual(record.cwd, record.dispatch_root)) {
1347
+ return { ok: false, reason: 'cwd-outside-dispatch-root' };
1348
+ }
1349
+ } catch {
1350
+ return { ok: false, reason: 'dispatch-root-inside-operator-checkout' };
1351
+ }
1352
+ return { ok: true, project, mode };
1353
+ }
1354
+
1355
+ function restartManagedSpec(name, opts) {
1269
1356
  if (typeof name !== 'string' || name.length === 0) {
1270
1357
  throw new Error('restartManagedSpec: name required');
1271
1358
  }
@@ -1273,6 +1360,11 @@ function restartManagedSpec(name) {
1273
1360
  if (!existing) {
1274
1361
  throw new Error('restartManagedSpec: spec not found: ' + name);
1275
1362
  }
1363
+ const executionContext = validateManagedSpecExecutionContext(existing, opts);
1364
+ if (!executionContext.ok) {
1365
+ try { removeManagedSpec(name); } catch {}
1366
+ throw new Error('restartManagedSpec: unsafe persisted execution context (' + executionContext.reason + ')');
1367
+ }
1276
1368
  // Reconstruct a sidecar-shaped spec from the persisted state row. The
1277
1369
  // state shape keeps cmd/args/cwd/env/ports/attrs/healthcheck verbatim
1278
1370
  // (see _toStateRecord), so this is a direct projection.
@@ -1299,6 +1391,9 @@ function restartManagedSpec(name) {
1299
1391
  owner_agent: existing.owner_agent || '',
1300
1392
  owner_wi: existing.owner_wi || '',
1301
1393
  owner_project: existing.owner_project || '',
1394
+ owner_work_type: existing.owner_work_type || '',
1395
+ dispatch_root: existing.dispatch_root || '',
1396
+ checkout_mode: existing.checkout_mode || '',
1302
1397
  };
1303
1398
  const runtime = spawnManagedSpec(spec, ctx);
1304
1399
  // recordManagedSpec replaces by name (item 2 idempotency contract) and
@@ -1380,6 +1475,12 @@ function _runManagedReconcile(opts) {
1380
1475
  const killBatch = typeof opts.killBatch === 'function'
1381
1476
  ? opts.killBatch
1382
1477
  : shared.killByPidsImmediate;
1478
+ let projects;
1479
+ try {
1480
+ projects = Array.isArray(opts.projects) ? opts.projects : shared.getProjects(opts.config);
1481
+ } catch {
1482
+ projects = [];
1483
+ }
1383
1484
  const stats = {
1384
1485
  scanned: 0,
1385
1486
  ttlExpired: 0,
@@ -1413,6 +1514,11 @@ function _runManagedReconcile(opts) {
1413
1514
  stats.deadDropped++;
1414
1515
  continue; // dead + not expired → drop
1415
1516
  }
1517
+ const executionContext = validateManagedSpecExecutionContext(rec, { projects });
1518
+ if (!executionContext.ok) {
1519
+ ttlPidsToKill.push(rec.pid);
1520
+ continue;
1521
+ }
1416
1522
  kept.push(rec);
1417
1523
  survivors.push({ name: rec.name, log_path: rec.log_path || '', healthy: rec.healthy === true, last_health_at: rec.last_health_at || 0 });
1418
1524
  }
@@ -1550,6 +1656,7 @@ module.exports = {
1550
1656
  // Item 4 (P-4b8d2e57): discovery API (etag + state-driven respawn).
1551
1657
  computeManagedSpecsEtag: computeManagedSpecsEtag,
1552
1658
  restartManagedSpec: restartManagedSpec,
1659
+ validateManagedSpecExecutionContext: validateManagedSpecExecutionContext,
1553
1660
  // Item 7 (P-8a4d6f29): TTL sweep + boot reconcile + project cleanup.
1554
1661
  sweepManagedSpawn: sweepManagedSpawn,
1555
1662
  bootReconcileManagedSpawn: bootReconcileManagedSpawn,