@ran-sh/dsh-crew 2.0.1 → 2.0.3
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/docs/job-contracts.md +12 -7
- package/package.json +1 -1
- package/src/hub/index.mjs +25 -16
- package/src/runtime-identity.mjs +1 -1
- package/src/workspace-audit.mjs +123 -9
package/docs/job-contracts.md
CHANGED
|
@@ -85,13 +85,18 @@ Per-job precedence is request `constraints` > Profile > session defaults.
|
|
|
85
85
|
`auto`, `existing`, or `none`. Workspace preflight reports `READY`, `CONFLICT`,
|
|
86
86
|
`READ_ONLY`, or `UNAVAILABLE` before dispatch.
|
|
87
87
|
|
|
88
|
-
`constraints.allow_no_changes: true` is
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
`constraints.allow_no_changes: true` is for tasks explicitly expected to leave
|
|
89
|
+
no net workspace changes, including read-only search/inspection and bounded
|
|
90
|
+
temporary smoke work. It does not blindly convert a partial Delivery Report
|
|
91
|
+
into success: Crew requires a complete Delivery Report, at least one passing
|
|
92
|
+
evidence check, no failed check, and machine-verifiable zero net workspace
|
|
93
|
+
changes. For Hub jobs, that evidence comes from a clean Git baseline, or from a
|
|
94
|
+
non-Git workspace whose initial tree contains only directories; the latter audit
|
|
95
|
+
compares paths and entry types without opening file contents. Non-Git trees that
|
|
96
|
+
already contain files, links, or special entries remain unverifiable and fail
|
|
97
|
+
closed. If the Worker reports no changes but the workspace contains edits—or
|
|
98
|
+
reports changes while the workspace is empty—the workflow fails closed with
|
|
99
|
+
workspace-evidence mismatch.
|
|
95
100
|
|
|
96
101
|
The CLI is a JSON projection of the same local HTTP surface:
|
|
97
102
|
|
package/package.json
CHANGED
package/src/hub/index.mjs
CHANGED
|
@@ -502,10 +502,9 @@ export function hubCanonicalEvents(job = {}) {
|
|
|
502
502
|
|
|
503
503
|
export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChanges = false, isolation = 'shared', role = 'worker' } = {}) {
|
|
504
504
|
void isolation;
|
|
505
|
-
const
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
|
|
505
|
+
const hasChanges = workspaceHasChanges(workspaceDiff);
|
|
506
|
+
const evidenceAvailable = (workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true)
|
|
507
|
+
|| (workspaceDiff?.kind === 'filesystem-empty' && typeof workspaceDiff.unchanged === 'boolean');
|
|
509
508
|
return applyWorkspaceEvidence(outcome, {
|
|
510
509
|
evidenceAvailable,
|
|
511
510
|
hasChanges,
|
|
@@ -521,6 +520,19 @@ export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChang
|
|
|
521
520
|
});
|
|
522
521
|
}
|
|
523
522
|
|
|
523
|
+
function workspaceHasChanges(workspaceDiff) {
|
|
524
|
+
if (workspaceDiff?.kind === 'filesystem-empty') return workspaceDiff.unchanged !== true;
|
|
525
|
+
const changes = workspaceDiff?.changes ?? {};
|
|
526
|
+
return ['modified', 'deleted', 'renamed', 'untracked']
|
|
527
|
+
.some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function invalidateReviewForWorkspaceMutation({ role, review, workspaceDiff } = {}) {
|
|
531
|
+
if (role !== 'reviewer' || !review || !['git', 'filesystem-empty'].includes(workspaceDiff?.kind)) return review;
|
|
532
|
+
if (!workspaceHasChanges(workspaceDiff)) return review;
|
|
533
|
+
return { ...review, mutated_candidate: true, invalidated: true, verdict: 'request_changes' };
|
|
534
|
+
}
|
|
535
|
+
|
|
524
536
|
/**
|
|
525
537
|
* Build the explicit provider callability probe used by the 3210 lifecycle
|
|
526
538
|
* route. It performs one token-bounded Harness stream through the same llm
|
|
@@ -602,7 +614,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
602
614
|
task_status: job.outcome?.task_status ?? null,
|
|
603
615
|
workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
|
|
604
616
|
review_verdict: job.review?.verdict ?? null,
|
|
605
|
-
workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind
|
|
617
|
+
workspace_diff_available: !!job.workspaceDiff && ['git', 'filesystem-empty'].includes(job.workspaceDiff.kind),
|
|
606
618
|
workspace_retained: job.workspace_retained === true,
|
|
607
619
|
cleanup_warning: job.cleanup_warning ?? null,
|
|
608
620
|
profile_id: job.profile_id ?? null,
|
|
@@ -935,7 +947,8 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
935
947
|
// Read-only pre-run snapshot (async, never blocks dispatch): the audit
|
|
936
948
|
// only needs the before-state by the time the worker finishes. Non-repos
|
|
937
949
|
// degrade to { kind:'no-git' } instead of failing the job.
|
|
938
|
-
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd
|
|
950
|
+
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd, allowEmptyNonGitDirectory: true })
|
|
951
|
+
.catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
939
952
|
try {
|
|
940
953
|
this.assertProviderNotMutating(selection.provider, providerMutationEpoch);
|
|
941
954
|
this.acquireProviderLease(selection.provider);
|
|
@@ -1120,7 +1133,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
1120
1133
|
});
|
|
1121
1134
|
job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
|
|
1122
1135
|
// Read-only after-snapshot of the workspace: bounded, redacted patch.
|
|
1123
|
-
job.workspaceDiff = job.baseline.kind
|
|
1136
|
+
job.workspaceDiff = ['git', 'filesystem-empty'].includes(job.baseline.kind)
|
|
1124
1137
|
? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
|
|
1125
1138
|
: job.baseline;
|
|
1126
1139
|
job.outcome = applyHubWorkspaceEvidence({
|
|
@@ -1139,15 +1152,11 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
1139
1152
|
error: { code: job.error_code, message: job.error },
|
|
1140
1153
|
});
|
|
1141
1154
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
job.review.invalidated = true;
|
|
1148
|
-
job.review.verdict = 'request_changes';
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1155
|
+
job.review = invalidateReviewForWorkspaceMutation({
|
|
1156
|
+
role: job.role,
|
|
1157
|
+
review: job.review,
|
|
1158
|
+
workspaceDiff: job.workspaceDiff,
|
|
1159
|
+
});
|
|
1151
1160
|
if (job.isolatedWorkspace) {
|
|
1152
1161
|
const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
|
|
1153
1162
|
job.workspace_retained = cleanup.ok !== true;
|
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.0.
|
|
39
|
+
export const RUNTIME_VERSION = '2.0.3';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
package/src/workspace-audit.mjs
CHANGED
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Discipline: strictly read-only. Only git porcelain reads are issued — never
|
|
7
7
|
// reset / stash / clean / checkout, and nothing here writes to disk. A
|
|
8
|
-
// non-git directory degrades to { kind: 'no-git' }
|
|
9
|
-
//
|
|
8
|
+
// non-git directory degrades to { kind: 'no-git' } by default, and a pre-dirty
|
|
9
|
+
// workspace is flagged (dirtyBaseline) rather than hidden. Hub callers may
|
|
10
|
+
// opt into a bounded directory-only snapshot for initially empty non-Git trees.
|
|
10
11
|
//
|
|
11
12
|
// The default `git` runner is replaceable with an injected runner in tests:
|
|
12
13
|
// it receives (argsArray, { cwd }) and resolves { code, stdout, stderr }.
|
|
13
14
|
|
|
14
15
|
import { execFile } from 'node:child_process';
|
|
15
16
|
import { existsSync } from 'node:fs';
|
|
16
|
-
import {
|
|
17
|
+
import { lstat, opendir, realpath } from 'node:fs/promises';
|
|
18
|
+
import { isAbsolute, relative, resolve, sep, win32 } from 'node:path';
|
|
17
19
|
import { promisify } from 'node:util';
|
|
18
20
|
|
|
19
21
|
const execFileAsync = promisify(execFile);
|
|
@@ -22,7 +24,10 @@ export const DIFF_LIMIT = 64 * 1024;
|
|
|
22
24
|
export const NOT_A_GIT_REPOSITORY = 'NOT_A_GIT_REPOSITORY';
|
|
23
25
|
export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
|
|
24
26
|
export const GIT_TIMEOUT = 'GIT_TIMEOUT';
|
|
27
|
+
export const GIT_ERROR = 'GIT_ERROR';
|
|
25
28
|
export const GIT_TIMEOUT_MS = 8000;
|
|
29
|
+
const EMPTY_TREE_ENTRY_LIMIT = 20_000;
|
|
30
|
+
const EMPTY_TREE_DEPTH_LIMIT = 64;
|
|
26
31
|
|
|
27
32
|
const SENSITIVE_SUFFIXES = ['.pem', '.key'];
|
|
28
33
|
const SENSITIVE_PREFIXES = ['credentials', 'secret'];
|
|
@@ -85,15 +90,103 @@ async function runGit(runner, args, opts) {
|
|
|
85
90
|
if (/not a git repository/i.test(stderr)) {
|
|
86
91
|
return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: stderr.trim() };
|
|
87
92
|
}
|
|
93
|
+
if (Number.isInteger(r.code) && r.code !== 0) {
|
|
94
|
+
return { ok: false, reason: GIT_ERROR, error: 'git audit failed' };
|
|
95
|
+
}
|
|
88
96
|
return { ok: true, code: r.code ?? 0, stdout: r.stdout ?? '', stderr };
|
|
89
97
|
} catch (err) {
|
|
90
98
|
const msg = err?.message ?? String(err);
|
|
99
|
+
const diagnostic = `${err?.stderr ?? ''}\n${msg}`;
|
|
100
|
+
if (/not a git repository/i.test(diagnostic)) return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: 'not a git repository' };
|
|
91
101
|
if (err?.code === 'ETIMEDOUT' || err?.killed === true || /timed out|timeout/i.test(msg)) return { ok: false, reason: GIT_TIMEOUT, error: 'git audit timed out' };
|
|
92
102
|
if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
|
|
93
|
-
return { ok: false, reason:
|
|
103
|
+
return { ok: false, reason: GIT_ERROR, error: 'git audit failed' };
|
|
94
104
|
}
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
function samePath(left, right) {
|
|
108
|
+
const normalizedLeft = resolve(left).replace(/[\\/]+$/, '');
|
|
109
|
+
const normalizedRight = resolve(right).replace(/[\\/]+$/, '');
|
|
110
|
+
return process.platform === 'win32'
|
|
111
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
112
|
+
: normalizedLeft === normalizedRight;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pathIsWithin(root, candidate) {
|
|
116
|
+
const rel = relative(root, candidate);
|
|
117
|
+
const comparable = process.platform === 'win32' ? rel.toLowerCase() : rel;
|
|
118
|
+
return comparable === '' || (comparable !== '..' && !comparable.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Capture names and entry kinds only; never open file contents. This fallback
|
|
123
|
+
* is eligible only when the pre-run tree has no files or links, so it can prove
|
|
124
|
+
* that a temporary file/directory was removed without reading existing data.
|
|
125
|
+
*/
|
|
126
|
+
async function captureDirectoryEntries(cwd) {
|
|
127
|
+
let root;
|
|
128
|
+
try {
|
|
129
|
+
const requested = resolve(cwd);
|
|
130
|
+
const requestedStat = await lstat(requested);
|
|
131
|
+
if (!requestedStat.isDirectory() || requestedStat.isSymbolicLink()) {
|
|
132
|
+
return { ok: false, reason: 'WORKSPACE_ROOT_UNSAFE' };
|
|
133
|
+
}
|
|
134
|
+
root = await realpath(requested);
|
|
135
|
+
} catch {
|
|
136
|
+
return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pending = [{ absolute: root, depth: 0 }];
|
|
140
|
+
const entries = [];
|
|
141
|
+
let regularFileCount = 0;
|
|
142
|
+
let linkOrSpecialCount = 0;
|
|
143
|
+
try {
|
|
144
|
+
while (pending.length > 0) {
|
|
145
|
+
const current = pending.pop();
|
|
146
|
+
if (current.depth > EMPTY_TREE_DEPTH_LIMIT) return { ok: false, reason: 'WORKSPACE_SCAN_LIMIT' };
|
|
147
|
+
const beforeStat = await lstat(current.absolute);
|
|
148
|
+
if (!beforeStat.isDirectory() || beforeStat.isSymbolicLink()) return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
149
|
+
const canonical = await realpath(current.absolute);
|
|
150
|
+
if (!pathIsWithin(root, canonical)) return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
151
|
+
const children = await opendir(canonical);
|
|
152
|
+
for await (const child of children) {
|
|
153
|
+
if (entries.length >= EMPTY_TREE_ENTRY_LIMIT) return { ok: false, reason: 'WORKSPACE_SCAN_LIMIT' };
|
|
154
|
+
const absolute = resolve(canonical, child.name);
|
|
155
|
+
const entryPath = relative(root, absolute).split(sep).join('/');
|
|
156
|
+
const stat = await lstat(absolute);
|
|
157
|
+
let type;
|
|
158
|
+
if (stat.isSymbolicLink()) {
|
|
159
|
+
type = 'link';
|
|
160
|
+
linkOrSpecialCount += 1;
|
|
161
|
+
} else if (stat.isDirectory()) {
|
|
162
|
+
type = 'directory';
|
|
163
|
+
pending.push({ absolute, depth: current.depth + 1 });
|
|
164
|
+
} else if (stat.isFile()) {
|
|
165
|
+
type = 'file';
|
|
166
|
+
regularFileCount += 1;
|
|
167
|
+
} else {
|
|
168
|
+
type = 'special';
|
|
169
|
+
linkOrSpecialCount += 1;
|
|
170
|
+
}
|
|
171
|
+
entries.push({ path: entryPath, type });
|
|
172
|
+
}
|
|
173
|
+
const afterStat = await lstat(current.absolute);
|
|
174
|
+
const afterCanonical = await realpath(current.absolute);
|
|
175
|
+
if (!afterStat.isDirectory() || afterStat.isSymbolicLink()
|
|
176
|
+
|| !samePath(canonical, afterCanonical)
|
|
177
|
+
|| beforeStat.mtimeMs !== afterStat.mtimeMs
|
|
178
|
+
|| beforeStat.ctimeMs !== afterStat.ctimeMs) {
|
|
179
|
+
return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
entries.sort((left, right) => left.path.localeCompare(right.path));
|
|
187
|
+
return { ok: true, entries, regularFileCount, linkOrSpecialCount };
|
|
188
|
+
}
|
|
189
|
+
|
|
97
190
|
function splitFirstTab(line) {
|
|
98
191
|
const i = line.indexOf('\t');
|
|
99
192
|
return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
|
|
@@ -136,10 +229,11 @@ export function parseChanges(nameStatus, statusPorcelain) {
|
|
|
136
229
|
|
|
137
230
|
/**
|
|
138
231
|
* Read-only pre-run snapshot of a workspace. Returns a git snapshot
|
|
139
|
-
* { kind:'git', status, nameStatus, stat, dirty, changes }
|
|
140
|
-
* { kind:'
|
|
232
|
+
* { kind:'git', status, nameStatus, stat, dirty, changes }, an opted-in
|
|
233
|
+
* { kind:'filesystem-empty', entries } for an empty non-Git tree, or a degraded
|
|
234
|
+
* { kind:'no-git', reason, error } for other non-repos / missing Git. Never throws.
|
|
141
235
|
*/
|
|
142
|
-
export async function captureWorkspaceBaseline({ cwd, git } = {}) {
|
|
236
|
+
export async function captureWorkspaceBaseline({ cwd, git, allowEmptyNonGitDirectory = false } = {}) {
|
|
143
237
|
const runner = git ?? defaultRunner;
|
|
144
238
|
if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
|
|
145
239
|
const [status, nameStatus, stat] = await Promise.all([
|
|
@@ -147,8 +241,19 @@ export async function captureWorkspaceBaseline({ cwd, git } = {}) {
|
|
|
147
241
|
runGit(runner, ['diff', '--name-status'], { cwd }),
|
|
148
242
|
runGit(runner, ['diff', '--stat'], { cwd }),
|
|
149
243
|
]);
|
|
150
|
-
|
|
151
|
-
|
|
244
|
+
const results = [status, nameStatus, stat];
|
|
245
|
+
const failures = results.filter((result) => !result.ok);
|
|
246
|
+
if (failures.length > 0) {
|
|
247
|
+
const allExplicitlyNonGit = failures.length === results.length
|
|
248
|
+
&& failures.every((result) => result.reason === NOT_A_GIT_REPOSITORY);
|
|
249
|
+
if (allExplicitlyNonGit && allowEmptyNonGitDirectory === true) {
|
|
250
|
+
const tree = await captureDirectoryEntries(cwd);
|
|
251
|
+
if (tree.ok && tree.regularFileCount === 0 && tree.linkOrSpecialCount === 0) {
|
|
252
|
+
return { kind: 'filesystem-empty', entries: tree.entries };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const failed = failures.find((result) => result.reason !== NOT_A_GIT_REPOSITORY) ?? failures[0];
|
|
256
|
+
return { kind: 'no-git', reason: failed.reason, error: failed.error };
|
|
152
257
|
}
|
|
153
258
|
const changes = parseChanges(nameStatus.stdout, status.stdout);
|
|
154
259
|
return {
|
|
@@ -203,6 +308,15 @@ async function buildPatch(runner, { cwd, changes, limit }) {
|
|
|
203
308
|
export async function captureWorkspaceDiff({ cwd, baseline, git, limit = DIFF_LIMIT } = {}) {
|
|
204
309
|
const runner = git ?? defaultRunner;
|
|
205
310
|
if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
|
|
311
|
+
if (baseline?.kind === 'filesystem-empty') {
|
|
312
|
+
const tree = await captureDirectoryEntries(cwd);
|
|
313
|
+
if (!tree.ok) return { kind: 'no-git', reason: tree.reason, error: 'filesystem audit unavailable' };
|
|
314
|
+
return {
|
|
315
|
+
kind: 'filesystem-empty',
|
|
316
|
+
dirtyBaseline: false,
|
|
317
|
+
unchanged: JSON.stringify(tree.entries) === JSON.stringify(baseline.entries),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
206
320
|
if (!baseline || baseline.kind !== 'git') {
|
|
207
321
|
return { kind: 'no-git', reason: baseline?.reason ?? NOT_A_GIT_REPOSITORY, error: baseline?.error };
|
|
208
322
|
}
|