@yemi33/minions 0.1.2179 → 0.1.2181

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)
@@ -2988,10 +3018,6 @@ function resolvePollFlag(engineCfg, granularKey, legacyMacroKey) {
2988
3018
  // (CLI chooses)" option (which submits an empty string) clears the override
2989
3019
  // instead of pinning the runtime to nothing.
2990
3020
 
2991
- function _isMeaningful(v) {
2992
- return v !== undefined && v !== null && v !== '';
2993
- }
2994
-
2995
3021
  /**
2996
3022
  * Resolve the CLI runtime for a per-agent spawn. Priority:
2997
3023
  * 1. `agent.cli` — per-agent override
@@ -3001,8 +3027,8 @@ function _isMeaningful(v) {
3001
3027
  * Does NOT fall through to `engine.ccCli`. CC and agents are independent paths.
3002
3028
  */
3003
3029
  function resolveAgentCli(agent, engine) {
3004
- if (agent && _isMeaningful(agent.cli)) return String(agent.cli);
3005
- if (engine && _isMeaningful(engine.defaultCli)) return String(engine.defaultCli);
3030
+ if (agent && agent.cli !== undefined && agent.cli !== null && agent.cli !== '') return String(agent.cli);
3031
+ if (engine && engine.defaultCli !== undefined && engine.defaultCli !== null && engine.defaultCli !== '') return String(engine.defaultCli);
3006
3032
  return ENGINE_DEFAULTS.defaultCli;
3007
3033
  }
3008
3034
 
@@ -3016,8 +3042,8 @@ function resolveAgentCli(agent, engine) {
3016
3042
  * it's a fleet-wide singleton.
3017
3043
  */
3018
3044
  function resolveCcCli(engine) {
3019
- if (engine && _isMeaningful(engine.ccCli)) return String(engine.ccCli);
3020
- if (engine && _isMeaningful(engine.defaultCli)) return String(engine.defaultCli);
3045
+ if (engine && engine.ccCli !== undefined && engine.ccCli !== null && engine.ccCli !== '') return String(engine.ccCli);
3046
+ if (engine && engine.defaultCli !== undefined && engine.defaultCli !== null && engine.defaultCli !== '') return String(engine.defaultCli);
3021
3047
  return ENGINE_DEFAULTS.defaultCli;
3022
3048
  }
3023
3049
 
@@ -3038,7 +3064,8 @@ function resolveCcCli(engine) {
3038
3064
  * 4. ENGINE_DEFAULTS.ccUseWorkerPool — final fallback
3039
3065
  *
3040
3066
  * Strict boolean check on the override so a literal `false` opts out even on
3041
- * Copilot, matching `_isMeaningful` semantics for boolean flags.
3067
+ * Copilot, matching the "treat empty/null/undefined as unset" semantics used
3068
+ * throughout the resolve* helpers for boolean flags.
3042
3069
  */
3043
3070
  function resolveCcUseWorkerPool(engine) {
3044
3071
  // Guard 1 (W-mphlriic00095f69): pool transport is ACP-only. If CC runtime
@@ -3063,8 +3090,8 @@ function resolveCcUseWorkerPool(engine) {
3063
3090
  * to the user's `~/.copilot/settings.json` model).
3064
3091
  */
3065
3092
  function resolveAgentModel(agent, engine) {
3066
- if (agent && _isMeaningful(agent.model)) return String(agent.model);
3067
- if (engine && _isMeaningful(engine.defaultModel)) return String(engine.defaultModel);
3093
+ if (agent && agent.model !== undefined && agent.model !== null && agent.model !== '') return String(agent.model);
3094
+ if (engine && engine.defaultModel !== undefined && engine.defaultModel !== null && engine.defaultModel !== '') return String(engine.defaultModel);
3068
3095
  return undefined;
3069
3096
  }
3070
3097
 
@@ -3075,8 +3102,8 @@ function resolveAgentModel(agent, engine) {
3075
3102
  * 3. `undefined` — let the runtime adapter pick
3076
3103
  */
3077
3104
  function resolveCcModel(engine) {
3078
- if (engine && _isMeaningful(engine.ccModel)) return String(engine.ccModel);
3079
- if (engine && _isMeaningful(engine.defaultModel)) return String(engine.defaultModel);
3105
+ if (engine && engine.ccModel !== undefined && engine.ccModel !== null && engine.ccModel !== '') return String(engine.ccModel);
3106
+ if (engine && engine.defaultModel !== undefined && engine.defaultModel !== null && engine.defaultModel !== '') return String(engine.defaultModel);
3080
3107
  return undefined;
3081
3108
  }
3082
3109
 
@@ -3151,6 +3178,32 @@ function resolveCopilotAgentDisabledMcpServers(agent, engine) {
3151
3178
  return [];
3152
3179
  }
3153
3180
 
3181
+ /**
3182
+ * P-49e1c8b7 — Resolve whether this agent should run with a hermetic harness.
3183
+ * Priority (mirrors `resolveAgentBareMode`):
3184
+ * 1. `agent.hermeticHarness` — per-agent override (boolean)
3185
+ * 2. `engine.hermeticHarness` — fleet default
3186
+ * 3. `false` — hardcoded fallback
3187
+ *
3188
+ * Strict undefined/null check (not falsy) so a per-agent `false` correctly
3189
+ * overrides an engine `true`. Truthy/falsy non-bool values are coerced via
3190
+ * `!!` for config tolerance.
3191
+ *
3192
+ * When TRUE, engine.js (a) skips Claude workspace .mcp.json pre-approval,
3193
+ * (b) skips project-local-on-main `--project-harness-dir` propagation, and
3194
+ * (c) forwards `--hermetic-harness` to spawn-agent.js so `computeAddDirs`
3195
+ * returns `[minionsDir]` only (user-scope skill/command/MCP roots stripped).
3196
+ * Independent of `copilotDisableBuiltinMcps` and `copilotSuppressAgentsMd`.
3197
+ */
3198
+ function resolveAgentHermeticHarness(agent, engine) {
3199
+ const a = agent ? agent.hermeticHarness : undefined;
3200
+ if (a !== undefined && a !== null) return !!a;
3201
+ const e = engine ? engine.hermeticHarness : undefined;
3202
+ if (e !== undefined && e !== null) return !!e;
3203
+ return false;
3204
+ }
3205
+
3206
+
3154
3207
  // ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
3155
3208
  //
3156
3209
  // Pre-P-3b8e5f1d, `engine.ccModel` was the single fleet-wide model knob (it
@@ -3177,14 +3230,12 @@ let _legacyCcModelMigrationLogged = false;
3177
3230
  function applyLegacyCcModelMigration(config, { logger = log } = {}) {
3178
3231
  if (!config || !config.engine || typeof config.engine !== 'object') return false;
3179
3232
  const e = config.engine;
3180
- if (_isMeaningful(e.defaultModel)) return false;
3181
- if (!_isMeaningful(e.ccModel)) return false;
3233
+ if (e.defaultModel !== undefined && e.defaultModel !== null && e.defaultModel !== '') return false;
3234
+ if (!(e.ccModel !== undefined && e.ccModel !== null && e.ccModel !== '')) return false;
3182
3235
  e.defaultModel = e.ccModel;
3183
3236
  if (!_legacyCcModelMigrationLogged) {
3184
3237
  _legacyCcModelMigrationLogged = true;
3185
- try {
3186
- logger('warn', 'ccModel is now a CC-specific override; set defaultModel to apply fleet-wide');
3187
- } catch { /* logger may not be wired during tests — best-effort */ }
3238
+ logger('warn', 'ccModel is now a CC-specific override; set defaultModel to apply fleet-wide');
3188
3239
  }
3189
3240
  return true;
3190
3241
  }
@@ -3276,7 +3327,7 @@ function runtimeConfigWarnings(config, registeredRuntimes) {
3276
3327
  // 1. Unknown CLI values across the fleet.
3277
3328
  const seen = new Set();
3278
3329
  const checkCli = (label, value) => {
3279
- if (!_isMeaningful(value)) return;
3330
+ if (!(value !== undefined && value !== null && value !== '')) return;
3280
3331
  const key = `${label}:${value}`;
3281
3332
  if (seen.has(key)) return;
3282
3333
  seen.add(key);
@@ -3320,7 +3371,7 @@ function runtimeConfigWarnings(config, registeredRuntimes) {
3320
3371
  const ccCli = resolveCcCli(engine);
3321
3372
  let ccRuntime = null;
3322
3373
  try { ccRuntime = require('./runtimes').resolveRuntime(ccCli); } catch { /* unknown runtime — skip */ }
3323
- if (ccRuntime?.capabilities?.bareMode === true && !_isMeaningful(engine.ccSystemPrompt)) {
3374
+ if (ccRuntime?.capabilities?.bareMode === true && !(engine.ccSystemPrompt !== undefined && engine.ccSystemPrompt !== null && engine.ccSystemPrompt !== '')) {
3324
3375
  warnings.push({
3325
3376
  id: 'bare-mode-misconfig',
3326
3377
  message: `engine.claudeBareMode is true but CC runs on ${ccCli} (which honours --bare) with no engine.ccSystemPrompt — CLAUDE.md auto-discovery is suppressed and CC will lose project context.`,
@@ -4021,6 +4072,7 @@ const FAILURE_CLASS = {
4021
4072
  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
4073
  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
4074
  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.
4075
+ 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
4076
  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
4077
  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
4078
  WORKSPACE_MANIFEST_TOOL: 'workspace-manifest-tool-forbidden', // W-mq07avbk000m5543: out-of-scope tool call (manifest enforcement at the runtime gate). Non-retryable as-is.
@@ -5045,11 +5097,7 @@ function sanitizeBranch(name) {
5045
5097
  // of side-effecting child_process imports at module load.
5046
5098
 
5047
5099
  function getOperatorLogin(config) {
5048
- try {
5049
- return require('./operator-identity').resolveOperatorLogin(config || {});
5050
- } catch {
5051
- return null;
5052
- }
5100
+ return require('./operator-identity').resolveOperatorLogin(config || {});
5053
5101
  }
5054
5102
 
5055
5103
  function deriveWorkItemBranchName(item, config) {
@@ -5278,21 +5326,47 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
5278
5326
  * `LIVE_CHECKOUT_NO_LOCALPATH` if `localPath` is missing/falsy — live mode
5279
5327
  * has no fallback because there is no neutral location to dispatch into.
5280
5328
  *
5329
+ * **Workdir override (P-714ef144).** When `options.workdir` is a non-empty
5330
+ * relative POSIX subpath (already validated by `validateWorkItemWorkdir`),
5331
+ * the resolver joins it onto the spawn cwd:
5332
+ *
5333
+ * - live mode: cwd = path.join(localPath, workdir)
5334
+ * - read-only mode: cwd = path.join(<base>, workdir)
5335
+ * - mutating mode: cwd stays null (worktree doesn't exist yet); the
5336
+ * normalized workdir is echoed back as result.workdir so spawnAgent
5337
+ * can apply `shared.applyWorkdir(worktreePath, workdir)` after
5338
+ * `git worktree add` succeeds.
5339
+ *
5340
+ * Live and read-only branches run the post-resolve containment guard via
5341
+ * `applyWorkdir` and throw an Error with `code: 'INVALID_WORKDIR'` if the
5342
+ * resolved cwd escapes its base (defense-in-depth — validator already
5343
+ * rejects ".." / absolute paths, but symlink-style attacks can only be
5344
+ * caught post-resolve). The mutating containment check happens later in
5345
+ * spawnAgent, against the actual worktree path.
5346
+ *
5281
5347
  * @param {{ localPath?: string|null, worktreeMode?: string|null }|null|undefined} project
5282
5348
  * @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
5283
5349
  * @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)
5350
+ * @param {{ workdir?: string|null }} [options] — optional per-WI overrides
5351
+ * @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean, workdir?: string|null }}
5352
+ * - For live mode (any type): { cwd: <abs localPath[/workdir]>, worktreeRootDir: null, liveMode: true }
5353
+ * - For isolated read-only types: { cwd: <project dir[/workdir] or MINIONS_DIR>, worktreeRootDir: null }
5354
+ * - For isolated code-mutating types: { cwd: null, worktreeRootDir: <project root>, workdir: <subpath|null> }
5355
+ * (caller defaults cwd to worktreeRootDir before worktree creation,
5356
+ * then runs shared.applyWorkdir(worktreePath, result.workdir) to land
5357
+ * the agent inside the subpackage cwd)
5289
5358
  * The optional `liveMode` discriminator lets callers branch on one boolean
5290
5359
  * instead of re-reading `project.worktreeMode`.
5291
5360
  * @throws {Error} LIVE_CHECKOUT_NO_LOCALPATH (live mode, missing localPath),
5361
+ * INVALID_WORKDIR (live or read-only, workdir escapes base),
5292
5362
  * WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT (isolated code-mutating),
5293
5363
  * or WORKTREE_ROOTDIR_MISSING_BASE if neither anchor present.
5294
5364
  */
5295
- function resolveSpawnPaths(project, type, minionsDir) {
5365
+ function resolveSpawnPaths(project, type, minionsDir, options) {
5366
+ const workdir = (options && typeof options === 'object' && typeof options.workdir === 'string' && options.workdir)
5367
+ ? options.workdir
5368
+ : null;
5369
+
5296
5370
  // ── Live-checkout mode short-circuit (P-a3f9b202) ──────────────────────
5297
5371
  // Runs BEFORE the read-only / code-mutating split so live mode is the
5298
5372
  // single decision point regardless of task type — read-only tasks in
@@ -5306,8 +5380,10 @@ function resolveSpawnPaths(project, type, minionsDir) {
5306
5380
  err.code = 'LIVE_CHECKOUT_NO_LOCALPATH';
5307
5381
  throw err;
5308
5382
  }
5383
+ const baseLive = path.resolve(String(project.localPath));
5384
+ const liveCwd = _applyWorkdirOrThrow(baseLive, workdir);
5309
5385
  return {
5310
- cwd: path.resolve(String(project.localPath)),
5386
+ cwd: liveCwd,
5311
5387
  worktreeRootDir: null,
5312
5388
  liveMode: true,
5313
5389
  };
@@ -5315,14 +5391,189 @@ function resolveSpawnPaths(project, type, minionsDir) {
5315
5391
 
5316
5392
  const isReadOnly = READ_ONLY_ROOT_TASK_TYPES.has(type);
5317
5393
  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;
5394
+ let base = null;
5395
+ if (project?.localPath) base = path.resolve(String(project.localPath));
5396
+ else if (minionsDir) base = path.resolve(String(minionsDir));
5397
+ else {
5398
+ const err = new Error('Cannot resolve cwd for read-only spawn: no project.localPath and no MINIONS_DIR provided.');
5399
+ err.code = 'WORKTREE_ROOTDIR_MISSING_BASE';
5400
+ throw err;
5401
+ }
5402
+ const roCwd = _applyWorkdirOrThrow(base, workdir);
5403
+ return { cwd: roCwd, worktreeRootDir: null };
5323
5404
  }
5324
5405
  const worktreeRootDir = resolveProjectRootDir(project?.localPath, minionsDir);
5325
- return { cwd: null, worktreeRootDir };
5406
+ // Mutating types: workdir is echoed back UNAPPLIED. spawnAgent calls
5407
+ // `shared.applyWorkdir(worktreePath, workdir)` after `git worktree add`
5408
+ // succeeds, which is the only time the actual base path exists on disk.
5409
+ return { cwd: null, worktreeRootDir, workdir };
5410
+ }
5411
+
5412
+ // ── meta.workdir helpers (P-714ef144) ────────────────────────────────────
5413
+ //
5414
+ // Per-WI `meta.workdir` is a relative POSIX subpath under the spawn base
5415
+ // (project.localPath for read-only / live; worktree root for mutating).
5416
+ // When set, the engine lands the agent at <base>/<workdir> so monorepo
5417
+ // subpackage dispatches can use the runtime CLI's native cwd-rooted skill
5418
+ // discovery (`<workdir>/.claude/skills/<name>/SKILL.md`) without engine
5419
+ // awareness of which package is which. Cross-package skill aggregation is
5420
+ // explicitly out of scope (per PRD open_question) — when workdir is set,
5421
+ // the harness propagation block in engine.js also CLIPS its candidate
5422
+ // `--project-harness-dir` set to dirs that live under the workdir mirror
5423
+ // in `project.localPath`, so a `packages/foo` dispatch never sees
5424
+ // `packages/bar/.claude/skills/...`.
5425
+ //
5426
+ // Three pure helpers, all unit-tested in test/unit/work-item-workdir.test.js:
5427
+ //
5428
+ // 1. validateWorkItemWorkdir(value) — input validation for the
5429
+ // dashboard.js POST /api/work-items handler. Returns
5430
+ // `{ valid: true, value: null }` for empty/unset (back-compat
5431
+ // default), `{ valid: true, value: <normalized POSIX subpath> }`
5432
+ // for a clean subpath, or `{ valid: false, error: <human reason> }`
5433
+ // for any rejection. The normalized form has backslashes folded to
5434
+ // `/`, trims surrounding whitespace, drops leading `./` and trailing
5435
+ // `/`, and rejects `..` segments, absolute paths (POSIX or Windows
5436
+ // drive-letter), null bytes, and non-string types.
5437
+ //
5438
+ // 2. applyWorkdir(base, workdir) — pure join + containment guard.
5439
+ // Returns `{ cwd: <resolved absolute path>, error?: <reason> }`. The
5440
+ // `error` field is set when the resolved cwd would escape `base`
5441
+ // (defense-in-depth against symlink-style attacks; the validator
5442
+ // already rejects bare `..` / absolute strings). `base` MUST be
5443
+ // absolute. Empty/null workdir returns the base unchanged.
5444
+ //
5445
+ // 3. filterProjectHarnessDirsForWorkdir(dirs, opts) — clips the
5446
+ // candidate `--project-harness-dir` set to entries under the resolved
5447
+ // cwd anchor in `project.localPath`, excluding entries under the
5448
+ // worktree mirror (already discoverable via the agent's cwd). When
5449
+ // `workdir` is null/empty the behavior matches P-08b62d49 unchanged
5450
+ // (filter to project.localPath, exclude worktree).
5451
+ //
5452
+ // The private `_applyWorkdirOrThrow(base, workdir)` is the throw-on-error
5453
+ // adapter used inside resolveSpawnPaths so live/read-only callers don't
5454
+ // need to forward the `error` field.
5455
+
5456
+ function _applyWorkdirOrThrow(base, workdir) {
5457
+ if (!workdir) return base;
5458
+ const r = applyWorkdir(base, workdir);
5459
+ if (r.error) {
5460
+ const err = new Error(r.error);
5461
+ err.code = 'INVALID_WORKDIR';
5462
+ err.workdir = workdir;
5463
+ err.base = base;
5464
+ throw err;
5465
+ }
5466
+ return r.cwd;
5467
+ }
5468
+
5469
+ function validateWorkItemWorkdir(value) {
5470
+ // Unset / empty → null (back-compat default, today's project-root behavior)
5471
+ if (value === undefined || value === null) return { valid: true, value: null };
5472
+ if (typeof value !== 'string') {
5473
+ return { valid: false, error: 'meta.workdir must be a string (got ' + typeof value + ')' };
5474
+ }
5475
+ const trimmed = value.trim();
5476
+ if (!trimmed) return { valid: true, value: null };
5477
+
5478
+ // Null bytes — common path-injection signal; refuse defensively.
5479
+ if (trimmed.indexOf('\u0000') !== -1) {
5480
+ return { valid: false, error: 'meta.workdir must not contain null bytes' };
5481
+ }
5482
+
5483
+ // Windows drive-letter prefix (e.g. "C:\\Windows", "D:foo") — even when
5484
+ // the path looks "inside" the project, it's an absolute reference.
5485
+ if (/^[A-Za-z]:[\\/]?/.test(trimmed)) {
5486
+ return { valid: false, error: 'meta.workdir must be a relative POSIX subpath, not a Windows drive path (got "' + value + '")' };
5487
+ }
5488
+
5489
+ // POSIX absolute / UNC / backslash-absolute.
5490
+ if (path.isAbsolute(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) {
5491
+ return { valid: false, error: 'meta.workdir must be a relative path, not absolute (got "' + value + '")' };
5492
+ }
5493
+
5494
+ // ".." segments — pre-resolve guard. applyWorkdir runs the post-resolve
5495
+ // containment check as defense-in-depth, but rejecting `..` here gives
5496
+ // operators a clearer error message at the dashboard validation site.
5497
+ const segments = trimmed.split(/[\\/]+/);
5498
+ if (segments.some(s => s === '..')) {
5499
+ return { valid: false, error: 'meta.workdir may not contain ".." segments (escape attempt blocked)' };
5500
+ }
5501
+
5502
+ // Normalize: backslashes → forward slashes, drop leading "./", strip
5503
+ // trailing slashes. Preserve "." segments (filtered out by path.join later).
5504
+ let normalized = trimmed.replace(/\\/g, '/');
5505
+ while (normalized.startsWith('./')) normalized = normalized.slice(2);
5506
+ normalized = normalized.replace(/\/+$/, '');
5507
+ if (!normalized) return { valid: true, value: null };
5508
+
5509
+ return { valid: true, value: normalized };
5510
+ }
5511
+
5512
+ function applyWorkdir(base, workdir) {
5513
+ if (workdir === null || workdir === undefined || workdir === '') {
5514
+ return { cwd: base };
5515
+ }
5516
+ if (typeof workdir !== 'string') {
5517
+ return { cwd: base, error: 'workdir must be a string (got ' + typeof workdir + ')' };
5518
+ }
5519
+ // Refuse absolute workdir even if it'd technically resolve inside — only
5520
+ // relative subpaths are allowed. This catches the "operator pasted an
5521
+ // absolute path" authoring error before path.join silently swaps the base.
5522
+ if (path.isAbsolute(workdir) || /^[A-Za-z]:[\\/]?/.test(workdir) || workdir.startsWith('/') || workdir.startsWith('\\')) {
5523
+ return { cwd: base, error: 'workdir must be relative, not absolute (got "' + workdir + '")' };
5524
+ }
5525
+ const baseAbs = path.resolve(String(base));
5526
+ const joined = path.resolve(baseAbs, workdir);
5527
+ // Containment guard: resolved cwd must equal base or live strictly inside.
5528
+ if (joined !== baseAbs && !joined.startsWith(baseAbs + path.sep)) {
5529
+ return {
5530
+ cwd: baseAbs,
5531
+ error: 'workdir resolved outside base (containment escape): "' + workdir + '" → "' + joined + '" not inside "' + baseAbs + '"',
5532
+ };
5533
+ }
5534
+ return { cwd: joined };
5535
+ }
5536
+
5537
+ function filterProjectHarnessDirsForWorkdir(dirs, opts) {
5538
+ if (!Array.isArray(dirs) || dirs.length === 0) return [];
5539
+ const projectLocalPath = opts && opts.projectLocalPath;
5540
+ const worktreePath = opts && opts.worktreePath;
5541
+ const workdir = opts && opts.workdir;
5542
+ if (!projectLocalPath || !worktreePath) return [];
5543
+
5544
+ const projectLocalAbs = path.resolve(String(projectLocalPath));
5545
+ const worktreeAbs = path.resolve(String(worktreePath));
5546
+
5547
+ // When workdir is set, the harness propagation anchors shift to the
5548
+ // subpath mirror in BOTH the project root and the worktree root, so a
5549
+ // `packages/foo` dispatch only sees `<projectLocal>/packages/foo/...`
5550
+ // harness dirs (its actual cwd in the operator's main checkout) and
5551
+ // excludes `<worktree>/packages/foo/...` (already discoverable via cwd).
5552
+ let projectAnchor = projectLocalAbs;
5553
+ let worktreeAnchor = worktreeAbs;
5554
+ if (workdir && typeof workdir === 'string' && workdir) {
5555
+ const pr = applyWorkdir(projectLocalAbs, workdir);
5556
+ const wr = applyWorkdir(worktreeAbs, workdir);
5557
+ // If either join fails containment, drop everything — better empty than wrong.
5558
+ if (pr.error || wr.error) return [];
5559
+ projectAnchor = pr.cwd;
5560
+ worktreeAnchor = wr.cwd;
5561
+ }
5562
+
5563
+ const seen = new Set();
5564
+ const out = [];
5565
+ for (const dir of dirs) {
5566
+ if (typeof dir !== 'string' || !dir) continue;
5567
+ const abs = path.resolve(dir);
5568
+ if (seen.has(abs)) continue;
5569
+ seen.add(abs);
5570
+ const insideProject = abs === projectAnchor || abs.startsWith(projectAnchor + path.sep);
5571
+ if (!insideProject) continue;
5572
+ const insideWorktree = abs === worktreeAnchor || abs.startsWith(worktreeAnchor + path.sep);
5573
+ if (insideWorktree) continue;
5574
+ out.push(abs);
5575
+ }
5576
+ return out;
5326
5577
  }
5327
5578
 
5328
5579
  // ── HTTP Origin Allowlist & Security Headers ─────────────────────────────────
@@ -7805,6 +8056,7 @@ module.exports = {
7805
8056
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
7806
8057
  resolveAgentMaxBudget, resolveAgentBareMode,
7807
8058
  resolveCopilotAgentDisabledMcpServers,
8059
+ resolveAgentHermeticHarness,
7808
8060
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
7809
8061
  applyCcWorkerPoolForceOnMigration,
7810
8062
  runtimeConfigWarnings,
@@ -7897,6 +8149,9 @@ module.exports = {
7897
8149
  assertWorktreeOutsideProject,
7898
8150
  resolveProjectRootDir,
7899
8151
  resolveSpawnPaths,
8152
+ validateWorkItemWorkdir,
8153
+ applyWorkdir,
8154
+ filterProjectHarnessDirsForWorkdir,
7900
8155
  READ_ONLY_ROOT_TASK_TYPES,
7901
8156
  isLiveCommandCenterPath,
7902
8157
  describeCcProtectedPaths,