@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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/crew-workspaces.mjs +21 -3
- package/src/install/install-legacy.mjs +5 -5
- package/src/install/npx-lifecycle.mjs +3 -0
- package/src/log-prune.mjs +38 -9
- package/src/mcp-runtime.mjs +9 -1
- package/src/runtime-identity.mjs +1 -1
- package/src/runtime-readiness-snapshot.mjs +13 -3
- package/src/workspace-isolation.mjs +27 -0
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
|
|
|
@@ -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)
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
-
|
|
84
|
-
|
|
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 {
|
|
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
|
}
|
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.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
|
-
|
|
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:
|
|
280
|
-
reviewer:
|
|
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. */
|