@ran-sh/dsh-crew 2.2.9 → 2.2.11

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "2.2.9",
3
+ "version": "2.2.11",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "2.2.9",
3
+ "version": "2.2.11",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -27,6 +27,8 @@ import {
27
27
  defaultWorktreeRoot,
28
28
  inspectRepository,
29
29
  resetWorktreeTo,
30
+ sameRepoPath,
31
+ withSafeDirectory,
30
32
  } from './workspace-isolation.mjs';
31
33
  import { acquireWorkspaceLock, DEFAULT_LOCK_WAIT_MS } from './workspace-lock.mjs';
32
34
 
@@ -81,6 +83,14 @@ export function crewWorkspaceLockPath({ repoRoot, role, root = defaultWorktreeRo
81
83
  */
82
84
  export async function ensureCrewWorkspace({
83
85
  cwd,
86
+ // The repository root the REQUEST named, when Crew may trust it for this job's git
87
+ // subprocesses. A shared checkout can be owned by another account, and Git refuses
88
+ // to read it without a `safe.directory` exception; the exception is scoped to this
89
+ // one root, and the repository Git resolves must be exactly that root — anything
90
+ // else (a subdirectory cwd, a different repo) fails closed rather than widening the
91
+ // trust. Worktree-local operations do not get the exception: the worktree Crew
92
+ // creates is owned by the account Crew runs as.
93
+ trustedRepoRoot = null,
84
94
  role,
85
95
  baseRevision,
86
96
  at,
@@ -92,8 +102,16 @@ export async function ensureCrewWorkspace({
92
102
  isAlive,
93
103
  pid,
94
104
  } = {}) {
95
- const repo = await inspectRepository({ cwd, git });
105
+ const run = trustedRepoRoot ? withSafeDirectory(git, trustedRepoRoot) : git;
106
+ const repo = await inspectRepository({ cwd, git: run });
96
107
  if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
108
+ if (trustedRepoRoot && !sameRepoPath(repo.repoRoot, trustedRepoRoot)) {
109
+ return {
110
+ ok: false,
111
+ reason: 'WORKSPACE_TRUST_ANCHOR_MISMATCH',
112
+ error: `git resolved ${repo.repoRoot}, not the trusted root ${trustedRepoRoot}; run the job at the repository root or register a workspace context`,
113
+ };
114
+ }
97
115
  const repoRoot = repo.repoRoot;
98
116
  const revision = baseRevision ?? repo.baseRevision;
99
117
  const name = crewWorkspaceName(role);
@@ -109,7 +127,7 @@ export async function ensureCrewWorkspace({
109
127
  return { ok: false, reason, error };
110
128
  };
111
129
 
112
- const reused = await crewOwnsWorktree({ worktreePath, repoRoot, git });
130
+ const reused = await crewOwnsWorktree({ worktreePath, repoRoot, git: run });
113
131
  if (!reused) {
114
132
  if (existsSync(worktreePath)) {
115
133
  // Something is there that Crew did not create. Deleting it would be a
@@ -117,7 +135,7 @@ export async function ensureCrewWorkspace({
117
135
  // say which path is in the way.
118
136
  return fail(WORKSPACE_CONFLICT, `workspace path exists but is not a Crew worktree of this repository: ${worktreePath}`);
119
137
  }
120
- const created = await createWorktreeAt({ dir: worktreePath, repoRoot, revision, purpose: name, at, git });
138
+ const created = await createWorktreeAt({ dir: worktreePath, repoRoot, revision, purpose: name, at, git: run });
121
139
  if (!created.ok) return fail(created.reason, created.error);
122
140
  }
123
141
 
@@ -1249,10 +1249,11 @@ export function installHudSegment({ home = homedir() } = {}) {
1249
1249
 
1250
1250
  // The paths Crew has recorded as its own in this Claude host: the marketplace records
1251
1251
  // it wrote (under the current and pre-rename names, in settings and in the CLI's own
1252
- // cache), the constant an older installer used, and the checkout this module runs
1253
- // from. Only these are grounds for removing anything: a directory that merely happens
1254
- // to be named `dsh-crew` belongs to whoever made it, and a path that is not recorded
1255
- // here cannot be identified as Crew's, so it is left alone.
1252
+ // cache) and the constant an older installer used. Only these are grounds for removing
1253
+ // anything: a directory that merely happens to be named `dsh-crew` belongs to whoever
1254
+ // made it, the module's own checkout is not evidence (an uninstall may run from a
1255
+ // checkout this host never recorded), and a path that is not recorded here is left
1256
+ // alone.
1256
1257
  function crewOwnedRoots({ home, settings, markets, installed }) {
1257
1258
  const roots = [];
1258
1259
  const push = (value) => { if (typeof value === 'string' && value.trim() && !roots.includes(value.trim())) roots.push(value.trim()); };
@@ -1266,7 +1267,6 @@ function crewOwnedRoots({ home, settings, markets, installed }) {
1266
1267
  for (const entry of entries) push(entry?.installPath);
1267
1268
  }
1268
1269
  push(join(home, '.config', 'dsh-crew', 'marketplace'));
1269
- push(ROOT);
1270
1270
  return roots;
1271
1271
  }
1272
1272
 
@@ -2785,6 +2785,9 @@ export async function npxLogs({ args = [], log = console.log } = {}) {
2785
2785
  const keep = Number(args[1]);
2786
2786
  const result = pruneCrewTempLogs(Number.isInteger(keep) && keep > 0 ? { keepRuns: keep } : {});
2787
2787
  log(`✓ Crew diagnostics pruned: removed ${result.removed.length} file(s); kept ${result.kept.length} run(s)`);
2788
+ if (result.failed?.length) {
2789
+ log(`- ${result.failed.length} file(s) could not be deleted (a running process may hold them); they are counted in neither number and the next prune retries`);
2790
+ }
2788
2791
  return { ok: true, ...result };
2789
2792
  }
2790
2793
 
package/src/log-prune.mjs CHANGED
@@ -26,7 +26,14 @@ const LOG_FAMILIES = [
26
26
  { prefix: 'dsh-crew-dsh-crew-3210-', suffix: '.out.log', keep: 10 },
27
27
  { prefix: 'dsh-crew-web-', suffix: '.out.log', keep: 20 },
28
28
  { prefix: 'dsh-official-web-', suffix: '.out.log', keep: 20 },
29
- ];
29
+ ].map((family) => ({
30
+ ...family,
31
+ // An `.err.log` whose `.out.log` sibling is gone (or was never written) is still a
32
+ // Crew diagnostic, so each family recognizes its error files as prunable runs too.
33
+ errStamp: (name) => name.startsWith(family.prefix) && name.endsWith('.err.log')
34
+ ? name.slice(family.prefix.length, name.length - '.err.log'.length) || null
35
+ : null,
36
+ }));
30
37
 
31
38
  // `dsh-crew-<profile>-<port>-<stamp>.out.log` -> the stamp identifies one run, and
32
39
  // its .err.log sibling must live or die with it.
@@ -51,14 +58,28 @@ export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = null, maxAgeD
51
58
  const runs = new Map();
52
59
  for (const name of names) {
53
60
  const stamp = runStamp(name, family);
54
- if (!stamp) continue;
61
+ if (stamp) {
62
+ const file = join(tempDir, name);
63
+ let mtimeMs = 0;
64
+ try { mtimeMs = statSync(file).mtimeMs; } catch { continue; }
65
+ const run = runs.get(stamp) ?? { stamp, files: [], newest: 0 };
66
+ run.files.push(name);
67
+ run.newest = Math.max(run.newest, mtimeMs);
68
+ runs.set(stamp, run);
69
+ continue;
70
+ }
71
+ // An orphan `.err.log` (its `.out.log` sibling already pruned or never written)
72
+ // is still a Crew diagnostic: prune it on its own stamp so a half-written run
73
+ // cannot outlive every bound.
74
+ const errStamp = family.errStamp(name);
75
+ if (!errStamp) continue;
55
76
  const file = join(tempDir, name);
56
77
  let mtimeMs = 0;
57
78
  try { mtimeMs = statSync(file).mtimeMs; } catch { continue; }
58
- const run = runs.get(stamp) ?? { stamp, files: [], newest: 0 };
79
+ const run = runs.get(errStamp) ?? { stamp: errStamp, files: [], newest: 0 };
59
80
  run.files.push(name);
60
81
  run.newest = Math.max(run.newest, mtimeMs);
61
- runs.set(stamp, run);
82
+ runs.set(errStamp, run);
62
83
  }
63
84
  const ordered = [...runs.values()].sort((left, right) => right.newest - left.newest);
64
85
  const keep = keepOverride ?? family.keep;
@@ -73,15 +94,23 @@ export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = null, maxAgeD
73
94
  // bound exists to stop. A burst of eleven young runs still keeps ten.
74
95
  if ((tooOld || tooMany) && !tooRecentToTouch) {
75
96
  for (const name of run.files) removed.push(name);
76
- const errName = run.files[0].replace(/\.out\.log$/, '.err.log');
77
- if (names.includes(errName)) removed.push(errName);
78
97
  } else {
79
98
  kept.push(run.stamp);
80
99
  }
81
100
  });
82
101
  }
83
- for (const name of removed) {
84
- try { rmSync(join(tempDir, name), { force: true }); } catch { /* a locked file stays until the next pass */ }
102
+ // `removed` lists what is actually gone. A locked file (Windows holds a log open
103
+ // while its process runs) is reported in `failed` instead of pretending a deletion
104
+ // happened: the next pass tries again, and the operator is not told a number that
105
+ // is not true.
106
+ const failed = [];
107
+ for (const name of [...new Set(removed)]) {
108
+ try { rmSync(join(tempDir, name), { force: true }); } catch { failed.push(name); }
85
109
  }
86
- return { ok: true, removed: [...new Set(removed)].sort(), kept: kept.sort() };
110
+ return {
111
+ ok: failed.length === 0 ? true : 'partial',
112
+ removed: removed.filter((name) => !failed.includes(name)).sort(),
113
+ kept: kept.sort(),
114
+ ...(failed.length ? { failed: failed.sort() } : {}),
115
+ };
87
116
  }
@@ -14,6 +14,7 @@ import {
14
14
  cleanupIsolatedWorkspace,
15
15
  inspectRepository,
16
16
  captureCandidate as captureIsolationCandidate,
17
+ withSafeDirectory,
17
18
  } from './workspace-isolation.mjs';
18
19
  import { startJob, waitJob, jobView, cancelJob } from './jobs.mjs';
19
20
  import { hub } from './hub-client.mjs';
@@ -297,7 +298,13 @@ export function buildMcpWorkflowRuntime(deps) {
297
298
  }
298
299
  // Isolated roles fail closed when the workspace
299
300
  // is not a git repo — never silently fall back to sharing the working tree.
300
- const repo = await inspectRepository({ cwd: job.requested_cwd });
301
+ // The pre-check and the workspace entry share one scoped exception: the
302
+ // requested cwd is the only root this dispatch may trust, and only when Git
303
+ // resolves it as the repository root (a shared checkout owned by another
304
+ // account is the case this exists for).
305
+ const trustedRoot = job.requested_cwd;
306
+ const scopedGit = withSafeDirectory(null, trustedRoot);
307
+ const repo = await inspectRepository({ cwd: job.requested_cwd, git: scopedGit });
301
308
  if (!repo.ok) {
302
309
  return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `${job.role ?? 'worker'} needs an isolated git worktree: ${repo.error ?? repo.reason}` };
303
310
  }
@@ -309,6 +316,7 @@ export function buildMcpWorkflowRuntime(deps) {
309
316
  // cannot share it.
310
317
  const workspace = await ensureCrewWorkspace({
311
318
  cwd: job.requested_cwd,
319
+ trustedRepoRoot: trustedRoot,
312
320
  role: job.role ?? 'worker',
313
321
  baseRevision: job.workspace_branch ?? repo.baseRevision,
314
322
  });
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '2.2.9';
39
+ export const RUNTIME_VERSION = '2.2.11';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -251,7 +251,12 @@ export function reprojectRuntimeModelCallability(snapshot, { enabled_roles = {},
251
251
  const roles = {};
252
252
  for (const role of ['worker', 'reviewer']) {
253
253
  if (enabled_roles?.[role] === false) {
254
- roles[role] = { state: 'NOT_APPLICABLE', reason_code: 'ROLE_DISABLED', selected: null, last_success: null };
254
+ // The hub may still record a selected route for a role this session disables;
255
+ // keeping that route here (rather than nulling it) is what lets the canonical
256
+ // validation below accept the reprojection. Nulling it manufactured a
257
+ // selection mismatch against the unchanged outer snapshot and read the whole
258
+ // contract as DEGRADED for an intentionally disabled role.
259
+ roles[role] = { state: 'NOT_APPLICABLE', reason_code: 'ROLE_DISABLED', selected: snapshot[role]?.selected ?? null, last_success: null };
255
260
  } else if (sourceProjection.enabled_roles?.[role] === true) {
256
261
  roles[role] = structuredClone(sourceProjection.roles[role]);
257
262
  } else {
@@ -275,9 +280,14 @@ export function reprojectRuntimeModelCallability(snapshot, { enabled_roles = {},
275
280
  projection: projected,
276
281
  runtime: snapshot.runtime,
277
282
  expectedEnabledRoles: projected.enabled_roles,
283
+ // Expected selections are the routes the snapshot RECORDED, for disabled roles
284
+ // too: the canonical rule is "a disabled role keeps its route", and expecting
285
+ // null here would contradict the very projection this function just built — the
286
+ // mismatch then discarded a healthy worker's evidence and read the session as
287
+ // degraded for a role that was intentionally switched off.
278
288
  expectedSelections: {
279
- worker: enabled_roles.worker === true ? snapshot.worker?.selected ?? null : null,
280
- reviewer: enabled_roles.reviewer === true ? snapshot.reviewer?.selected ?? null : null,
289
+ worker: snapshot.worker?.selected ?? null,
290
+ reviewer: snapshot.reviewer?.selected ?? null,
281
291
  },
282
292
  now,
283
293
  });
@@ -77,6 +77,33 @@ async function runGit(runner, args, opts) {
77
77
  }
78
78
  }
79
79
 
80
+ function repoPathKey(value) {
81
+ try {
82
+ const p = resolve(String(value ?? ''));
83
+ return process.platform === 'win32' ? p.toLowerCase() : p;
84
+ } catch { return String(value ?? ''); }
85
+ }
86
+
87
+ /** Whether two paths name the same repository root on this platform. */
88
+ export function sameRepoPath(left, right) {
89
+ const a = repoPathKey(left);
90
+ const b = repoPathKey(right);
91
+ return a.length > 0 && a === b;
92
+ }
93
+
94
+ // A shared checkout can be owned by another Windows account, and Git refuses even to
95
+ // read it ("dubious ownership"); the operator's usual remedy, a global
96
+ // `safe.directory` entry, is one Crew must never write. `-c safe.directory=<root>` is
97
+ // the scoped form: it lives and dies with the subprocess, persists nothing, and names
98
+ // exactly one repository rather than a wildcard. Callers pass the root the request
99
+ // itself named, and the workspace entry fails closed when Git resolves any other
100
+ // repository.
101
+ export function withSafeDirectory(git, trustedRepoRoot) {
102
+ const run = git ?? defaultRunner;
103
+ const safe = resolve(String(trustedRepoRoot ?? ''));
104
+ return (args, opts) => run(['-c', `safe.directory=${safe}`, ...args], opts);
105
+ }
106
+
80
107
  const PURPOSE_MAX = 32;
81
108
 
82
109
  /** Local wall-clock stamp: readable, and what an operator expects to see. */