@ran-sh/dsh-crew 1.10.1 → 1.10.2

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": "1.10.1",
3
+ "version": "1.10.2",
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": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -11,6 +11,7 @@ import {
11
11
  existsSync,
12
12
  lstatSync,
13
13
  mkdirSync,
14
+ readdirSync,
14
15
  readFileSync,
15
16
  realpathSync,
16
17
  renameSync,
@@ -528,9 +529,21 @@ async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned,
528
529
  return recovery;
529
530
  }
530
531
  }
531
- try { rmSync(liveRoot, { recursive: true, force: true }); } catch {}
532
532
  try {
533
- if (existsSync(prevRoot)) { rename(prevRoot, liveRoot); recovery.restore = true; }
533
+ rmSync(liveRoot, { recursive: true, force: true });
534
+ } catch (error) {
535
+ // A failed removal must stop the recovery. Swallowing it left the window
536
+ // open: the restore was attempted over whatever remained, and
537
+ // `startOwned()` ran regardless — so a tree that could not be removed was
538
+ // then asked to start again, which on Windows is exactly the case where
539
+ // live handles blocked the removal.
540
+ recovery.removeError = String(error?.message ?? error);
541
+ recovery.ok = false;
542
+ return recovery;
543
+ }
544
+ try {
545
+ if (!existsSync(prevRoot)) recovery.restoreError = `previous runtime is missing at ${prevRoot}`;
546
+ else { rename(prevRoot, liveRoot); recovery.restore = true; }
534
547
  } catch (error) {
535
548
  recovery.restoreError = String(error?.message ?? error);
536
549
  }
@@ -538,6 +551,14 @@ async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned,
538
551
  recovery.ok = recovery.restore === true;
539
552
  return recovery;
540
553
  }
