@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.
package/package.json
CHANGED
package/src/crew-workspaces.mjs
CHANGED
|
@@ -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
|
|
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
|
|
package/src/mcp-runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -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.
|
|
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. */
|