@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/README.md +7 -5
- package/bin/minions.js +15 -6
- package/dashboard/js/memory-panel.js +62 -0
- package/dashboard/js/refresh.js +59 -61
- package/dashboard/js/render-other.js +142 -1
- package/dashboard/js/render-work-items.js +18 -1
- package/dashboard/js/settings.js +23 -0
- package/dashboard/pages/engine-memory-panel.html +7 -0
- package/dashboard/pages/tools.html +8 -0
- package/dashboard-build.js +44 -8
- package/dashboard.js +466 -3
- package/docs/diagnostics-memory.md +446 -0
- package/docs/harness-propagation.md +273 -0
- package/docs/human-vs-automated.md +1 -1
- package/docs/runtime-adapters.md +5 -0
- package/engine/cli.js +24 -5
- package/engine/features.js +0 -18
- package/engine/preflight.js +265 -0
- package/engine/queries.js +204 -19
- package/engine/runtimes/claude.js +36 -0
- package/engine/runtimes/codex.js +19 -0
- package/engine/runtimes/copilot.js +27 -36
- package/engine/shared.js +293 -38
- package/engine/spawn-agent.js +178 -12
- package/engine.js +232 -3
- package/package.json +1 -1
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 &&
|
|
3005
|
-
if (engine &&
|
|
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 &&
|
|
3020
|
-
if (engine &&
|
|
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
|
|
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 &&
|
|
3067
|
-
if (engine &&
|
|
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 &&
|
|
3079
|
-
if (engine &&
|
|
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 (
|
|
3181
|
-
if (!
|
|
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
|
-
|
|
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 (!
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
* @
|
|
5285
|
-
*
|
|
5286
|
-
* - For
|
|
5287
|
-
* - For isolated
|
|
5288
|
-
*
|
|
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:
|
|
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
|
-
|
|
5319
|
-
if (
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
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
|
-
|
|
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,
|