@yemi33/minions 0.1.2179 → 0.1.2180

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/engine/shared.js CHANGED
@@ -2699,6 +2699,36 @@ const ENGINE_DEFAULTS = {
2699
2699
  claudeFallbackModel: undefined,// Claude --fallback-model — Claude CLI honors this on rate-limit (429) only; engine retry on FAILURE_CLASS.MODEL_UNAVAILABLE keeps the flag passed so the CLI can swap internally
2700
2700
  copilotFallbackModel: undefined,// W-mpg6isvy000xca4d: Copilot has no --fallback-model flag; engine retry on FAILURE_CLASS.MODEL_UNAVAILABLE OVERRIDES --model directly with this value (separate knob from claudeFallbackModel because model namespaces differ across runtimes)
2701
2701
  copilotDisableBuiltinMcps: true, // Copilot --disable-builtin-mcps: keep github-mcp-server out so it can't bypass pull-requests.json tracking
2702
+ // P-7d31a06b — Claude pre-approves workspace .mcp.json servers in ~/.claude.json on every spawn into a fresh
2703
+ // worktree cwd. Without this, the first Claude invocation in a brand-new worktree shows a project-scoped MCP
2704
+ // "trust this server?" prompt that blocks until a human accepts — invisible behind --dangerously-skip-permissions
2705
+ // so the agent silently runs without the workspace MCPs. Pre-warming
2706
+ // projects.<worktreePath>.enabledMcpjsonServers (the field Claude Code actually reads — see live ~/.claude.json
2707
+ // shape) means the workspace MCPs are connected on first call. Best-effort: failures NEVER block dispatch.
2708
+ // Engine-scoped per the CLAUDE.md rule against `runtime.name === ...` branches at call sites — the runtime check
2709
+ // lives inside the helper (engine/spawn-agent.js#preApproveWorkspaceMcps) which no-ops for non-Claude runtimes.
2710
+ claudePreApproveWorkspaceMcps: true,
2711
+ // P-08b62d49 — propagate project-local-on-main harness dirs (uncommitted
2712
+ // <repo>/.claude/skills, <repo>/.copilot/commands, etc.) into the worktree
2713
+ // dispatch via additional `--add-dir` entries. Defaults TRUE so the
2714
+ // worktree-uncommitted footgun described in docs/harness-propagation.md is
2715
+ // closed by default. Set to false to fall back to the legacy
2716
+ // "only committed assets are visible" behavior.
2717
+ harnessPropagateProjectLocal: true,
2718
+ // P-49e1c8b7 — hermetic harness opt-out. When TRUE the spawned agent runs
2719
+ // with a known-empty harness surface around the worktree:
2720
+ // - `--add-dir` is exactly `[minionsDir]` (user-scope skill/command/MCP
2721
+ // roots from `runtime.getUserAssetDirs(...)` are dropped);
2722
+ // - project-local-on-main harness propagation (`harnessPropagateProjectLocal`)
2723
+ // is skipped — no `--project-harness-dir` flags emitted;
2724
+ // - Claude workspace `.mcp.json` pre-approval (`claudePreApproveWorkspaceMcps`)
2725
+ // is skipped.
2726
+ // Independent of `copilotDisableBuiltinMcps` and `copilotSuppressAgentsMd` —
2727
+ // those keep their existing semantics so operators can mix-and-match. Use
2728
+ // per-agent override `agent.hermeticHarness` for targeted runs. Resolved
2729
+ // through `shared.resolveAgentHermeticHarness(agent, engine)` (mirrors
2730
+ // `resolveAgentBareMode`).
2731
+ hermeticHarness: false,
2702
2732
  copilotSuppressAgentsMd: true, // Copilot --no-custom-instructions: stop AGENTS.md auto-load from fighting Minions playbook prompts
2703
2733
  copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
2704
2734
  copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
@@ -3151,6 +3181,32 @@ function resolveCopilotAgentDisabledMcpServers(agent, engine) {
3151
3181
  return [];
3152
3182
  }
3153
3183
 
3184
+ /**
3185
+ * P-49e1c8b7 — Resolve whether this agent should run with a hermetic harness.
3186
+ * Priority (mirrors `resolveAgentBareMode`):
3187
+ * 1. `agent.hermeticHarness` — per-agent override (boolean)
3188
+ * 2. `engine.hermeticHarness` — fleet default
3189
+ * 3. `false` — hardcoded fallback
3190
+ *
3191
+ * Strict undefined/null check (not falsy) so a per-agent `false` correctly
3192
+ * overrides an engine `true`. Truthy/falsy non-bool values are coerced via
3193
+ * `!!` for config tolerance.
3194
+ *
3195
+ * When TRUE, engine.js (a) skips Claude workspace .mcp.json pre-approval,
3196
+ * (b) skips project-local-on-main `--project-harness-dir` propagation, and
3197
+ * (c) forwards `--hermetic-harness` to spawn-agent.js so `computeAddDirs`
3198
+ * returns `[minionsDir]` only (user-scope skill/command/MCP roots stripped).
3199
+ * Independent of `copilotDisableBuiltinMcps` and `copilotSuppressAgentsMd`.
3200
+ */
3201
+ function resolveAgentHermeticHarness(agent, engine) {
3202
+ const a = agent ? agent.hermeticHarness : undefined;
3203
+ if (a !== undefined && a !== null) return !!a;
3204
+ const e = engine ? engine.hermeticHarness : undefined;
3205
+ if (e !== undefined && e !== null) return !!e;
3206
+ return false;
3207
+ }
3208
+
3209
+
3154
3210
  // ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
3155
3211
  //
3156
3212
  // Pre-P-3b8e5f1d, `engine.ccModel` was the single fleet-wide model knob (it
@@ -4021,6 +4077,7 @@ const FAILURE_CLASS = {
4021
4077
  MANAGED_SPAWN_HEALTHCHECK_FAILED: 'managed-spawn-healthcheck-failed', // P-7a3b1c92: at least one managed-spawn spec was spawned but failed its healthcheck within timeout_s. Engine killed the failing PIDs; siblings stay alive. Dispatch ERROR with the failing spec name + log tail surfaced in the inbox alert.
4022
4078
  INJECTION_FLAGGED: 'injection-flagged', // F5 (W-mpeklod3000we69c): the agent set `securityFlags.injectionAttempt:true` in its completion report after spotting a prompt-injection attempt inside an <UNTRUSTED-INPUT> fence. Engine writes a security inbox note + stamps `_securityFlag` on the WI and treats the dispatch as non-retryable so a human can review the source before the agent re-runs.
4023
4079
  LIVE_CHECKOUT_DIRTY: 'live-checkout-dirty', // P-a3f9b204 (live-checkout dispatch mode): spawnAgent ran prepareLiveCheckout against project.localPath and `git status --porcelain` reported uncommitted changes. Engine refused to spawn (it never runs `git reset`/`git clean` against the operator tree). Inbox alert lists dirty files; WI is stamped `_pendingReason: 'live_checkout_dirty'`. Non-retryable — operator must commit/stash/discard before re-dispatch.
4080
+ INVALID_WORKDIR: 'invalid-workdir', // P-714ef144: dispatch carried a meta.workdir override that failed validation — non-string, absolute path, drive-letter prefix, null byte, ".." segment, or post-resolve containment escape against project.localPath / worktree root. Engine refuses to spawn (the subpath would either be unreachable on disk or point outside the operator's allowed surface). Non-retryable — operator must fix the WI's meta.workdir before re-dispatch. Inbox alert lists the offending value + the resolved-vs-base mismatch.
4024
4081
  MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
4025
4082
  WORKSPACE_MANIFEST_REPO: 'workspace-manifest-repo-forbidden', // W-mq07avbk000m5543: dispatch routed an agent to a project/repo not present in its workspace_manifest.allowed_repos. Structural — never retryable until the manifest is widened or a different agent is chosen.
4026
4083
  WORKSPACE_MANIFEST_TOOL: 'workspace-manifest-tool-forbidden', // W-mq07avbk000m5543: out-of-scope tool call (manifest enforcement at the runtime gate). Non-retryable as-is.
@@ -5278,21 +5335,47 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
5278
5335
  * `LIVE_CHECKOUT_NO_LOCALPATH` if `localPath` is missing/falsy — live mode
5279
5336
  * has no fallback because there is no neutral location to dispatch into.
5280
5337
  *
5338
+ * **Workdir override (P-714ef144).** When `options.workdir` is a non-empty
5339
+ * relative POSIX subpath (already validated by `validateWorkItemWorkdir`),
5340
+ * the resolver joins it onto the spawn cwd:
5341
+ *
5342
+ * - live mode: cwd = path.join(localPath, workdir)
5343
+ * - read-only mode: cwd = path.join(<base>, workdir)
5344
+ * - mutating mode: cwd stays null (worktree doesn't exist yet); the
5345
+ * normalized workdir is echoed back as result.workdir so spawnAgent
5346
+ * can apply `shared.applyWorkdir(worktreePath, workdir)` after
5347
+ * `git worktree add` succeeds.
5348
+ *
5349
+ * Live and read-only branches run the post-resolve containment guard via
5350
+ * `applyWorkdir` and throw an Error with `code: 'INVALID_WORKDIR'` if the
5351
+ * resolved cwd escapes its base (defense-in-depth — validator already
5352
+ * rejects ".." / absolute paths, but symlink-style attacks can only be
5353
+ * caught post-resolve). The mutating containment check happens later in
5354
+ * spawnAgent, against the actual worktree path.
5355
+ *
5281
5356
  * @param {{ localPath?: string|null, worktreeMode?: string|null }|null|undefined} project
5282
5357
  * @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
5283
5358
  * @param {string} minionsDir — MINIONS_DIR fallback anchor (ignored in live mode)
5284
- * @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean }}
5285
- * - For live mode (any type): { cwd: <abs localPath>, worktreeRootDir: null, liveMode: true }
5286
- * - For isolated read-only types: { cwd: <project dir or MINIONS_DIR>, worktreeRootDir: null }
5287
- * - For isolated code-mutating types: { cwd: null, worktreeRootDir: <project root> }
5288
- * (caller defaults cwd to worktreeRootDir before worktree creation)
5359
+ * @param {{ workdir?: string|null }} [options] — optional per-WI overrides
5360
+ * @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean, workdir?: string|null }}
5361
+ * - For live mode (any type): { cwd: <abs localPath[/workdir]>, worktreeRootDir: null, liveMode: true }
5362
+ * - For isolated read-only types: { cwd: <project dir[/workdir] or MINIONS_DIR>, worktreeRootDir: null }
5363
+ * - For isolated code-mutating types: { cwd: null, worktreeRootDir: <project root>, workdir: <subpath|null> }
5364
+ * (caller defaults cwd to worktreeRootDir before worktree creation,
5365
+ * then runs shared.applyWorkdir(worktreePath, result.workdir) to land
5366
+ * the agent inside the subpackage cwd)
5289
5367
  * The optional `liveMode` discriminator lets callers branch on one boolean