554
+ // Restart only on positive proof that the prior runtime is the tree now at the
555
+ // live root. Starting without it would run the failed candidate again, or
556
+ // nothing at all, and report it as a recovery.
557
+ if (recovery.restore !== true || !existsSync(liveRoot)) {
558
+ recovery.restartSkipped = 'the previous runtime was not restored';
559
+ recovery.ok = false;
560
+ return recovery;
561
+ }
541
562
  try {
542
563
  const restarted = await startOwned();
543
564
  recovery.restart = restarted?.ok === true;
@@ -551,6 +572,8 @@ async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned,
551
572
 
552
573
  // ---- retained runtime cohorts -------------------------------------------------
553
574
 
575
+ const DISPLACED_RUNTIME_PREFIX = 'runtime-displaced-';
576
+
554
577
  function retainedRuntimesRoot({ home }) {
555
578
  return join(crewDshHome({ home }), RETAINED_RUNTIMES_DIRNAME);
556
579
  }
@@ -572,12 +595,22 @@ function runtimeTreeVersion(root, read = readFileSync) {
572
595
  // Best-effort retention of the swapped-out prior runtime tree. Never throws
573
596
  // and never fails the caller: retention is an optimization for offline
574
597
  // rollback, not a correctness requirement of the migration itself.
575
- function retainPriorRuntime({ home, prevRoot, rename = renameSync }) {
598
+ /**
599
+ * Retain a parked runtime tree under the version it ships.
600
+ *
601
+ * `removeUnreadable` exists for one caller: the rollback rotation, where the
602
+ * parked tree is the ONLY copy of the cohort just displaced. There, failing to
603
+ * read its version is not a reason to delete it — that would destroy the very
604
+ * copy the rotation exists to keep — so it stays parked and is found later by
605
+ * `findRetainedRuntime`. The forward migration path keeps the default, where an
606
+ * unreadable tree really is junk.
607
+ */
608
+ function retainPriorRuntime({ home, prevRoot, rename = renameSync, removeUnreadable = true }) {
576
609
  try {
577
610
  if (!existsSync(prevRoot)) return { ok: true, retained: false };
578
611
  const version = runtimeTreeVersion(prevRoot);
579
612
  if (!version) {
580
- // No version to key retention on; the tree is unreadable junk.
613
+ if (!removeUnreadable) return { ok: false, retained: false, prevRoot, error: 'parked cohort version unreadable; left in place' };
581
614
  try { rmSync(prevRoot, { recursive: true, force: true }); } catch {}
582
615
  return { ok: true, retained: false, reason: 'prior tree version unreadable; removed' };
583
616
  }
@@ -601,9 +634,24 @@ function retainPriorRuntime({ home, prevRoot, rename = renameSync }) {
601
634
  export function findRetainedRuntime({ home = homedir(), version, exists = existsSync, read = readFileSync } = {}) {
602
635
  if (typeof version !== 'string' || version.length === 0) return null;
603
636
  const dir = retainedRuntimeDir({ home, version });
604
- if (!exists(dir)) return null;
605
- if (runtimeTreeVersion(dir, read) !== version) return null;
606
- return dir;
637
+ if (exists(dir) && runtimeTreeVersion(dir, read) === version) return dir;
638
+ // A rotation whose retain step failed leaves the displaced cohort parked in the
639
+ // harness home. It is a complete, valid copy of that cohort, so it is found
640
+ // here rather than being written off: otherwise a later offline rollback to
641
+ // that version reports RETAINED_MISSING while the tree sits on disk.
642
+ return findDisplacedRuntime({ home, version, exists, read });
643
+ }
644
+
645
+ function findDisplacedRuntime({ home, version, exists = existsSync, read = readFileSync }) {
646
+ let names;
647
+ try { names = readdirSync(crewDshHome({ home })); } catch { return null; }
648
+ for (const name of names) {
649
+ if (!name.startsWith(DISPLACED_RUNTIME_PREFIX)) continue;
650
+ const dir = join(crewDshHome({ home }), name);
651
+ if (!exists(dir)) continue;
652
+ if (runtimeTreeVersion(dir, read) === version) return dir;
653
+ }
654
+ return null;
607
655
  }
608
656
 
609
657
  // Offline cohort restore for cross-cohort rollback: move the retained tree
@@ -642,14 +690,34 @@ export async function restoreRetainedRuntime({
642
690
  if (!stop.ok) {
643
691
  return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
644
692
  }
693
+ // Park the displaced cohort instead of deleting it. Deleting it was what made a
694
+ // second rollback impossible: after B→A the B tree was gone, so a later A→B
695
+ // found no retained B. Retention has to rotate, like the forward path does.
696
+ const parked = join(crewDshHome({ home }), `${DISPLACED_RUNTIME_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`);
697
+ let parkedLive = false;
698
+ try {
699
+ if (existsSync(liveRoot)) { rename(liveRoot, parked); parkedLive = true; }
700
+ } catch (error) {
701
+ return { ok: false, code: 'DSH_RUNTIME_PARK_FAILED', error: String(error?.message ?? error) };
702
+ }
645
703
  try {
646
- if (existsSync(liveRoot)) rmSync(liveRoot, { recursive: true, force: true });
647
704
  rename(retained, liveRoot);
648
705
  } catch (error) {
649
- // Restore the pre-existing live tree is impossible (it was replaced only
650
- // on success above); report and let the caller reconcile.
706
+ // Put the displaced tree back: leaving the live root empty because a rename
707
+ // failed would take a working runtime down for nothing.
708
+ try { if (parkedLive && !existsSync(liveRoot)) rename(parked, liveRoot); } catch { /* reported below */ }
651
709
  return { ok: false, code: 'DSH_RUNTIME_RESTORE_SWAP_FAILED', error: String(error?.message ?? error) };
652
710
  }
711
+ // The cohort just displaced becomes the retained one, so the version this
712
+ // restore moved away from can still be rolled back to. This parked tree is the
713
+ // only copy of that cohort, so an unreadable version keeps it rather than
714
+ // deleting it, and a failure is surfaced instead of passing as a clean restore.
715
+ const rotated = parkedLive
716
+ ? retainPriorRuntime({ home, prevRoot: parked, rename, removeUnreadable: false })
717
+ : { ok: true, retained: false };
718
+ if (parkedLive && rotated.ok === false) {
719
+ log(`! could not retain the displaced runtime cohort; it remains at ${parked}: ${rotated.error ?? ''}`);
720
+ }
653
721
  if (prepareOnly) {
654
722
  log(`- runtime tree prepared offline from retained tree (@${version}); process not started`);
655
723
  return { ok: true, version, liveRoot, prepared: true };
@@ -161,21 +161,62 @@ export function readCurrentPointerState({ home = homedir() } = {}) {
161
161
  return { status: 'malformed', file, code: 'POINTER_FIELDS_INVALID' };
162
162
  }
163
163
  if (!isAbsolute(raw.path)) return { status: 'malformed', file, code: 'POINTER_PATH_NOT_ABSOLUTE' };
164
- return { status: 'valid', file, pointer: raw };
164
+ // The pointer names the release that is live, and recovery acts on it. A path
165
+ // outside the managed releases directory is a corrupt or tampered pointer, not
166
+ // a release, so it fails closed exactly like the other malformed cases — and
167
+ // the canonical value is what the caller gets, so every later use acts on the
168
+ // path that was checked.
169
+ const canonical = managedReleasePath({ home, value: raw.path });
170
+ if (canonical === null) {
171
+ return { status: 'malformed', file, code: 'POINTER_PATH_OUTSIDE_RELEASES' };
172
+ }
173
+ return { status: 'valid', file, pointer: { ...raw, path: canonical } };
174
+ }
175
+
176
+ /**
177
+ * Resolve a path that a journal, the pointer, or recovery is about to act on, and
178
+ * refuse it unless it is inside the Crew-owned releases directory.
179
+ *
180
+ * An absolute path is not enough. Recovery recursively deletes the journal's
181
+ * candidate stage directory and moves the pointer's release, so a syntactically
182
+ * valid but corrupt or tampered journal naming any absolute path would grant
183
+ * delete authority over it — including the official `~/.dsh` tree this plugin is
184
+ * required never to touch. Containment is therefore checked before any read,
185
+ * write, delete or activation, and a path that fails it is treated exactly like
186
+ * a malformed journal: nothing is touched and the state is retained.
187
+ */
188
+ export function managedReleasePath({ home = homedir(), value } = {}) {
189
+ if (typeof value !== 'string' || value.length === 0 || !isAbsolute(value)) return null;
190
+ const releases = realpathOr(resolve(crewReleasesDir({ home })));
191
+ const candidate = realpathOr(resolve(value));
192
+ // Resolve both sides before comparing: `..` segments and symlinks must not be
193
+ // able to leave the managed directory after the check.
194
+ const relative = relativeTo(releases, candidate);
195
+ if (relative === null || relative === '' || relative.startsWith('..') || isAbsolute(relative)) return null;
196
+ return candidate;
165
197
  }
166
198
 
167
- function validJournalRelease(value) {
199
+ function realpathOr(path) {
200
+ try { return realpathSync(path); } catch { return path; }
201
+ }
202
+
203
+ /** `path` expressed relative to `from`, or null when they are on different roots. */
204
+ function relativeTo(from, path) {
205
+ return relative(from, path);
206
+ }
207
+
208
+ function validJournalRelease({ home, value }) {
168
209
  return !!value && typeof value === 'object'
169
210
  && typeof value.name === 'string' && value.name.length > 0
170
211
  && typeof value.version === 'string' && value.version.length > 0
171
- && typeof value.path === 'string' && isAbsolute(value.path);
212
+ && managedReleasePath({ home, value: value.path }) !== null;
172
213
  }
173
214
 
174
- function validJournalCandidate(value) {
215
+ function validJournalCandidate({ home, value }) {
175
216
  return !!value && typeof value === 'object'
176
217
  && typeof value.name === 'string' && value.name.length > 0
177
218
  && typeof value.version === 'string' && value.version.length > 0
178
- && typeof value.stageDir === 'string' && isAbsolute(value.stageDir);
219
+ && managedReleasePath({ home, value: value.stageDir }) !== null;
179
220
  }
180
221
 
181
222
  function writeFileAtomic(file, content) {
@@ -359,13 +400,23 @@ function readUpdateJournal({ home = homedir() } = {}) {
359
400
  return { malformed: true, file };
360
401
  }
361
402
  // Full schema check: a semantically broken journal (null candidate,
362
- // incomplete prior) must fail closed, never enter recovery.
363
- if (!validJournalCandidate(raw.candidate)) {
403
+ // incomplete prior) must fail closed, never enter recovery. The paths are
404
+ // checked for containment too, because recovery deletes and moves them, and
405
+ // they are replaced with the canonical value that was checked so that every
406
+ // later use acts on the path that was validated rather than the string that
407
+ // happened to be written.
408
+ const canonicalCandidate = managedReleasePath({ home, value: raw.candidate?.stageDir });
409
+ if (!validJournalCandidate({ home, value: raw.candidate }) || canonicalCandidate === null) {
364
410
  return { malformed: true, file, code: 'JOURNAL_CANDIDATE_SCHEMA_INVALID' };
365
411
  }
366
- if (raw.prior !== null && raw.prior !== undefined && !validJournalRelease(raw.prior)) {
367
- return { malformed: true, file, code: 'JOURNAL_PRIOR_SCHEMA_INVALID' };
412
+ if (raw.prior !== null && raw.prior !== undefined) {
413
+ const canonicalPrior = managedReleasePath({ home, value: raw.prior?.path });
414
+ if (!validJournalRelease({ home, value: raw.prior }) || canonicalPrior === null) {
415
+ return { malformed: true, file, code: 'JOURNAL_PRIOR_SCHEMA_INVALID' };
416
+ }
417
+ raw = { ...raw, prior: { ...raw.prior, path: canonicalPrior } };
368
418
  }
419
+ raw = { ...raw, candidate: { ...raw.candidate, stageDir: canonicalCandidate } };
369
420
  return raw;
370
421
  }
371
422
 
@@ -554,7 +605,9 @@ export function reconcileUpdateJournal({ home = homedir(), log = () => {}, insta
554
605
  return { ok: false, code: 'POINTER_MALFORMED', file: pointerState.file, error: pointerState.code ?? 'pointer unreadable; refusing recovery' };
555
606
  }
556
607
  const pointer = pointerState.status === 'valid' ? pointerState.pointer : null;
557
- const candidateDir = journal.candidate?.stageDir ?? null;
608
+ // Act on the canonical path that was validated, not the raw string that was
609
+ // written: the delete below is only as safe as the path it is handed.
610
+ const candidateDir = managedReleasePath({ home, value: journal.candidate?.stageDir ?? null });
558
611
  const candidateManifest = candidateDir && existsSync(candidateDir) ? readManifest(candidateDir) : null;
559
612
 
560
613
  // A coordinated-update journal MUST carry a runtime segment; recovery may
@@ -74,12 +74,23 @@ export function capturePayloadContent(root, suppliedManifest) {
74
74
  } catch { return null; }
75
75
  }
76
76
 
77
- /** This digest covers first-party shipped files, not dependency tamper attestation. */
77
+ /**
78
+ * This digest covers first-party shipped files, not dependency tamper attestation.
79
+ *
80
+ * The permission bits are part of it: the copy below writes each file with the
81
+ * mode captured from the source, so a payload whose `0644` became `0755` with
82
+ * identical bytes is not the same payload — it is the case where an intended
83
+ * permission repair would otherwise be skipped as already done.
84
+ */
78
85
  export function payloadContentDigest(root) {
79
86
  const snapshot = capturePayloadContent(root);
80
87
  if (!snapshot) return null;
81
- const hashes = [...snapshot.files].map(([name, bytes]) => [name, createHash('sha256').update(bytes).digest('hex')]);
82
- return createHash('sha256').update(JSON.stringify(hashes.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))).digest('hex');
88
+ const entries = [...snapshot.files].map(([name, bytes]) => [
89
+ name,
90
+ snapshot.modes.get(name) ?? null,
91
+ createHash('sha256').update(bytes).digest('hex'),
92
+ ]);
93
+ return createHash('sha256').update(JSON.stringify(entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))).digest('hex');
83
94
  }
84
95
 
85
96
  export function samePayloadContent(sourceRoot, installedRoot) {
@@ -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 = '1.10.1';
39
+ export const RUNTIME_VERSION = '1.10.2';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([