@yemi33/minions 0.1.2383 → 0.1.2384

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/cli.js CHANGED
@@ -653,6 +653,12 @@ const commands = {
653
653
 
654
654
  const hadPid = agentPid && agentPid > 0; // track before liveness check
655
655
  if (agentPid && agentPid > 0 && !isPidAlive(agentPid)) agentPid = null;
656
+ if (agentPid && !shared.isDispatchExecutionPathSafe(item, config)) {
657
+ e.log('warn', `Reattach: refusing ${item.id} — persisted execution path is not safe under current checkout config`);
658
+ try { shared.killImmediate({ pid: agentPid }); }
659
+ catch (err) { e.log('warn', `Reattach: failed to terminate unsafe dispatch ${item.id}: ${err.message}`); }
660
+ agentPid = null;
661
+ }
656
662
 
657
663
  // PID was found but confirmed dead — exempt from restart grace period (#869)
658
664
  if (hadPid && !agentPid) {
@@ -812,18 +818,14 @@ const commands = {
812
818
  );
813
819
  } catch (err) { e.log('warn', `Orphan dispatch complete: ${err.message}`); }
814
820
 
815
- // P-d9e6b2c4 live-mode dispatch-end auto-restore + terminal-failure
816
- // notify on the engine-restart reattach completion path. originalRef
817
- // is only persisted for live-mode dispatches (P-c5a1f3b8), so its
818
- // presence is the liveMode signal here — the slimmed meta.project
819
- // carries no checkoutMode. Mirrors the in-process onAgentClose wiring
820
- // (engine.js). Fire-and-forget + best-effort: the helper swallows its
821
- // own errors and only ever issues a plain `git checkout <originalRef>`
822
- // (never --force/reset/clean/stash) against the operator tree.
823
- if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
821
+ // Restore only records explicitly dispatched live and still configured
822
+ // live. Worktree mode fails closed even if stale state carries
823
+ // originalRef fields.
824
+ if (shared.isLiveCheckoutDispatchRecord(item, config)) {
824
825
  try {
825
826
  require('./live-checkout').maybeRestoreLiveCheckoutFromRecord({
826
827
  item,
828
+ config,
827
829
  isTerminalFailure: !isSuccess,
828
830
  resultLabel: result,
829
831
  log: (lvl, msg) => e.log(lvl, msg),
@@ -1201,6 +1203,38 @@ const commands = {
1201
1203
  const timeout = config.engine?.shutdownTimeout || shared.ENGINE_DEFAULTS.shutdownTimeout;
1202
1204
  const deadline = Date.now() + timeout;
1203
1205
 
1206
+ async function cancelTimedOutPooled(ids) {
1207
+ const closing = [];
1208
+ for (const id of ids) {
1209
+ const procInfo = e.activeProcesses.get(id);
1210
+ if (!procInfo?._pooled) continue;
1211
+ try {
1212
+ procInfo.proc.kill();
1213
+ if (typeof procInfo.proc.waitForClose === 'function') {
1214
+ closing.push(procInfo.proc.waitForClose());
1215
+ }
1216
+ } catch (err) {
1217
+ e.log('warn', `Failed to cancel pooled dispatch ${id} during shutdown: ${err.message}`);
1218
+ }
1219
+ }
1220
+ await Promise.allSettled(closing);
1221
+
1222
+ const stillActive = new Set((getDispatch().active || []).map(item => item.id));
1223
+ for (const id of ids) {
1224
+ e.activeProcesses.delete(id);
1225
+ e.realActivityMap.delete(id);
1226
+ if (!stillActive.has(id)) continue;
1227
+ e.completeDispatch(
1228
+ id,
1229
+ DISPATCH_RESULT.ERROR,
1230
+ 'Pooled dispatch cancelled after graceful shutdown timeout',
1231
+ '',
1232
+ { failureClass: FAILURE_CLASS.TIMEOUT, retryable: true },
1233
+ );
1234
+ }
1235
+ finishShutdown(`Shutdown timeout (${timeout / 1000}s) — cancelled ${ids.length} pooled agent(s) for retry.`, 1);
1236
+ }
1237
+
1204
1238
  const poll = setInterval(() => {
1205
1239
  pooledIds = getOutstandingPooledIds();
1206
1240
  if (pooledIds.length === 0 && agentWorkerPool.isDrained()) {
@@ -1210,24 +1244,10 @@ const commands = {
1210
1244
  }
1211
1245
  if (Date.now() >= deadline) {
1212
1246
  clearInterval(poll);
1213
- for (const id of pooledIds) {
1214
- const procInfo = e.activeProcesses.get(id);
1215
- if (procInfo?._pooled) {
1216
- try { procInfo.proc.kill(); } catch (err) {
1217
- e.log('warn', `Failed to cancel pooled dispatch ${id} during shutdown: ${err.message}`);
1218
- }
1219
- e.activeProcesses.delete(id);
1220
- e.realActivityMap.delete(id);
1221
- }
1222
- e.completeDispatch(
1223
- id,
1224
- DISPATCH_RESULT.ERROR,
1225
- 'Pooled dispatch cancelled after graceful shutdown timeout',
1226
- '',
1227
- { failureClass: FAILURE_CLASS.TIMEOUT, retryable: true },
1228
- );
1229
- }
1230
- finishShutdown(`Shutdown timeout (${timeout / 1000}s) — cancelled ${pooledIds.length} pooled agent(s) for retry.`, 1);
1247
+ void cancelTimedOutPooled(pooledIds).catch((err) => {
1248
+ e.log('error', `Pooled shutdown cleanup failed: ${err.message}`);
1249
+ finishShutdown(`Shutdown cleanup failed after timeout: ${err.message}`, 1);
1250
+ });
1231
1251
  }
1232
1252
  }, 2000);
1233
1253
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * engine/create-pr-worktree.js — checkout-mode-aware Create-PR staging.
3
3
  *
4
- * Background (the bug this fixes): the CC "Create PR" flow has the LLM commit /
4
+ * Background (the bug this fixes): the CC "Create PR" flow used to have the LLM commit /
5
5
  * branch / push directly in the project's LIVE operator checkout
6
6
  * (`project.localPath`). For a `checkoutMode: "worktree"` project that is wrong —
7
7
  * a misstep by the LLM (committing before branching, freelancing `git pull`)
@@ -10,16 +10,13 @@
10
10
  * - `live` → operate in the live checkout (handled by the existing flow);
11
11
  * - `worktree` → operate in an isolated git worktree, never the live checkout.
12
12
  *
13
- * This module implements the `worktree` path. `prepareCreatePrWorktree` moves the
14
- * uncommitted edits CC made in the live checkout into a fresh worktree (created
15
- * off the same base commit, on a new branch) and then restores the live checkout
16
- * clean DETERMINISTICALLY, server-side, so nothing relies on the LLM running
17
- * git correctly. CC is then told to commit / push / open the PR inside the
18
- * returned worktree path, and to call `cleanupCreatePrWorktree` when done.
13
+ * This module implements the `worktree` path. `prepareCreatePrWorktree` copies
14
+ * pre-existing operator edits into a fresh worktree created from the configured
15
+ * main tip. The source checkout is never reset, cleaned, checked out, or deleted
16
+ * from; the operator remains the sole owner of that tree.
19
17
  *
20
- * Safety ordering: the worktree is populated and verified BEFORE the live
21
- * checkout is touched. If applying the diff fails, the worktree is removed and
22
- * the live checkout is left exactly as it was — the operator never loses work.
18
+ * If applying the diff fails, the worktree is removed and the source checkout is
19
+ * left exactly as it was.
23
20
  */
24
21
 
25
22
  const fs = require('fs');
@@ -29,16 +26,15 @@ const shared = require('./shared');
29
26
  const PATCH_APPLY_TIMEOUT_MS = 120000;
30
27
 
31
28
  /**
32
- * Move a worktree-mode project's uncommitted live-checkout changes into an
33
- * isolated worktree and restore the live checkout clean.
29
+ * Copy a worktree-mode project's uncommitted operator changes into an isolated
30
+ * worktree without modifying the source checkout.
34
31
  *
35
32
  * @returns {Promise<{ ok:true, worktreePath, branch, baseBranch, baseSha,
36
33
  * trackedChanged:boolean, untrackedCount:number } | { ok:false, reason:string }>}
37
- * @throws on a git/fs failure AFTER which the live checkout is guaranteed
38
- * untouched (the operator keeps their work).
34
+ * @throws on a git/fs failure; the source checkout remains untouched.
39
35
  */
40
36
  async function prepareCreatePrWorktree({
41
- project, branch, minionsDir, engine, log = () => {}, _git, _fs,
37
+ project, projects, branch, minionsDir, engine, log = () => {}, _git, _fs,
42
38
  } = {}) {
43
39
  const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
44
40
  const fsm = _fs || fs;
@@ -49,12 +45,15 @@ async function prepareCreatePrWorktree({
49
45
  if (!localPath) {
50
46
  throw new Error('prepareCreatePrWorktree: project.localPath required');
51
47
  }
48
+ if (shared.resolveCheckoutMode(project, shared.WORK_TYPE.IMPLEMENT) !== shared.CHECKOUT_MODES.WORKTREE) {
49
+ throw new Error('prepareCreatePrWorktree: project must use checkoutMode "worktree"');
50
+ }
52
51
  const mainBranch = (project.mainBranch && String(project.mainBranch).trim()) || 'main';
53
52
 
54
- // 1. Capture the uncommitted state from the live checkout.
55
- const baseSha = (await git(['-C', localPath, 'rev-parse', 'HEAD'])).trim();
56
- if (!/^[0-9a-f]{7,40}$/i.test(baseSha)) {
57
- throw new Error(`prepareCreatePrWorktree: could not resolve HEAD in ${localPath} (got ${JSON.stringify(baseSha)})`);
53
+ // 1. Capture the uncommitted state from the protected operator checkout.
54
+ const sourceHeadSha = (await git(['-C', localPath, 'rev-parse', 'HEAD'])).trim();
55
+ if (!/^[0-9a-f]{7,40}$/i.test(sourceHeadSha)) {
56
+ throw new Error(`prepareCreatePrWorktree: could not resolve HEAD in ${localPath} (got ${JSON.stringify(sourceHeadSha)})`);
58
57
  }
59
58
  // `git diff HEAD --binary` captures staged + unstaged tracked changes
60
59
  // (including deletions and binary deltas) as a single patch.
@@ -70,9 +69,29 @@ async function prepareCreatePrWorktree({
70
69
  return { ok: false, reason: 'no-changes' };
71
70
  }
72
71
 
73
- // 2. Create an isolated worktree off the SAME base commit the edits were made
74
- // against, on a NEW branch (so it never collides with `main` being checked
75
- // out in the live tree, and never commits onto a shared branch).
72
+ // 2. Resolve the configured main tip without changing the operator checkout.
73
+ // Prefer the fetched remote-tracking ref; local-only repos fall back to the
74
+ // local main branch.
75
+ try {
76
+ await git(['-C', localPath, 'fetch', 'origin', mainBranch], { timeout: 30000 });
77
+ } catch (e) {
78
+ log('warn', `[cc-create-pr] fetch origin ${mainBranch} failed; trying local ${mainBranch}: ${e.message}`);
79
+ }
80
+ let baseSha = '';
81
+ for (const ref of [`refs/remotes/origin/${mainBranch}`, `refs/heads/${mainBranch}`]) {
82
+ try {
83
+ baseSha = (await git(['-C', localPath, 'rev-parse', '--verify', ref])).trim();
84
+ } catch { /* try next base */ }
85
+ if (/^[0-9a-f]{7,40}$/i.test(baseSha)) break;
86
+ baseSha = '';
87
+ }
88
+ if (!baseSha) {
89
+ throw new Error(`prepareCreatePrWorktree: could not resolve origin/${mainBranch} or local ${mainBranch}`);
90
+ }
91
+
92
+ // 3. Create a fresh isolated branch from main, then apply only the captured
93
+ // uncommitted changes. Topic commits currently checked out by the operator
94
+ // never enter the PR.
76
95
  const uid = shared.uid();
77
96
  const sanitized = String(project.name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 40) || 'project';
78
97
  const requested = branch && String(branch).trim();
@@ -86,6 +105,7 @@ async function prepareCreatePrWorktree({
86
105
  const wtRel = (engine && engine.worktreeRoot) || shared.ENGINE_DEFAULTS.worktreeRoot;
87
106
  const worktreesBase = path.resolve(projectRoot, wtRel);
88
107
  const wtPath = path.join(worktreesBase, `cc-createpr-${sanitized}-${uid}`);
108
+ shared.assertWorktreeOutsideProjects(wtPath, Array.isArray(projects) ? projects : [project]);
89
109
  try { fsm.mkdirSync(worktreesBase, { recursive: true }); } catch { /* best-effort; worktree add will surface a real failure */ }
90
110
 
91
111
  await git(
@@ -94,8 +114,8 @@ async function prepareCreatePrWorktree({
94
114
  );
95
115
  try { shared.writeWorktreeOwnerMarker(wtPath, { source: 'cc-create-pr', project: project.name }); } catch { /* marker is best-effort */ }
96
116
 
97
- // 3. Reproduce the live-checkout changes inside the worktree. If anything here
98
- // fails, tear the worktree down and leave the live checkout untouched.
117
+ // 4. Reproduce the operator-checkout changes inside the worktree. If anything
118
+ // here fails, tear the worktree down and leave the source untouched.
99
119
  try {
100
120
  if (hasTracked) {
101
121
  const patchFile = path.join(wtPath, `.cc-createpr-${uid}.patch`);
@@ -111,64 +131,15 @@ async function prepareCreatePrWorktree({
111
131
  }
112
132
  } catch (e) {
113
133
  // Tear the worktree down via shared.removeWorktree (EPERM/EBUSY retry +
114
- // escalation + real-repo refusal — CLAUDE.md footgun #6). The live checkout
115
- // has NOT been touched yet at this point, so the operator keeps their work.
134
+ // escalation + real-repo refusal — CLAUDE.md footgun #6).
116
135
  try { shared.removeWorktree(wtPath, projectRoot, worktreesBase); } catch { /* leak rather than throw a second error */ }
117
136
  throw new Error(
118
137
  `prepareCreatePrWorktree: failed to stage changes into the worktree — ${e.message}. ` +
119
- 'The live checkout was left untouched.',
120
- );
121
- }
122
-
123
- // 4. The worktree now holds the changes — restore the live checkout clean.
124
- // The captured edits are SAFE in the worktree (step 3 verified), so the
125
- // documented job here is to return the live checkout to a clean HEAD.
126
- // Use `git reset --hard HEAD` (NOT `git checkout -- .`): the old
127
- // working-tree-only revert left the STAGED INDEX intact, so any change CC
128
- // had `git add`-ed stayed staged → the live checkout was reported "restored"
129
- // while still dirty, wedging the next Create-PR / live dispatch (the
130
- // operator's "no recovery from dirty checkout"). `reset --hard` reverts
131
- // staged + unstaged tracked changes in one shot; untracked entries are
132
- // removed surgically below (only the ones we captured — never ignored or
133
- // pre-existing untracked files, which `git clean` would wrongly nuke).
134
- // This `reset --hard` is intentional and scoped to THIS Create-PR-worktree
135
- // staging flow whose explicit contract is to restore the live checkout
136
- // clean — it is NOT the live-checkout DISPATCH mode (which never resets).
137
- const residualUntracked = [];
138
- try {
139
- await git(['-C', localPath, 'reset', '--hard', 'HEAD']);
140
- for (const rel of untracked) {
141
- const target = path.join(localPath, rel.replace(/\/$/, ''));
142
- try {
143
- // Retry transient Windows file locks (EBUSY/EPERM/ENOTEMPTY) before
144
- // giving up — a single failed unlink is what left the tree dirty. A few
145
- // quick retries ride out a transient AV/indexer lock without a long stall.
146
- shared._retryFsOp(() => fsm.rmSync(target, { recursive: true, force: true }), `cc-create-pr rm ${rel}`, { attempts: 4, baseMs: 100 });
147
- } catch (rmErr) {
148
- residualUntracked.push(rel);
149
- log('warn', `[cc-create-pr] could not remove untracked ${rel} from live checkout after retries: ${rmErr.message}`);
150
- }
151
- }
152
- } catch (e) {
153
- // shared.removeWorktree carries the EPERM/EBUSY retry + escalation +
154
- // real-repo refusal (CLAUDE.md footgun #6 — don't hand-roll force-remove).
155
- try { shared.removeWorktree(wtPath, projectRoot, worktreesBase); } catch { /* leak rather than double-throw */ }
156
- throw new Error(
157
- `prepareCreatePrWorktree: failed to restore live checkout — ${e.message}. ` +
158
- `Worktree at ${wtPath} may need manual cleanup.`,
138
+ 'The operator checkout was left untouched.',
159
139
  );
160
140
  }
161
141
 
162
- // If untracked removal could not fully clean the live tree, surface it in the
163
- // return (liveTreeDirty) instead of silently reporting success — the caller
164
- // can warn CC / the operator rather than letting the residue wedge the next
165
- // dispatch with a phantom "dirty" refusal.
166
- const liveTreeDirty = residualUntracked.length > 0;
167
- if (liveTreeDirty) {
168
- log('warn', `[cc-create-pr] live checkout ${localPath} left with ${residualUntracked.length} residual untracked path(s) after staging; surfacing liveTreeDirty`);
169
- } else {
170
- log('info', `[cc-create-pr] staged ${project.name} changes into isolated worktree ${wtPath} on branch ${branchName} (live checkout restored)`);
171
- }
142
+ log('info', `[cc-create-pr] copied ${project.name} changes into isolated worktree ${wtPath} on branch ${branchName}; operator checkout preserved`);
172
143
  return {
173
144
  ok: true,
174
145
  worktreePath: wtPath,
@@ -177,8 +148,7 @@ async function prepareCreatePrWorktree({
177
148
  baseSha,
178
149
  trackedChanged: hasTracked,
179
150
  untrackedCount: untracked.length,
180
- liveTreeDirty,
181
- residualUntracked,
151
+ sourceCheckoutPreserved: true,
182
152
  };
183
153
  }
184
154
 
@@ -187,7 +157,7 @@ async function prepareCreatePrWorktree({
187
157
  * touch a path that doesn't carry the minions ownership marker (so a bad/forged
188
158
  * path can never delete an arbitrary directory).
189
159
  */
190
- async function cleanupCreatePrWorktree({ worktreePath, project, _git, _fs } = {}) {
160
+ async function cleanupCreatePrWorktree({ worktreePath, project, projects, _git, _fs } = {}) {
191
161
  const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
192
162
  const fsm = _fs || fs;
193
163
  if (!worktreePath || typeof worktreePath !== 'string') {
@@ -200,6 +170,14 @@ async function cleanupCreatePrWorktree({ worktreePath, project, _git, _fs } = {}
200
170
  if (!shared.hasWorktreeOwnerMarker(worktreePath)) {
201
171
  return { ok: false, reason: 'not-owned' };
202
172
  }
173
+ try {
174
+ shared.assertWorktreeOutsideProjects(
175
+ worktreePath,
176
+ Array.isArray(projects) ? projects : (project ? [project] : shared.getProjects()),
177
+ );
178
+ } catch {
179
+ return { ok: false, reason: 'protected-project-overlap' };
180
+ }
203
181
  const fromDir = (project && project.localPath) || worktreePath;
204
182
  try {
205
183
  await git(['-C', fromDir, 'worktree', 'remove', '--force', worktreePath]);
@@ -81,6 +81,20 @@ function validateKeepPidsRecord(parsed, opts) {
81
81
  if (typeof parsed.cwd === 'string' && parsed.cwd.length > 500) {
82
82
  return { ok: false, reason: 'cwd-too-long' };
83
83
  }
84
+ if (opts.allowedCwdRoot) {
85
+ if (typeof parsed.cwd !== 'string' || parsed.cwd.length === 0) {
86
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-required-for-dispatch-confinement' };
87
+ }
88
+ try {
89
+ const cwdReal = shared.realPathForComparison(parsed.cwd);
90
+ const rootReal = shared.realPathForComparison(opts.allowedCwdRoot);
91
+ if (!shared.isPathInsideOrEqual(cwdReal, rootReal)) {
92
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-outside-dispatch-worktree' };
93
+ }
94
+ } catch (err) {
95
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-containment-check-failed (' + err.message + ')' };
96
+ }
97
+ }
84
98
  // W-mp6k7ywi000fa33c — when the engine requires a real git workdir
85
99
  // (default true; per-WI override via `meta.keep_processes_skip_workdir_check`),
86
100
  // verify the recorded `cwd` looks like a real worktree. Empty/missing
@@ -110,22 +124,105 @@ function validateKeepPidsRecord(parsed, opts) {
110
124
  if (parsed.wi_id != null && typeof parsed.wi_id !== 'string') {
111
125
  return { ok: false, reason: 'wi_id-not-string' };
112
126
  }
127
+ for (const field of ['project', 'work_type', 'checkout_mode', 'dispatch_root']) {
128
+ if (parsed[field] != null && typeof parsed[field] !== 'string') {
129
+ return { ok: false, reason: field + '-not-string' };
130
+ }
131
+ }
132
+
133
+ let canonicalCwd = typeof parsed.cwd === 'string' ? parsed.cwd : '';
134
+ if (canonicalCwd) {
135
+ try { canonicalCwd = shared.realPathForComparison(canonicalCwd); }
136
+ catch (err) {
137
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-canonicalization-failed (' + err.message + ')' };
138
+ }
139
+ }
140
+ let canonicalDispatchRoot = typeof parsed.dispatch_root === 'string' ? parsed.dispatch_root : '';
141
+ if (canonicalDispatchRoot) {
142
+ try { canonicalDispatchRoot = shared.realPathForComparison(canonicalDispatchRoot); }
143
+ catch (err) {
144
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'dispatch-root-canonicalization-failed (' + err.message + ')' };
145
+ }
146
+ }
113
147
 
114
148
  return {
115
149
  ok: true,
116
150
  value: {
117
151
  pids: pids,
118
152
  purpose: typeof parsed.purpose === 'string' ? parsed.purpose : '',
119
- cwd: typeof parsed.cwd === 'string' ? parsed.cwd : '',
153
+ cwd: canonicalCwd,
120
154
  ports: Array.isArray(parsed.ports) ? parsed.ports.map(Number) : [],
121
155
  expires_at: parsed.expires_at,
122
156
  expiresAtMs: expiresAtMs,
123
157
  written_by: typeof parsed.written_by === 'string' ? parsed.written_by : '',
124
158
  wi_id: typeof parsed.wi_id === 'string' ? parsed.wi_id : '',
159
+ project: typeof parsed.project === 'string' ? parsed.project : '',
160
+ work_type: typeof parsed.work_type === 'string' ? parsed.work_type : '',
161
+ checkout_mode: typeof parsed.checkout_mode === 'string' ? parsed.checkout_mode : '',
162
+ dispatch_root: canonicalDispatchRoot,
125
163
  },
126
164
  };
127
165
  }
128
166
 
167
+ function validateKeepProcessExecutionContext(record, opts) {
168
+ opts = opts || {};
169
+ if (!record) return { ok: false, reason: 'record-missing' };
170
+ if (!record.cwd) {
171
+ return record.checkout_mode || record.dispatch_root
172
+ ? { ok: false, reason: 'cwd-missing' }
173
+ : { ok: true, legacy: true };
174
+ }
175
+ let projects;
176
+ try {
177
+ projects = Array.isArray(opts.projects) ? opts.projects : shared.getProjects(opts.config);
178
+ } catch {
179
+ return { ok: false, reason: 'project-config-unavailable' };
180
+ }
181
+
182
+ for (const project of projects) {
183
+ if (!project?.localPath) continue;
184
+ const mode = shared.resolveCheckoutMode(
185
+ project,
186
+ record.work_type || shared.WORK_TYPE.IMPLEMENT,
187
+ );
188
+ if (mode !== shared.CHECKOUT_MODES.WORKTREE) continue;
189
+ try {
190
+ if (shared.pathsOverlap(record.cwd, project.localPath)) {
191
+ return { ok: false, reason: 'cwd-overlaps-worktree-mode-operator-checkout' };
192
+ }
193
+ } catch {
194
+ return { ok: false, reason: 'cwd-overlap-check-failed' };
195
+ }
196
+ }
197
+
198
+ const project = record.project ? shared.findProjectByName(projects, record.project) : null;
199
+ if (project && shared.resolveCheckoutMode(project, record.work_type || shared.WORK_TYPE.IMPLEMENT) === shared.CHECKOUT_MODES.LIVE) {
200
+ try {
201
+ if (!shared.isPathInsideOrEqual(record.cwd, project.localPath)) {
202
+ return { ok: false, reason: 'live-cwd-outside-project' };
203
+ }
204
+ } catch {
205
+ return { ok: false, reason: 'live-cwd-unavailable' };
206
+ }
207
+ return { ok: true };
208
+ }
209
+
210
+ if (!record.dispatch_root) {
211
+ return record.checkout_mode
212
+ ? { ok: false, reason: 'dispatch-root-missing' }
213
+ : { ok: true, legacy: true };
214
+ }
215
+ try {
216
+ shared.assertWorktreeOutsideProjects(record.dispatch_root, projects);
217
+ if (!shared.isPathInsideOrEqual(record.cwd, record.dispatch_root)) {
218
+ return { ok: false, reason: 'cwd-outside-dispatch-root' };
219
+ }
220
+ } catch {
221
+ return { ok: false, reason: 'dispatch-root-overlaps-operator-checkout' };
222
+ }
223
+ return { ok: true };
224
+ }
225
+
129
226
  function readKeepPidsFile(agentId, opts) {
130
227
  opts = opts || {};
131
228
  const filePath = path.join(_agentsDir(), agentId, KEEP_PIDS_FILENAME);
@@ -198,6 +295,16 @@ function sweepKeepProcesses(opts) {
198
295
  const value = rec.value;
199
296
  const filePath = rec.filePath;
200
297
  const agentId = rec.agentId;
298
+ const executionContext = validateKeepProcessExecutionContext(value, opts);
299
+ if (!executionContext.ok) {
300
+ for (const pid of value.pids) {
301
+ if (killOne(pid)) stats.killedPids++;
302
+ _audit('unsafe-context-kill', { agentId, pid, reason: executionContext.reason, wi_id: value.wi_id });
303
+ }
304
+ try { fs.unlinkSync(filePath); } catch {}
305
+ log('warn', 'keep-processes: removed unsafe execution context for ' + agentId + ' — ' + executionContext.reason);
306
+ continue;
307
+ }
201
308
  if (value.expiresAtMs < now) {
202
309
  let killed = 0;
203
310
  for (const pid of value.pids) {
@@ -227,6 +334,7 @@ function getActiveAnchorPids(opts) {
227
334
  for (const rec of listAllKeepPidsFiles({ now: now })) {
228
335
  if (!rec.valid) continue;
229
336
  if (rec.value.expiresAtMs < now) continue;
337
+ if (!validateKeepProcessExecutionContext(rec.value, opts).ok) continue;
230
338
  for (const pid of rec.value.pids) out.add(pid);
231
339
  }
232
340
  return out;
@@ -237,13 +345,7 @@ function getActiveAnchorPidsForAgent(agentId, opts) {
237
345
  const now = Number.isFinite(opts.now) ? opts.now : Date.now();
238
346
  const out = new Set();
239
347
  if (!agentId) return { pids: out, record: null };
240
- // W-mp6k7ywi000fa33c forward `requireGitWorkdir` (and any future opts)
241
- // so the per-WI override path can disable workdir validation. Earlier
242
- // implementation only forwarded `now`, which silently ignored opts.
243
- const readOpts = { now: now };
244
- if (Object.prototype.hasOwnProperty.call(opts, 'requireGitWorkdir')) {
245
- readOpts.requireGitWorkdir = opts.requireGitWorkdir;
246
- }
348
+ const readOpts = { ...opts, now };
247
349
  const rec = readKeepPidsFile(String(agentId), readOpts);
248
350
  if (!rec) return { pids: out, record: null };
249
351
  if (!rec.valid) {
@@ -251,10 +353,26 @@ function getActiveAnchorPidsForAgent(agentId, opts) {
251
353
  return { pids: out, record: null, reason: rec.reason };
252
354
  }
253
355
  if (rec.value.expiresAtMs < now) return { pids: out, record: rec.value, reason: 'expired' };
356
+ const executionContext = validateKeepProcessExecutionContext(rec.value, opts);
357
+ if (!executionContext.ok) return { pids: out, record: rec.value, reason: executionContext.reason };
254
358
  for (const pid of rec.value.pids) out.add(pid);
255
359
  return { pids: out, record: rec.value };
256
360
  }
257
361
 
362
+ function getActiveKeepProcessCwds(opts) {
363
+ opts = opts || {};
364
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
365
+ const out = [];
366
+ for (const rec of listAllKeepPidsFiles({ now })) {
367
+ if (!rec.valid || rec.value.expiresAtMs < now || !rec.value.cwd) continue;
368
+ if (!validateKeepProcessExecutionContext(rec.value, opts).ok) continue;
369
+ if (alivePids(rec.value.pids, opts).length === 0) continue;
370
+ try { out.push(shared.realPathForComparison(rec.value.cwd)); }
371
+ catch { /* malformed/unreachable cwd contributes no anchor */ }
372
+ }
373
+ return out;
374
+ }
375
+
258
376
  function computeReapPlan(descendants, agentId, opts) {
259
377
  opts = opts || {};
260
378
  const list = (Array.isArray(descendants) ? descendants : []).map(Number)
@@ -311,6 +429,7 @@ function evaluateKeepPidsAcceptance(agentId, opts) {
311
429
  record: rec.value,
312
430
  recordedCwd: rec.value.cwd || null,
313
431
  filePath: rec.filePath,
432
+ parsedRaw: rec.parsed || null,
314
433
  };
315
434
  }
316
435
  const reason = rec.reason || 'unknown';
@@ -368,7 +487,7 @@ function buildKeepProcessesHint(opts) {
368
487
  '{',
369
488
  ' "pids": [12345, 12346],',
370
489
  ' "purpose": "bun run dev (Constellation server + dashboard)",',
371
- ' "cwd": "D:/repos/constellation",',
490
+ ' "cwd": "<absolute path at or below MINIONS_AGENT_CWD>",',
372
491
  ' "ports": [3001, 5173],',
373
492
  ' "expires_at": "<ISO-8601 timestamp <= ' + ttl + ' minutes from now>",',
374
493
  ' "written_by": "' + agentId + '",',
@@ -377,6 +496,7 @@ function buildKeepProcessesHint(opts) {
377
496
  '```',
378
497
  '',
379
498
  'Caps the engine enforces: max ' + maxPids + ' PIDs, TTL <= ' + maxTtl + ' minutes, `purpose`/`cwd` <= 500 chars, <= 20 ports. Files outside the cap are dropped (engine reaps as normal) and a warning is logged.',
499
+ '`cwd` MUST resolve at or below the current dispatch root from `MINIONS_AGENT_CWD`. The operator checkout is rejected for worktree-mode dispatches, even when it is a valid Git checkout.',
380
500
  '',
381
501
  'If you do NOT write the file, the engine will kill ALL of your descendant processes when you exit (today\'s default). Do not write the file unless you are intentionally leaving a process behind for the human or follow-up work.',
382
502
  '',
@@ -452,6 +572,8 @@ module.exports = {
452
572
  sweepKeepProcesses: sweepKeepProcesses,
453
573
  getActiveAnchorPids: getActiveAnchorPids,
454
574
  getActiveAnchorPidsForAgent: getActiveAnchorPidsForAgent,
575
+ getActiveKeepProcessCwds: getActiveKeepProcessCwds,
576
+ validateKeepProcessExecutionContext: validateKeepProcessExecutionContext,
455
577
  computeReapPlan: computeReapPlan,
456
578
  buildKeepProcessesHint: buildKeepProcessesHint,
457
579
  evaluateKeepPidsAcceptance: evaluateKeepPidsAcceptance,
@@ -3501,7 +3501,7 @@ async function rebaseBranchOntoMain(pr, project, config) {
3501
3501
  // and produce mirror writes. Misconfigured engine.worktreeRoot is the only
3502
3502
  // way this lands inside root; the assert throws so the caller can recover.
3503
3503
  try {
3504
- shared.assertWorktreeOutsideProject(tmpWt, root);
3504
+ shared.assertWorktreeOutsideProjects(tmpWt, shared.getProjects(config));
3505
3505
  } catch (err) {
3506
3506
  log('warn', `Post-merge rebase: refusing nested worktree path — ${err.message}`);
3507
3507
  return { success: false, error: err.message };
@@ -2034,6 +2034,7 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
2034
2034
  *
2035
2035
  * @param {object} opts
2036
2036
  * @param {object} opts.item persisted dispatch record (has id, originalRef, meta.branch, meta.project.{localPath,name})
2037
+ * @param {object} [opts.config] current config; worktree mode makes this helper fail closed
2037
2038
  * @param {boolean} [opts.isTerminalFailure] true on error/timeout/crash
2038
2039
  * @param {string} [opts.resultLabel] short status word for the alert body
2039
2040
  * @param {function} [opts.log] (level, msg) => void
@@ -2043,8 +2044,9 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
2043
2044
  * @returns {Promise<object>} restore result (or { skipped:true } for a non-live record)
2044
2045
  */
2045
2046
  async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
2046
- const { item, isTerminalFailure, resultLabel, log, writeInboxAlert, gitOpts, _git } = opts;
2047
- if (!item || !item.originalRef || !item.meta?.branch || !item.meta?.project?.localPath) {
2047
+ const { item, config, isTerminalFailure, resultLabel, log, writeInboxAlert, gitOpts, _git } = opts;
2048
+ if (!shared.isLiveCheckoutDispatchRecord(item, config)
2049
+ || !item.originalRef || !item.meta?.branch || !item.meta?.project?.localPath) {
2048
2050
  return { restored: false, reason: 'not-live-record', skipped: true };
2049
2051
  }
2050
2052
  try {