@ran-sh/dsh-crew 2.2.10 → 2.2.12

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.10",
3
+ "version": "2.2.12",
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.10",
3
+ "version": "2.2.12",
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
 
@@ -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.10';
39
+ export const RUNTIME_VERSION = '2.2.12';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -25,6 +25,8 @@ export const NOT_GIT_REPOSITORY = 'NOT_GIT_REPOSITORY';
25
25
  export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
26
26
  export const GIT_TIMEOUT = 'GIT_TIMEOUT';
27
27
  export const GIT_ERROR = 'GIT_ERROR';
28
+ /** Git refused to read a repository owned by another account; the trust decision is the operator's. */
29
+ export const WORKTREE_GIT_OWNERSHIP_UNSAFE = 'WORKTREE_GIT_OWNERSHIP_UNSAFE';
28
30
  /** A valid repository whose HEAD does not resolve because nothing is committed. */
29
31
  export const REPOSITORY_HAS_NO_COMMITS = 'REPOSITORY_HAS_NO_COMMITS';
30
32
  export const WORKTREE_LOCKED = 'WORKTREE_LOCKED';
@@ -67,6 +69,12 @@ async function runGit(runner, args, opts) {
67
69
  const r = await runner(args, opts);
68
70
  const stderr = r.stderr ?? '';
69
71
  if (/not a git repository/i.test(stderr)) return { ok: false, reason: NOT_GIT_REPOSITORY, error: stderr.trim() };
72
+ // Git's ownership refusal is its own condition, distinct from a broken
73
+ // repository: the tree exists, Crew can see it, and the remedy is a trust
74
+ // decision that belongs to the operator, not a repair step. Naming it keeps
75
+ // "the worktree looks unowned" (WORKSPACE_CONFLICT) and generic GIT_ERROR
76
+ // from misdescribing a shared checkout.
77
+ if (/dubious ownership/i.test(stderr)) return { ok: false, reason: WORKTREE_GIT_OWNERSHIP_UNSAFE, code: r.code, error: stderr.trim() };
70
78
  if (r.code != null && r.code !== 0) return { ok: false, reason: r.code === -1 ? GIT_NOT_FOUND : GIT_ERROR, code: r.code, error: stderr.trim() || 'git exited non-zero' };
71
79
  return { ok: true, code: r.code, stdout: r.stdout ?? '', stderr };
72
80
  } catch (err) {
@@ -77,6 +85,40 @@ async function runGit(runner, args, opts) {
77
85
  }
78
86
  }
79
87
 
88
+ function repoPathKey(value) {
89
+ try {
90
+ const p = resolve(String(value ?? ''));
91
+ return process.platform === 'win32' ? p.toLowerCase() : p;
92
+ } catch { return String(value ?? ''); }
93
+ }
94
+
95
+ /** Whether two paths name the same repository root on this platform. */
96
+ export function sameRepoPath(left, right) {
97
+ const a = repoPathKey(left);
98
+ const b = repoPathKey(right);
99
+ return a.length > 0 && a === b;
100
+ }
101
+
102
+ // A shared checkout can be owned by another Windows account, and Git refuses even to
103
+ // read it ("dubious ownership"); the operator's usual remedy, a global
104
+ // `safe.directory` entry, is one Crew must never write. `-c safe.directory=<root>` is
105
+ // the scoped form: it lives and dies with the subprocess, persists nothing, and names
106
+ // exactly one repository rather than a wildcard. Callers pass the root the request
107
+ // itself named, and the workspace entry fails closed when Git resolves any other
108
+ // repository.
109
+ export function withSafeDirectory(git, trustedRepoRoot) {
110
+ const run = git ?? defaultRunner;
111
+ const safe = resolve(String(trustedRepoRoot ?? ''));
112
+ // Git reads a trailing `/*` as a prefix wildcard, and on POSIX `*` is a legal
113
+ // filename, so a value that could outlive "exactly one repository" is refused
114
+ // here rather than handed to Git and discovered later.
115
+ if (safe.includes('*')) {
116
+ const error = `Crew trust refusal: the safe.directory anchor must be one exact path without wildcards, got ${safe}`;
117
+ return async () => ({ code: 1, stdout: '', stderr: error });
118
+ }
119
+ return (args, opts) => run(['-c', `safe.directory=${safe}`, ...args], opts);
120
+ }
121
+
80
122
  const PURPOSE_MAX = 32;
81
123
 
82
124
  /** Local wall-clock stamp: readable, and what an operator expects to see. */