5290
5368
  * instead of re-reading `project.worktreeMode`.
5291
5369
  * @throws {Error} LIVE_CHECKOUT_NO_LOCALPATH (live mode, missing localPath),
5370
+ * INVALID_WORKDIR (live or read-only, workdir escapes base),
5292
5371
  * WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT (isolated code-mutating),
5293
5372
  * or WORKTREE_ROOTDIR_MISSING_BASE if neither anchor present.
5294
5373
  */
5295
- function resolveSpawnPaths(project, type, minionsDir) {
5374
+ function resolveSpawnPaths(project, type, minionsDir, options) {
5375
+ const workdir = (options && typeof options === 'object' && typeof options.workdir === 'string' && options.workdir)
5376
+ ? options.workdir
5377
+ : null;
5378
+
5296
5379
  // ── Live-checkout mode short-circuit (P-a3f9b202) ──────────────────────
5297
5380
  // Runs BEFORE the read-only / code-mutating split so live mode is the
5298
5381
  // single decision point regardless of task type — read-only tasks in
@@ -5306,8 +5389,10 @@ function resolveSpawnPaths(project, type, minionsDir) {
5306
5389
  err.code = 'LIVE_CHECKOUT_NO_LOCALPATH';
5307
5390
  throw err;
5308
5391
  }
5392
+ const baseLive = path.resolve(String(project.localPath));
5393
+ const liveCwd = _applyWorkdirOrThrow(baseLive, workdir);
5309
5394
  return {
5310
- cwd: path.resolve(String(project.localPath)),
5395
+ cwd: liveCwd,
5311
5396
  worktreeRootDir: null,
5312
5397
  liveMode: true,
5313
5398
  };
@@ -5315,14 +5400,189 @@ function resolveSpawnPaths(project, type, minionsDir) {
5315
5400
 
5316
5401
  const isReadOnly = READ_ONLY_ROOT_TASK_TYPES.has(type);
5317
5402
  if (isReadOnly) {
5318
- if (project?.localPath) return { cwd: path.resolve(String(project.localPath)), worktreeRootDir: null };
5319
- if (minionsDir) return { cwd: path.resolve(String(minionsDir)), worktreeRootDir: null };
5320
- const err = new Error('Cannot resolve cwd for read-only spawn: no project.localPath and no MINIONS_DIR provided.');
5321
- err.code = 'WORKTREE_ROOTDIR_MISSING_BASE';
5322
- throw err;
5403
+ let base = null;
5404
+ if (project?.localPath) base = path.resolve(String(project.localPath));
5405
+ else if (minionsDir) base = path.resolve(String(minionsDir));
5406
+ else {
5407
+ const err = new Error('Cannot resolve cwd for read-only spawn: no project.localPath and no MINIONS_DIR provided.');
5408
+ err.code = 'WORKTREE_ROOTDIR_MISSING_BASE';
5409
+ throw err;
5410
+ }
5411
+ const roCwd = _applyWorkdirOrThrow(base, workdir);
5412
+ return { cwd: roCwd, worktreeRootDir: null };
5323
5413
  }
5324
5414
  const worktreeRootDir = resolveProjectRootDir(project?.localPath, minionsDir);
5325
- return { cwd: null, worktreeRootDir };
5415
+ // Mutating types: workdir is echoed back UNAPPLIED. spawnAgent calls
5416
+ // `shared.applyWorkdir(worktreePath, workdir)` after `git worktree add`
5417
+ // succeeds, which is the only time the actual base path exists on disk.
5418
+ return { cwd: null, worktreeRootDir, workdir };
5419
+ }
5420
+
5421
+ // ── meta.workdir helpers (P-714ef144) ────────────────────────────────────
5422
+ //
5423
+ // Per-WI `meta.workdir` is a relative POSIX subpath under the spawn base
5424
+ // (project.localPath for read-only / live; worktree root for mutating).
5425
+ // When set, the engine lands the agent at <base>/<workdir> so monorepo
5426
+ // subpackage dispatches can use the runtime CLI's native cwd-rooted skill
5427
+ // discovery (`<workdir>/.claude/skills/<name>/SKILL.md`) without engine
5428
+ // awareness of which package is which. Cross-package skill aggregation is
5429
+ // explicitly out of scope (per PRD open_question) — when workdir is set,
5430
+ // the harness propagation block in engine.js also CLIPS its candidate
5431
+ // `--project-harness-dir` set to dirs that live under the workdir mirror
5432
+ // in `project.localPath`, so a `packages/foo` dispatch never sees
5433
+ // `packages/bar/.claude/skills/...`.
5434
+ //
5435
+ // Three pure helpers, all unit-tested in test/unit/work-item-workdir.test.js:
5436
+ //
5437
+ // 1. validateWorkItemWorkdir(value) — input validation for the
5438
+ // dashboard.js POST /api/work-items handler. Returns
5439
+ // `{ valid: true, value: null }` for empty/unset (back-compat
5440
+ // default), `{ valid: true, value: <normalized POSIX subpath> }`
5441
+ // for a clean subpath, or `{ valid: false, error: <human reason> }`
5442
+ // for any rejection. The normalized form has backslashes folded to
5443
+ // `/`, trims surrounding whitespace, drops leading `./` and trailing
5444
+ // `/`, and rejects `..` segments, absolute paths (POSIX or Windows
5445
+ // drive-letter), null bytes, and non-string types.
5446
+ //
5447
+ // 2. applyWorkdir(base, workdir) — pure join + containment guard.
5448
+ // Returns `{ cwd: <resolved absolute path>, error?: <reason> }`. The
5449
+ // `error` field is set when the resolved cwd would escape `base`
5450
+ // (defense-in-depth against symlink-style attacks; the validator
5451
+ // already rejects bare `..` / absolute strings). `base` MUST be
5452
+ // absolute. Empty/null workdir returns the base unchanged.
5453
+ //
5454
+ // 3. filterProjectHarnessDirsForWorkdir(dirs, opts) — clips the
5455
+ // candidate `--project-harness-dir` set to entries under the resolved
5456
+ // cwd anchor in `project.localPath`, excluding entries under the
5457
+ // worktree mirror (already discoverable via the agent's cwd). When
5458
+ // `workdir` is null/empty the behavior matches P-08b62d49 unchanged
5459
+ // (filter to project.localPath, exclude worktree).
5460
+ //
5461
+ // The private `_applyWorkdirOrThrow(base, workdir)` is the throw-on-error
5462
+ // adapter used inside resolveSpawnPaths so live/read-only callers don't
5463
+ // need to forward the `error` field.
5464
+
5465
+ function _applyWorkdirOrThrow(base, workdir) {
5466
+ if (!workdir) return base;
5467
+ const r = applyWorkdir(base, workdir);
5468
+ if (r.error) {
5469
+ const err = new Error(r.error);
5470
+ err.code = 'INVALID_WORKDIR';
5471
+ err.workdir = workdir;
5472
+ err.base = base;
5473
+ throw err;
5474
+ }
5475
+ return r.cwd;
5476
+ }
5477
+
5478
+ function validateWorkItemWorkdir(value) {
5479
+ // Unset / empty → null (back-compat default, today's project-root behavior)
5480
+ if (value === undefined || value === null) return { valid: true, value: null };
5481
+ if (typeof value !== 'string') {
5482
+ return { valid: false, error: 'meta.workdir must be a string (got ' + typeof value + ')' };
5483
+ }
5484
+ const trimmed = value.trim();
5485
+ if (!trimmed) return { valid: true, value: null };
5486
+
5487
+ // Null bytes — common path-injection signal; refuse defensively.
5488
+ if (trimmed.indexOf('\u0000') !== -1) {
5489
+ return { valid: false, error: 'meta.workdir must not contain null bytes' };
5490
+ }
5491
+
5492
+ // Windows drive-letter prefix (e.g. "C:\\Windows", "D:foo") — even when
5493
+ // the path looks "inside" the project, it's an absolute reference.
5494
+ if (/^[A-Za-z]:[\\/]?/.test(trimmed)) {
5495
+ return { valid: false, error: 'meta.workdir must be a relative POSIX subpath, not a Windows drive path (got "' + value + '")' };
5496
+ }
5497
+
5498
+ // POSIX absolute / UNC / backslash-absolute.
5499
+ if (path.isAbsolute(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) {
5500
+ return { valid: false, error: 'meta.workdir must be a relative path, not absolute (got "' + value + '")' };
5501
+ }
5502
+
5503
+ // ".." segments — pre-resolve guard. applyWorkdir runs the post-resolve
5504
+ // containment check as defense-in-depth, but rejecting `..` here gives
5505
+ // operators a clearer error message at the dashboard validation site.
5506
+ const segments = trimmed.split(/[\\/]+/);
5507
+ if (segments.some(s => s === '..')) {
5508
+ return { valid: false, error: 'meta.workdir may not contain ".." segments (escape attempt blocked)' };
5509
+ }
5510
+
5511
+ // Normalize: backslashes → forward slashes, drop leading "./", strip
5512
+ // trailing slashes. Preserve "." segments (filtered out by path.join later).
5513
+ let normalized = trimmed.replace(/\\/g, '/');
5514
+ while (normalized.startsWith('./')) normalized = normalized.slice(2);
5515
+ normalized = normalized.replace(/\/+$/, '');
5516
+ if (!normalized) return { valid: true, value: null };
5517
+
5518
+ return { valid: true, value: normalized };
5519
+ }
5520
+
5521
+ function applyWorkdir(base, workdir) {
5522
+ if (workdir === null || workdir === undefined || workdir === '') {
5523
+ return { cwd: base };
5524
+ }
5525
+ if (typeof workdir !== 'string') {
5526
+ return { cwd: base, error: 'workdir must be a string (got ' + typeof workdir + ')' };
5527
+ }
5528
+ // Refuse absolute workdir even if it'd technically resolve inside — only
5529
+ // relative subpaths are allowed. This catches the "operator pasted an
5530
+ // absolute path" authoring error before path.join silently swaps the base.
5531
+ if (path.isAbsolute(workdir) || /^[A-Za-z]:[\\/]?/.test(workdir) || workdir.startsWith('/') || workdir.startsWith('\\')) {
5532
+ return { cwd: base, error: 'workdir must be relative, not absolute (got "' + workdir + '")' };
5533
+ }
5534
+ const baseAbs = path.resolve(String(base));
5535
+ const joined = path.resolve(baseAbs, workdir);
5536
+ // Containment guard: resolved cwd must equal base or live strictly inside.
5537
+ if (joined !== baseAbs && !joined.startsWith(baseAbs + path.sep)) {
5538
+ return {
5539
+ cwd: baseAbs,
5540
+ error: 'workdir resolved outside base (containment escape): "' + workdir + '" → "' + joined + '" not inside "' + baseAbs + '"',
5541
+ };
5542
+ }
5543
+ return { cwd: joined };
5544
+ }
5545
+
5546
+ function filterProjectHarnessDirsForWorkdir(dirs, opts) {
5547
+ if (!Array.isArray(dirs) || dirs.length === 0) return [];
5548
+ const projectLocalPath = opts && opts.projectLocalPath;
5549
+ const worktreePath = opts && opts.worktreePath;
5550
+ const workdir = opts && opts.workdir;
5551
+ if (!projectLocalPath || !worktreePath) return [];
5552
+
5553
+ const projectLocalAbs = path.resolve(String(projectLocalPath));
5554
+ const worktreeAbs = path.resolve(String(worktreePath));
5555
+
5556
+ // When workdir is set, the harness propagation anchors shift to the
5557
+ // subpath mirror in BOTH the project root and the worktree root, so a
5558
+ // `packages/foo` dispatch only sees `<projectLocal>/packages/foo/...`
5559
+ // harness dirs (its actual cwd in the operator's main checkout) and
5560
+ // excludes `<worktree>/packages/foo/...` (already discoverable via cwd).
5561
+ let projectAnchor = projectLocalAbs;
5562
+ let worktreeAnchor = worktreeAbs;
5563
+ if (workdir && typeof workdir === 'string' && workdir) {
5564
+ const pr = applyWorkdir(projectLocalAbs, workdir);
5565
+ const wr = applyWorkdir(worktreeAbs, workdir);
5566
+ // If either join fails containment, drop everything — better empty than wrong.
5567
+ if (pr.error || wr.error) return [];
5568
+ projectAnchor = pr.cwd;
5569
+ worktreeAnchor = wr.cwd;
5570
+ }
5571
+
5572
+ const seen = new Set();
5573
+ const out = [];
5574
+ for (const dir of dirs) {
5575
+ if (typeof dir !== 'string' || !dir) continue;
5576
+ const abs = path.resolve(dir);
5577
+ if (seen.has(abs)) continue;
5578
+ seen.add(abs);
5579
+ const insideProject = abs === projectAnchor || abs.startsWith(projectAnchor + path.sep);
5580
+ if (!insideProject) continue;
5581
+ const insideWorktree = abs === worktreeAnchor || abs.startsWith(worktreeAnchor + path.sep);
5582
+ if (insideWorktree) continue;
5583
+ out.push(abs);
5584
+ }
5585
+ return out;
5326
5586
  }
5327
5587
 
5328
5588
  // ── HTTP Origin Allowlist & Security Headers ─────────────────────────────────
@@ -7805,6 +8065,7 @@ module.exports = {
7805
8065
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
7806
8066
  resolveAgentMaxBudget, resolveAgentBareMode,
7807
8067
  resolveCopilotAgentDisabledMcpServers,
8068
+ resolveAgentHermeticHarness,
7808
8069
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
7809
8070
  applyCcWorkerPoolForceOnMigration,
7810
8071
  runtimeConfigWarnings,
@@ -7897,6 +8158,9 @@ module.exports = {
7897
8158
  assertWorktreeOutsideProject,
7898
8159
  resolveProjectRootDir,
7899
8160
  resolveSpawnPaths,
8161
+ validateWorkItemWorkdir,
8162
+ applyWorkdir,
8163
+ filterProjectHarnessDirsForWorkdir,
7900
8164
  READ_ONLY_ROOT_TASK_TYPES,
7901
8165
  isLiveCommandCenterPath,
7902
8166
  describeCcProtectedPaths,
@@ -55,6 +55,15 @@ function parseSpawnArgs(argv) {
55
55
  let runtimeName = 'claude';
56
56
  const opts = {};
57
57
  const passthrough = [];
58
+ // P-08b62d49 — accumulating array of project-local-on-main harness dirs that
59
+ // engine.js wants `computeAddDirs` to surface as additional `--add-dir`
60
+ // entries. Multiple `--project-harness-dir <path>` flags accumulate in
61
+ // declaration order; the dedup/exists filter happens inside `computeAddDirs`.
62
+ const projectHarnessDirs = [];
63
+ // P-49e1c8b7 — hermetic harness opt-out (boolean). Set by engine.js when
64
+ // shared.resolveAgentHermeticHarness(agent, engine) is true; propagates to
65
+ // computeAddDirs which then returns [minionsDir] only.
66
+ let hermetic = false;
58
67
 
59
68
  for (let i = 2; i < args.length; i++) {
60
69
  const a = args[i];
@@ -77,6 +86,17 @@ function parseSpawnArgs(argv) {
77
86
  case '--disable-builtin-mcps': opts.disableBuiltinMcps = true; break;
78
87
  case '--no-custom-instructions': opts.suppressAgentsMd = true; break;
79
88
  case '--enable-reasoning-summaries': opts.reasoningSummaries = true; break;
89
+ case '--project-harness-dir': {
90
+ const v = peek();
91
+ if (typeof v === 'string' && v) projectHarnessDirs.push(v);
92
+ break;
93
+ }
94
+ // P-49e1c8b7 — hermetic harness opt-out. When set, computeAddDirs in
95
+ // main() returns [minionsDir] only (user-scope asset dirs from
96
+ // runtime.getUserAssetDirs AND any forwarded --project-harness-dir
97
+ // entries are dropped). Engine.js gates this flag via
98
+ // shared.resolveAgentHermeticHarness(agent, engine).
99
+ case '--hermetic-harness': hermetic = true; break;
80
100
  // LEGACY: dropped — the runtime adapter emits its own permission flag.
81
101
  // Pre-P-2a6d9c4f engine.js still passes `--permission-mode bypassPermissions`;
82
102
  // letting it through would duplicate the permission flag for Claude.
@@ -85,7 +105,7 @@ function parseSpawnArgs(argv) {
85
105
  }
86
106
  }
87
107
 
88
- return { promptFile, sysPromptFile, runtimeName, opts, passthrough };
108
+ return { promptFile, sysPromptFile, runtimeName, opts, passthrough, projectHarnessDirs, hermetic };
89
109
  }
90
110
 
91
111
  /**
@@ -299,18 +319,39 @@ function createParentPipeForwarder(stream, outputPath, prefix = '') {
299
319
 
300
320
  /**
301
321
  * Build the `--add-dir` list passed to the runtime CLI. Pure: takes
302
- * `{ runtime, minionsDir, homeDir, exists }` and returns an ordered, deduped
303
- * array of dirs the agent should be able to read from outside its worktree.
322
+ * `{ runtime, minionsDir, homeDir, projectHarnessDirs, hermetic, exists }`
323
+ * and returns an ordered, deduped array of dirs the agent should be able to
324
+ * read from outside its worktree.
325
+ *
326
+ * Order:
327
+ * 1. minionsDir (always first, so playbooks/system-prompt are reachable).
328
+ * 2. Every existing dir from `runtime.getUserAssetDirs({ homeDir })` —
329
+ * user-scope skill/command/MCP roots (`~/.claude`, `~/.copilot`,
330
+ * `~/.agents`, …) so worktree-bound agents inherit user-installed
331
+ * harnesses.
332
+ * 3. P-08b62d49 — Every existing dir in `projectHarnessDirs` (engine.js
333
+ * passes the union of `queries.getProjectHarnesses(project).{skills,
334
+ * commands}` dirs that live in `project.localPath` but NOT under the
335
+ * worktree, so uncommitted `<repo>/.claude/skills/…` etc. on the main
336
+ * checkout are reachable from worktree dispatches). See
337
+ * `docs/harness-propagation.md` → "The worktree-uncommitted footgun".
304
338
  *
305
- * Order: minionsDir first (so playbooks/system-prompt are always reachable),
306
- * followed by every existing dir from `runtime.getUserAssetDirs({ homeDir })`.
307
- * Non-existent asset dirs are dropped — Claude CLI rejects unknown `--add-dir`
308
- * entries. The dedup compares resolved paths so we never emit minionsDir twice
309
- * (e.g. when runtime asset dir IS the minions repo in unusual setups).
339
+ * Non-existent dirs are dropped at every layer — Claude CLI rejects unknown
340
+ * `--add-dir` entries. The dedup compares resolved paths so we never emit a
341
+ * dir twice (e.g. when a runtime asset dir IS the minions repo in unusual
342
+ * setups, or when a project-harness dir duplicates a user-asset dir).
343
+ *
344
+ * P-49e1c8b7 — When `hermetic === true`, the result is exactly `[minionsDir]`:
345
+ * both the runtime adapter's user-asset dirs AND `projectHarnessDirs` are
346
+ * dropped so the spawned agent runs with a known-empty harness surface.
347
+ * Engine.js sets this via `shared.resolveAgentHermeticHarness(agent, engine)`
348
+ * and forwards `--hermetic-harness` to spawn-agent so the flag survives the
349
+ * subprocess hop.
310
350
  *
311
351
  * `exists` is injectable for tests; defaults to `fs.existsSync`.
312
352
  */
313
- function computeAddDirs({ runtime, minionsDir, homeDir, exists = fs.existsSync } = {}) {
353
+ function computeAddDirs({ runtime, minionsDir, homeDir, projectHarnessDirs, hermetic, exists = fs.existsSync } = {}) {
354
+ if (hermetic) return [minionsDir];
314
355
  const out = [minionsDir];
315
356
  const seen = new Set([path.resolve(minionsDir)]);
316
357
  const assetDirs = typeof runtime?.getUserAssetDirs === 'function'
@@ -324,9 +365,121 @@ function computeAddDirs({ runtime, minionsDir, homeDir, exists = fs.existsSync }
324
365
  out.push(d);
325
366
  seen.add(resolved);
326
367
  }
368
+ if (Array.isArray(projectHarnessDirs)) {
369
+ for (const d of projectHarnessDirs) {
370
+ if (!d) continue;
371
+ const resolved = path.resolve(d);
372
+ if (seen.has(resolved)) continue;
373
+ if (!exists(d)) continue;
374
+ out.push(d);
375
+ seen.add(resolved);
376
+ }
377
+ }
327
378
  return out;
328
379
  }
329
380
 
381
+ /**
382
+ * P-7d31a06b — Pre-approve a worktree's `.mcp.json` servers in `~/.claude.json`
383
+ * so Claude Code's first invocation in a fresh worktree cwd doesn't show the
384
+ * project-scoped "trust this MCP server?" prompt (which `--dangerously-skip-permissions`
385
+ * silently suppresses → the agent runs without the workspace MCPs).
386
+ *
387
+ * Writes to `projects[worktreePath].enabledMcpjsonServers` (the field Claude
388
+ * Code actually reads — confirmed against the live `~/.claude.json` schema:
389
+ * `enabledMcpjsonServers` / `disabledMcpjsonServers` are siblings of
390
+ * `mcpServers`, indexed by absolute project path). Notes on the PRD wording
391
+ * "mcpServers.approved": that's the intent label; `enabledMcpjsonServers` is
392
+ * the on-disk reality.
393
+ *
394
+ * Pure-ish: all I/O (fs reads, mutator) is injected for tests. Best-effort —
395
+ * callers MUST swallow errors and never block dispatch on this.
396
+ *
397
+ * Returns `{ wrote: boolean, reason: string, servers: string[] }`:
398
+ * - reason ∈ { 'ok', 'flag-off', 'not-claude', 'no-worktree',
399
+ * 'no-workspace-mcp', 'invalid-workspace-mcp', 'no-servers' }
400
+ * - servers: keys discovered in the workspace `.mcp.json` (regardless of write)
401
+ *
402
+ * Args:
403
+ * runtimeName — `runtime.name` from `resolveRuntime(...)`. Helper no-ops if !== 'claude'.
404
+ * worktreePath — absolute path to the agent's cwd (the worktree). Used as both the file location
405
+ * (`<worktreePath>/.mcp.json`) and the `projects[...]` key in `~/.claude.json`.
406
+ * homeDir — `os.homedir()`. Injected for test isolation; helper does NOT call os.homedir directly.
407
+ * engineConfig — config.engine object; reads `claudePreApproveWorkspaceMcps` (default true via ENGINE_DEFAULTS).
408
+ * mutateJsonFileLocked — injected file-locked mutator (engine/shared.js#mutateJsonFileLocked).
409
+ * existsSync, readFileSync — injectable fs primitives (default to node:fs).
410
+ */
411
+ function preApproveWorkspaceMcps({
412
+ runtimeName,
413
+ worktreePath,
414
+ homeDir,
415
+ engineConfig,
416
+ mutateJsonFileLocked,
417
+ existsSync = fs.existsSync,
418
+ readFileSync = fs.readFileSync,
419
+ } = {}) {
420
+ if (engineConfig && engineConfig.claudePreApproveWorkspaceMcps === false) {
421
+ return { wrote: false, reason: 'flag-off', servers: [] };
422
+ }
423
+ if (runtimeName !== 'claude') {
424
+ return { wrote: false, reason: 'not-claude', servers: [] };
425
+ }
426
+ if (!worktreePath || typeof worktreePath !== 'string') {
427
+ return { wrote: false, reason: 'no-worktree', servers: [] };
428
+ }
429
+ const workspaceMcpPath = path.join(worktreePath, '.mcp.json');
430
+ if (!existsSync(workspaceMcpPath)) {
431
+ return { wrote: false, reason: 'no-workspace-mcp', servers: [] };
432
+ }
433
+ let workspaceMcp;
434
+ try {
435
+ workspaceMcp = JSON.parse(readFileSync(workspaceMcpPath, 'utf8'));
436
+ } catch {
437
+ return { wrote: false, reason: 'invalid-workspace-mcp', servers: [] };
438
+ }
439
+ const serversObj = workspaceMcp && typeof workspaceMcp === 'object' ? workspaceMcp.mcpServers : null;
440
+ const servers = serversObj && typeof serversObj === 'object' && !Array.isArray(serversObj)
441
+ ? Object.keys(serversObj).filter((k) => typeof k === 'string' && k.length > 0)
442
+ : [];
443
+ if (servers.length === 0) {
444
+ return { wrote: false, reason: 'no-servers', servers: [] };
445
+ }
446
+ if (!homeDir || typeof mutateJsonFileLocked !== 'function') {
447
+ return { wrote: false, reason: 'no-home-or-mutator', servers };
448
+ }
449
+ const claudeJsonPath = path.join(homeDir, '.claude.json');
450
+ let didWrite = false;
451
+ mutateJsonFileLocked(claudeJsonPath, (data) => {
452
+ const root = data && typeof data === 'object' ? data : {};
453
+ if (!root.projects || typeof root.projects !== 'object') root.projects = {};
454
+ const projectKey = worktreePath;
455
+ const proj = root.projects[projectKey] && typeof root.projects[projectKey] === 'object'
456
+ ? root.projects[projectKey]
457
+ : {};
458
+ // enabledMcpjsonServers is Claude's actual on-disk approval field
459
+ const enabled = Array.isArray(proj.enabledMcpjsonServers) ? proj.enabledMcpjsonServers.slice() : [];
460
+ const disabled = Array.isArray(proj.disabledMcpjsonServers) ? proj.disabledMcpjsonServers.slice() : [];
461
+ const enabledSet = new Set(enabled.filter((s) => typeof s === 'string'));
462
+ let mutated = false;
463
+ for (const name of servers) {
464
+ if (!enabledSet.has(name)) {
465
+ enabledSet.add(name);
466
+ mutated = true;
467
+ }
468
+ }
469
+ // If a server was previously explicitly disabled, lift the disabled mark when we
470
+ // re-approve it — otherwise Claude treats the disabled entry as authoritative.
471
+ const nextDisabled = disabled.filter((s) => typeof s === 'string' && !servers.includes(s));
472
+ if (nextDisabled.length !== disabled.length) mutated = true;
473
+ if (!mutated) return data;
474
+ proj.enabledMcpjsonServers = [...enabledSet];
475
+ proj.disabledMcpjsonServers = nextDisabled;
476
+ root.projects[projectKey] = proj;
477
+ didWrite = true;
478
+ return root;
479
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
480
+ return { wrote: didWrite, reason: 'ok', servers };
481
+ }
482
+
330
483
  // ─── Main script execution ──────────────────────────────────────────────────
331
484
 
332
485
  function _installHint(name, runtime) {
@@ -345,7 +498,7 @@ function main() {
345
498
  console.error('Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [--runtime <name>] [args...]');
346
499
  process.exit(1);
347
500
  }
348
- const { promptFile, sysPromptFile, runtimeName, opts, passthrough } = parsed;
501
+ const { promptFile, sysPromptFile, runtimeName, opts, passthrough, projectHarnessDirs, hermetic } = parsed;
349
502
 
350
503
  const env = cleanChildEnv();
351
504
  injectAdoTokenEnvForRepoHost(env);
@@ -382,8 +535,21 @@ function main() {
382
535
  // Skill discovery dirs — agents run with CWD set to an external repo
383
536
  // worktree, so runtime-native global assets would otherwise be invisible.
384
537
  // The adapter owns both where those assets live and how to surface them.
538
+ // P-08b62d49: engine.js also forwards uncommitted project-local harness
539
+ // dirs from the operator's main checkout via `--project-harness-dir <path>`
540
+ // (one per dir, accumulated in `projectHarnessDirs`) so worktree-bound
541
+ // agents inherit them too. See docs/harness-propagation.md.
542
+ // P-49e1c8b7: when engine.js passes `--hermetic-harness`, computeAddDirs
543
+ // returns [minionsDir] only — both user-asset dirs and project harness
544
+ // dirs are dropped for a known-empty harness surface.
385
545
  const minionsDir = path.resolve(__dirname, '..');
386
- const addDirs = computeAddDirs({ runtime, minionsDir, homeDir: os.homedir() });
546
+ const addDirs = computeAddDirs({
547
+ runtime,
548
+ minionsDir,
549
+ homeDir: os.homedir(),
550
+ projectHarnessDirs,
551
+ hermetic,
552
+ });
387
553
 
388
554
  let resolved;
389
555
  try { resolved = runtime.resolveBinary({ env }); }
@@ -639,6 +805,6 @@ function main() {
639
805
  });
640
806
  }
641
807
 
642
- module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, createParentPipeForwarder, assertStaleHeadOk };
808
+ module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, preApproveWorkspaceMcps, createParentPipeForwarder, assertStaleHeadOk };
643
809
 
644
810
  if (require.main === module) main();