@yemi33/minions 0.1.2178 → 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/README.md +7 -5
- package/bin/minions.js +39 -17
- package/dashboard/js/command-parser.js +1 -1
- package/dashboard/js/memory-panel.js +324 -0
- package/dashboard/js/qa.js +2 -2
- package/dashboard/js/refresh.js +19 -1
- package/dashboard/js/render-other.js +143 -2
- package/dashboard/js/render-prs.js +2 -1
- package/dashboard/js/render-schedules.js +1 -1
- package/dashboard/js/render-watches.js +1 -1
- package/dashboard/js/render-work-items.js +18 -1
- package/dashboard/js/settings.js +23 -0
- package/dashboard/pages/engine-memory-panel.html +56 -0
- package/dashboard/pages/engine.html +1 -0
- package/dashboard/pages/tools.html +8 -0
- package/dashboard/slim/js/link-pr.js +5 -5
- package/dashboard/slim/js/modals-tiles.js +44 -3
- package/dashboard/slim/js/projects.js +8 -6
- package/dashboard/slim/styles.css +20 -0
- package/dashboard-build.js +17 -2
- package/dashboard.js +693 -19
- package/docs/branch-derivation.md +13 -1
- 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/diagnostics-memory.js +190 -0
- package/engine/lifecycle.js +111 -1
- package/engine/preflight.js +265 -0
- package/engine/queries.js +331 -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 +390 -15
- package/engine/spawn-agent.js +178 -12
- package/engine/watchdog.js +6 -0
- package/engine.js +277 -4
- package/package.json +2 -2
package/engine/shared.js
CHANGED
|
@@ -484,18 +484,99 @@ function resolveEngineCacheDir(fallbackEngineDir) {
|
|
|
484
484
|
// Cross-platform URL opener. Uses execSync so failures fall through the
|
|
485
485
|
// try/catch and the caller sees them. Dashboard self-open and `minions dash`
|
|
486
486
|
// / `minions restart` post-health open all funnel through here.
|
|
487
|
-
|
|
487
|
+
//
|
|
488
|
+
// Every call writes a structured `event: 'browser-open'` entry to log.json
|
|
489
|
+
// (via shared.log) and bumps a per-reason counter in `_engine.browserOpens`
|
|
490
|
+
// in metrics.json. Callers MUST pass `opts.reason` so the log/metric is
|
|
491
|
+
// attributable; a missing reason is logged at `warn` level with
|
|
492
|
+
// `reason: 'unknown'` to make a regression loud rather than silent.
|
|
493
|
+
//
|
|
494
|
+
// `MINIONS_NO_AUTO_OPEN=1` short-circuits the open and writes a debug-level
|
|
495
|
+
// SUPPRESSED entry instead — this lets us prove the kill-switch is firing
|
|
496
|
+
// in production (absence of opens otherwise looks identical whether the
|
|
497
|
+
// guard worked, nobody tried to open, or the guard regressed).
|
|
498
|
+
//
|
|
499
|
+
// Note: browser windows opened by external MCP servers (e.g. @playwright/mcp)
|
|
500
|
+
// do NOT funnel through this primitive and are therefore NOT logged. See
|
|
501
|
+
// MTG-mqam2mjs000e39d7.
|
|
502
|
+
//
|
|
503
|
+
// @param {string} url - URL to open in the user's default browser.
|
|
504
|
+
// @param {Object} [opts]
|
|
505
|
+
// @param {string} [opts.reason] - Why we are opening (e.g. 'cli-dash-warm',
|
|
506
|
+
// 'cli-start-force-open', 'dashboard-self-open'). Required-in-practice;
|
|
507
|
+
// missing values are logged at warn level.
|
|
508
|
+
// @param {string} [opts.callerHint] - Optional `file:line` to disambiguate
|
|
509
|
+
// when one reason has multiple call sites.
|
|
510
|
+
// @returns {{ok: boolean, error?: string, suppressed?: boolean}}
|
|
511
|
+
function openUrlInBrowser(url, opts = {}) {
|
|
512
|
+
const reason = (opts && typeof opts.reason === 'string' && opts.reason) ? opts.reason : 'unknown';
|
|
513
|
+
const callerHint = (opts && typeof opts.callerHint === 'string') ? opts.callerHint : null;
|
|
514
|
+
// Heuristic-driven opens (no explicit user action this dispatch) log at warn
|
|
515
|
+
// so a leak shows up as a warn cluster in the dashboard log filter.
|
|
516
|
+
const HEURISTIC_REASONS = new Set(['cli-restart-no-beacon', 'dashboard-self-open']);
|
|
517
|
+
let level = HEURISTIC_REASONS.has(reason) ? 'warn' : 'info';
|
|
518
|
+
if (reason === 'unknown') level = 'warn';
|
|
519
|
+
const callerStack = (new Error()).stack.split('\n').slice(2, 5).map(s => s.trim()).join(' | ');
|
|
520
|
+
const baseMeta = {
|
|
521
|
+
event: 'browser-open',
|
|
522
|
+
url,
|
|
523
|
+
reason,
|
|
524
|
+
callerHint,
|
|
525
|
+
callerStack,
|
|
526
|
+
pid: process.pid,
|
|
527
|
+
platform: process.platform,
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// Kill-switch: log SUPPRESSED at debug level and skip execSync. Centralizing
|
|
531
|
+
// the check here means every caller (existing + future) is observable, and
|
|
532
|
+
// outer guards (e.g. ralph's W-mqb9y83o000le41e leak fixes) become belt-and-
|
|
533
|
+
// suspenders without breaking the log audit trail.
|
|
534
|
+
if (process.env.MINIONS_NO_AUTO_OPEN) {
|
|
535
|
+
log('debug', `[browser-open] SUPPRESSED reason=${reason} url=${url} (MINIONS_NO_AUTO_OPEN=1)`, {
|
|
536
|
+
...baseMeta,
|
|
537
|
+
suppressed: true,
|
|
538
|
+
});
|
|
539
|
+
_bumpBrowserOpenMetric(reason, { suppressed: true });
|
|
540
|
+
return { ok: false, suppressed: true };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
log(level, `[browser-open] reason=${reason} url=${url}`, baseMeta);
|
|
544
|
+
_bumpBrowserOpenMetric(reason);
|
|
545
|
+
|
|
488
546
|
const { execSync } = require('child_process');
|
|
489
547
|
try {
|
|
490
548
|
if (process.platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore', windowsHide: true });
|
|
491
549
|
else if (process.platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
492
550
|
else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
551
|
+
log(level, `[browser-open] ok reason=${reason}`, { ...baseMeta, ok: true });
|
|
493
552
|
return { ok: true };
|
|
494
553
|
} catch (e) {
|
|
495
|
-
|
|
554
|
+
const errMsg = e && e.message ? e.message : String(e);
|
|
555
|
+
log(level, `[browser-open] failed reason=${reason} error=${errMsg}`, { ...baseMeta, ok: false, error: errMsg });
|
|
556
|
+
return { ok: false, error: errMsg };
|
|
496
557
|
}
|
|
497
558
|
}
|
|
498
559
|
|
|
560
|
+
// Bump per-reason counter under metrics._engine.browserOpens. Best-effort —
|
|
561
|
+
// metrics.json writes must never throw out of openUrlInBrowser. Schema:
|
|
562
|
+
// _engine.browserOpens.<reason> = { count, suppressedCount, lastAt }
|
|
563
|
+
function _bumpBrowserOpenMetric(reason, { suppressed = false } = {}) {
|
|
564
|
+
try {
|
|
565
|
+
mutateMetrics((metrics) => {
|
|
566
|
+
if (!metrics._engine || typeof metrics._engine !== 'object') metrics._engine = {};
|
|
567
|
+
if (!metrics._engine.browserOpens || typeof metrics._engine.browserOpens !== 'object') {
|
|
568
|
+
metrics._engine.browserOpens = {};
|
|
569
|
+
}
|
|
570
|
+
const slot = metrics._engine.browserOpens[reason] || { count: 0, suppressedCount: 0, lastAt: null };
|
|
571
|
+
if (suppressed) slot.suppressedCount = (slot.suppressedCount || 0) + 1;
|
|
572
|
+
else slot.count = (slot.count || 0) + 1;
|
|
573
|
+
slot.lastAt = new Date().toISOString();
|
|
574
|
+
metrics._engine.browserOpens[reason] = slot;
|
|
575
|
+
return metrics;
|
|
576
|
+
});
|
|
577
|
+
} catch { /* metric best-effort — never throw out of openUrlInBrowser */ }
|
|
578
|
+
}
|
|
579
|
+
|
|
499
580
|
function _flushLogBuffer() {
|
|
500
581
|
if (_logBuffer.length === 0) return;
|
|
501
582
|
const drained = _logBuffer.splice(0);
|
|
@@ -2533,6 +2614,11 @@ const ENGINE_DEFAULTS = {
|
|
|
2533
2614
|
cleanupEvery: 60, // runCleanup + MCP sync every N ticks (~10 min at default 10s tick)
|
|
2534
2615
|
planCompletionScanEvery: 60, // periodic PRD completion sweep (~10 min at default 10s tick) — catches plans completed while engine was down
|
|
2535
2616
|
watchPollEvery: 18, // checkWatches every N ticks (~3 min at default 10s tick)
|
|
2617
|
+
// P-b2c3d4e5: cadence for MEMORY_BASELINE log line + diagnostics-memory.json
|
|
2618
|
+
// sidecar write driven from engine.js tickInner. Defaults to 6 ticks ≈ 60s
|
|
2619
|
+
// at the default 10s tickInterval. Set to 0 (or any non-positive integer)
|
|
2620
|
+
// to disable both the log emission and sidecar write cleanly (operator opt-out).
|
|
2621
|
+
memoryBaselineEveryTicks: 6,
|
|
2536
2622
|
stalledDispatchSweepEvery: 120, // stalled-dispatch retry sweep (~20 min at default 10s tick) — only fires when all agents idle
|
|
2537
2623
|
// W-mp5trwh60008386d: per-PR 404 must repeat across N consecutive successful base-repo probes
|
|
2538
2624
|
// before flipping a PR to `abandoned`. A single 404 on `repos/{slug}/pulls/{n}` can be a transient
|
|
@@ -2613,6 +2699,36 @@ const ENGINE_DEFAULTS = {
|
|
|
2613
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
|
|
2614
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)
|
|
2615
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,
|
|
2616
2732
|
copilotSuppressAgentsMd: true, // Copilot --no-custom-instructions: stop AGENTS.md auto-load from fighting Minions playbook prompts
|
|
2617
2733
|
copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
|
|
2618
2734
|
copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
|
|
@@ -3065,6 +3181,32 @@ function resolveCopilotAgentDisabledMcpServers(agent, engine) {
|
|
|
3065
3181
|
return [];
|
|
3066
3182
|
}
|
|
3067
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
|
+
|
|
3068
3210
|
// ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
|
|
3069
3211
|
//
|
|
3070
3212
|
// Pre-P-3b8e5f1d, `engine.ccModel` was the single fleet-wide model knob (it
|
|
@@ -3935,6 +4077,7 @@ const FAILURE_CLASS = {
|
|
|
3935
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.
|
|
3936
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.
|
|
3937
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.
|
|
3938
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).
|
|
3939
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.
|
|
3940
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.
|
|
@@ -4284,6 +4427,30 @@ function getProjects(config) {
|
|
|
4284
4427
|
return [];
|
|
4285
4428
|
}
|
|
4286
4429
|
|
|
4430
|
+
// Generic, ambiguous project-name tokens that read poorly as a bare label in
|
|
4431
|
+
// the dashboard. For these, projectDisplayName derives a `<parentFolder>/<name>`
|
|
4432
|
+
// label from the project's localPath. `src` is the required case. DISPLAY ONLY —
|
|
4433
|
+
// the project `name` field is a load-bearing identifier and is never mutated.
|
|
4434
|
+
const GENERIC_PROJECT_NAME_TOKENS = new Set(['src', 'source', 'repo']);
|
|
4435
|
+
|
|
4436
|
+
// Human-facing label for a project. Returns `project.name` unchanged for normal
|
|
4437
|
+
// names. When the name is a generic token (e.g. `src`), derives the parent
|
|
4438
|
+
// folder one level up from localPath and returns `<parentBasename>/<name>` —
|
|
4439
|
+
// e.g. name `src` at localPath `C:/office/src` → `office/src`. Falls back to the
|
|
4440
|
+
// bare name when localPath is missing or the parent can't be derived. Never
|
|
4441
|
+
// mutates the project; preserves the original name casing.
|
|
4442
|
+
function projectDisplayName(project) {
|
|
4443
|
+
if (!project || typeof project !== 'object') return '';
|
|
4444
|
+
const name = project.name == null ? '' : String(project.name);
|
|
4445
|
+
if (!GENERIC_PROJECT_NAME_TOKENS.has(name.trim().toLowerCase())) return name;
|
|
4446
|
+
const localPath = project.localPath == null ? '' : String(project.localPath);
|
|
4447
|
+
const normalized = localPath.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
4448
|
+
if (!normalized) return name;
|
|
4449
|
+
const parent = path.basename(path.dirname(normalized));
|
|
4450
|
+
if (!parent || parent === '.') return name;
|
|
4451
|
+
return `${parent}/${name}`;
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4287
4454
|
function formatUnknownProjectError(projectName, projects = []) {
|
|
4288
4455
|
const known = projects.map(p => p.name).filter(Boolean).join(', ') || '(none configured)';
|
|
4289
4456
|
return `Project "${projectName}" not found. Known projects: ${known}`;
|
|
@@ -5168,21 +5335,47 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
|
|
|
5168
5335
|
* `LIVE_CHECKOUT_NO_LOCALPATH` if `localPath` is missing/falsy — live mode
|
|
5169
5336
|
* has no fallback because there is no neutral location to dispatch into.
|
|
5170
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
|
+
*
|
|
5171
5356
|
* @param {{ localPath?: string|null, worktreeMode?: string|null }|null|undefined} project
|
|
5172
5357
|
* @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
|
|
5173
5358
|
* @param {string} minionsDir — MINIONS_DIR fallback anchor (ignored in live mode)
|
|
5174
|
-
* @
|
|
5175
|
-
*
|
|
5176
|
-
* - For
|
|
5177
|
-
* - For isolated
|
|
5178
|
-
*
|
|
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)
|
|
5179
5367
|
* The optional `liveMode` discriminator lets callers branch on one boolean
|
|
5180
5368
|
* instead of re-reading `project.worktreeMode`.
|
|
5181
5369
|
* @throws {Error} LIVE_CHECKOUT_NO_LOCALPATH (live mode, missing localPath),
|
|
5370
|
+
* INVALID_WORKDIR (live or read-only, workdir escapes base),
|
|
5182
5371
|
* WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT (isolated code-mutating),
|
|
5183
5372
|
* or WORKTREE_ROOTDIR_MISSING_BASE if neither anchor present.
|
|
5184
5373
|
*/
|
|
5185
|
-
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
|
+
|
|
5186
5379
|
// ── Live-checkout mode short-circuit (P-a3f9b202) ──────────────────────
|
|
5187
5380
|
// Runs BEFORE the read-only / code-mutating split so live mode is the
|
|
5188
5381
|
// single decision point regardless of task type — read-only tasks in
|
|
@@ -5196,8 +5389,10 @@ function resolveSpawnPaths(project, type, minionsDir) {
|
|
|
5196
5389
|
err.code = 'LIVE_CHECKOUT_NO_LOCALPATH';
|
|
5197
5390
|
throw err;
|
|
5198
5391
|
}
|
|
5392
|
+
const baseLive = path.resolve(String(project.localPath));
|
|
5393
|
+
const liveCwd = _applyWorkdirOrThrow(baseLive, workdir);
|
|
5199
5394
|
return {
|
|
5200
|
-
cwd:
|
|
5395
|
+
cwd: liveCwd,
|
|
5201
5396
|
worktreeRootDir: null,
|
|
5202
5397
|
liveMode: true,
|
|
5203
5398
|
};
|
|
@@ -5205,14 +5400,189 @@ function resolveSpawnPaths(project, type, minionsDir) {
|
|
|
5205
5400
|
|
|
5206
5401
|
const isReadOnly = READ_ONLY_ROOT_TASK_TYPES.has(type);
|
|
5207
5402
|
if (isReadOnly) {
|
|
5208
|
-
|
|
5209
|
-
if (
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
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 };
|
|
5213
5413
|
}
|
|
5214
5414
|
const worktreeRootDir = resolveProjectRootDir(project?.localPath, minionsDir);
|
|
5215
|
-
|
|
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;
|
|
5216
5586
|
}
|
|
5217
5587
|
|
|
5218
5588
|
// ── HTTP Origin Allowlist & Security Headers ─────────────────────────────────
|
|
@@ -7695,6 +8065,7 @@ module.exports = {
|
|
|
7695
8065
|
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
|
|
7696
8066
|
resolveAgentMaxBudget, resolveAgentBareMode,
|
|
7697
8067
|
resolveCopilotAgentDisabledMcpServers,
|
|
8068
|
+
resolveAgentHermeticHarness,
|
|
7698
8069
|
applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
|
|
7699
8070
|
applyCcWorkerPoolForceOnMigration,
|
|
7700
8071
|
runtimeConfigWarnings,
|
|
@@ -7721,6 +8092,7 @@ module.exports = {
|
|
|
7721
8092
|
mergeManifestAllowedTools,
|
|
7722
8093
|
formatManifestRejection,
|
|
7723
8094
|
getProjects,
|
|
8095
|
+
projectDisplayName,
|
|
7724
8096
|
formatUnknownProjectError,
|
|
7725
8097
|
findProjectByName,
|
|
7726
8098
|
findProjectByNameOrPath,
|
|
@@ -7786,6 +8158,9 @@ module.exports = {
|
|
|
7786
8158
|
assertWorktreeOutsideProject,
|
|
7787
8159
|
resolveProjectRootDir,
|
|
7788
8160
|
resolveSpawnPaths,
|
|
8161
|
+
validateWorkItemWorkdir,
|
|
8162
|
+
applyWorkdir,
|
|
8163
|
+
filterProjectHarnessDirsForWorkdir,
|
|
7789
8164
|
READ_ONLY_ROOT_TASK_TYPES,
|
|
7790
8165
|
isLiveCommandCenterPath,
|
|
7791
8166
|
describeCcProtectedPaths,
|