@khorsheed/dsh-ankh-guard 0.3.2 → 0.4.1

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.
@@ -499,12 +499,26 @@ function snapshotCopyError(source, error) {
499
499
  return new Error(`preflight snapshot could not safely copy ${source}: ${String(error)}`);
500
500
  }
501
501
  /**
502
- * Top-level home entries that never join a preflight snapshot: scratch is
503
- * ephemeral by definition, and copying it can push prepare+canary past the
504
- * credential's freshness window (observed: a 24 GB scratch expired the
505
- * credential mid-cutover, the target canary then failed and restored).
502
+ * Top-level home entries the preflight snapshot copies — the ALLOWLIST of
503
+ * inputs the launcher's boot actually reads: the profile trees, the home-level
504
+ * patch layer, settings, and the credential/identity stores. Everything else
505
+ * (plugin data: sessions, state, local-agent sub-homes, tarballs, scratch, …)
506
+ * is excluded BY DEFAULT, so a newly installed plugin's data directory can
507
+ * never silently join the copy — this list moves only when the HOST's boot
508
+ * starts reading a new home input, and a miss fails the dry-run loudly with
509
+ * the missing path rather than degrading into a slow copy. The denylist this
510
+ * replaced failed twice the other way: a 24 GB scratch tree expired the
511
+ * credential mid-cutover (canary failed, restored), and on 2026-09-27 the
512
+ * local-agent sub-home's absolute links dragged the host checkout's entire
513
+ * node_modules into a ~4 GB / 848 s prepare.
506
514
  */
507
- const SNAPSHOT_SKIPPED_TOP_LEVEL = new Set(['scratch']);
515
+ export const SNAPSHOT_INCLUDED_TOP_LEVEL = [
516
+ 'profiles',
517
+ 'settings.yaml',
518
+ 'cordis.patch.yml',
519
+ '.credentials.yaml',
520
+ '.anonymous-user-id',
521
+ ];
508
522
  function canonicalSnapshotSource(source) {
509
523
  try {
510
524
  return realpathSync(source);
@@ -558,7 +572,7 @@ function copySnapshotNode(source, destination, context) {
558
572
  });
559
573
  try {
560
574
  for (const name of readdirSync(canonical)) {
561
- if (canonical === context.rootCanonical && SNAPSHOT_SKIPPED_TOP_LEVEL.has(name))
575
+ if (canonical === context.rootCanonical && !context.includeTopLevel.has(name))
562
576
  continue;
563
577
  copySnapshotNode(join(canonical, name), join(destination, name), context);
564
578
  }
@@ -574,6 +588,13 @@ function copySnapshotNode(source, destination, context) {
574
588
  copyFileSync(canonical, destination, constants.COPYFILE_FICLONE);
575
589
  chmodSync(destination, linkMetadata.mode & 0o7777);
576
590
  utimesSync(destination, linkMetadata.atime, linkMetadata.mtime);
591
+ context.copiedFiles++;
592
+ context.copiedBytes += linkMetadata.size;
593
+ context.onProgress?.({
594
+ files: context.copiedFiles,
595
+ bytes: context.copiedBytes,
596
+ skippedRuntimeEntries: context.skippedRuntimeEntries,
597
+ });
577
598
  }
578
599
  catch (error) {
579
600
  throw snapshotCopyError(source, error);
@@ -581,12 +602,19 @@ function copySnapshotNode(source, destination, context) {
581
602
  }
582
603
  /**
583
604
  * Preserve Node's ancestor node_modules lookup for an external package while
584
- * avoiding one copy per package link. Other external targets are materialized
585
- * individually and still deduplicated by canonical path.
605
+ * avoiding one copy per package link. The anchor is the package's OWN parent
606
+ * node_modules — the LAST node_modules segment in the resolved target: pnpm
607
+ * store links resolve to …/.pnpm/<name>@<version>/node_modules/<name>, where
608
+ * that parent already holds the package's dependency siblings, so the lookup
609
+ * survives at the tightest scope. Anchoring the FIRST segment instead dragged
610
+ * the whole multi-GB store root into the snapshot (observed 2026-09-27: 32
611
+ * profile links pulled in the host checkout's entire root node_modules).
612
+ * Other external targets are materialized individually and still deduplicated
613
+ * by canonical path.
586
614
  */
587
615
  function externalMaterializationAnchor(target) {
588
616
  const parsed = resolve(target).split(sep);
589
- const nodeModulesIndex = parsed.indexOf('node_modules');
617
+ const nodeModulesIndex = parsed.lastIndexOf('node_modules');
590
618
  if (nodeModulesIndex >= 0) {
591
619
  const prefix = parsed.slice(0, nodeModulesIndex + 1).join(sep) || sep;
592
620
  return { source: prefix, destination: 'node_modules' };
@@ -665,17 +693,21 @@ function finalizeSnapshotDirectories(context) {
665
693
  }
666
694
  }
667
695
  /**
668
- * Clone a live home while retaining a contained package-link graph. Internal
669
- * links are rebuilt against copied nodes; external targets are deduplicated in
670
- * a snapshot-owned materialization area. No retained link resolves outside the
671
- * snapshot root, so writes through pnpm/Cordis links cannot reach live bytes.
672
- * Runtime entries without copyable content (sockets, FIFOs — and links to
673
- * them) are skipped and counted, never copied; the top-level scratch/ tree is
674
- * excluded for size. Device nodes still fail closed.
696
+ * Clone a live home's BOOT INPUTS while retaining a contained package-link
697
+ * graph. Only the allowlisted top-level entries are copied (see
698
+ * {@link SNAPSHOT_INCLUDED_TOP_LEVEL}) — plugin data directories are excluded
699
+ * by default, so the copy's size is bounded by what the composition's boot
700
+ * reads, not by whatever the home happens to hold. Internal links are rebuilt
701
+ * against copied nodes; external targets are deduplicated in a snapshot-owned
702
+ * materialization area. No retained link resolves outside the snapshot root,
703
+ * so writes through pnpm/Cordis links cannot reach live bytes. Runtime entries
704
+ * without copyable content (sockets, FIFOs — and links to them) are skipped
705
+ * and counted, never copied. Device nodes still fail closed.
675
706
  * @param sourceHome - Live dsh home to read.
676
- * @returns Isolated home, an idempotent cleanup callback, and the count of skipped runtime entries.
707
+ * @param options - Include-list override and progress callback.
708
+ * @returns Isolated home, an idempotent cleanup callback, and copy statistics.
677
709
  */
678
- export function createPreflightSnapshot(sourceHome) {
710
+ export function createPreflightSnapshot(sourceHome, options = {}) {
679
711
  const root = mkdtempSync(join(tmpdir(), 'ankh-transition-preflight-'));
680
712
  const home = join(root, 'home');
681
713
  try {
@@ -684,31 +716,48 @@ export function createPreflightSnapshot(sourceHome) {
684
716
  const context = {
685
717
  externalRoot: join(root, 'materialized'),
686
718
  rootCanonical: source,
719
+ includeTopLevel: new Set(options.includeTopLevel ?? SNAPSHOT_INCLUDED_TOP_LEVEL),
687
720
  destinations: new Map(),
688
721
  pendingLinks: [],
689
722
  directories: [],
690
723
  skippedRuntimeEntries: 0,
724
+ copiedFiles: 0,
725
+ copiedBytes: 0,
726
+ ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }),
691
727
  };
692
728
  copySnapshotNode(source, home, context);
693
729
  resolveSnapshotLinks(context);
694
730
  assertSnapshotLinksContained(root, realpathSync(root));
695
731
  finalizeSnapshotDirectories(context);
696
- return { home, root, skippedRuntimeEntries: context.skippedRuntimeEntries, cleanup: () => { rmSync(root, { recursive: true, force: true }); } };
732
+ return {
733
+ home,
734
+ root,
735
+ skippedRuntimeEntries: context.skippedRuntimeEntries,
736
+ copiedFiles: context.copiedFiles,
737
+ copiedBytes: context.copiedBytes,
738
+ cleanup: () => { rmSync(root, { recursive: true, force: true }); },
739
+ };
697
740
  }
698
741
  catch (error) {
699
742
  rmSync(root, { recursive: true, force: true });
700
743
  throw error;
701
744
  }
702
745
  }
703
- export function createTransitionPreflightSnapshot(plan) {
704
- const snapshot = createPreflightSnapshot(plan.home);
746
+ export function createTransitionPreflightSnapshot(plan, options = {}) {
747
+ // A transition rehearses the plan's exact operation paths, so their
748
+ // top-level roots join the copy even when they are not boot inputs.
749
+ const operationRoots = plan.operations.map(operation => operation.path.split(sep)[0]);
750
+ const snapshot = createPreflightSnapshot(plan.home, {
751
+ ...options,
752
+ includeTopLevel: [...new Set([...(options.includeTopLevel ?? SNAPSHOT_INCLUDED_TOP_LEVEL), ...operationRoots])],
753
+ });
705
754
  try {
706
755
  const stateDir = join(snapshot.root, 'guard-state');
707
756
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
708
757
  const rebound = { ...plan, home: snapshot.home };
709
758
  const reference = prepareTransition(rebound, snapshot.home, stateDir, 'preflight');
710
759
  applyTransition(reference, snapshot.home, stateDir, 'preflight');
711
- return { home: snapshot.home, cleanup: snapshot.cleanup };
760
+ return snapshot;
712
761
  }
713
762
  catch (error) {
714
763
  snapshot.cleanup();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khorsheed/dsh-ankh-guard",
3
3
  "description": "Hard gate for self-modification restarts: a green-build credential bound to the git HEAD, checked before any restart of the running instance",
4
- "version": "0.3.2",
4
+ "version": "0.4.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -44,14 +44,14 @@
44
44
  "license": "MIT",
45
45
  "peerDependencies": {
46
46
  "@deepseek-ai/cordis": "^4.0.1",
47
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
48
- "@deepseek-ai/dsh-agent-preset-registry": "^0.1.0-rc.6",
49
- "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6",
50
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
51
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
52
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
53
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
54
- "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6",
47
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1",
48
+ "@deepseek-ai/dsh-agent-preset-registry": "^0.1.0-rc.6 || ^0.2.0-rc.1",
49
+ "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.2.0-rc.1",
50
+ "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6 || ^0.2.0-rc.1",
51
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6 || ^0.2.0-rc.1",
52
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6 || ^0.2.0-rc.1",
53
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1",
54
+ "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.2.0-rc.1",
55
55
  "@deepseek-ai/schemastery": "^3.18.1"
56
56
  },
57
57
  "peerDependenciesMeta": {
@@ -90,7 +90,7 @@
90
90
  },
91
91
  "compat": {
92
92
  "minHost": "0.1.5-rc.1",
93
- "notes": "0.1.5-rc.1 full-line boot-verified 2026-09-25 (42 packages including capture) through the three compat layers: preset-registry dual-name probe, dual-shape typert codecs, and typert faces carrying zod@4; 0.1.7-rc.2 is the 3080 production-verified line; composition-preflight runs via the standalone preflight-runner and degrades to a notice when no live harness checkout resolves; original-tab browser handoff feature-probes optional WebServer/connection auth seams; reversible state quarantine was exercised in a live 0.1.1-rc.2 to 0.1.2-alpha.4 cutover",
93
+ "notes": "0.1.5-rc.1 full-line boot-verified 2026-09-25 (42 packages including capture) through the three compat layers: preset-registry dual-name probe, dual-shape typert codecs, and typert faces carrying zod@4; 0.1.7-rc.2 is the 3080 production-verified line; composition-preflight runs via the standalone preflight-runner and degrades to a notice when no live harness checkout resolves; original-tab browser handoff feature-probes optional WebServer/connection auth seams; reversible state quarantine was exercised in a live 0.1.1-rc.2 to 0.1.2-alpha.4 cutover; since 0.4.0 the preflight verdict also fails on any agent preset the registry marks broken — preset rows mount on standing scopes beside the profile tree, so a clean-booting profile whose presets cannot serve sessions (picker 加载失败, resume never-started) no longer passes the gate",
94
94
  "verifiedHost": "0.1.7-rc.2"
95
95
  }
96
96
  }
@@ -48,6 +48,11 @@
48
48
  # WD_CUTOVER_ID=ID durable launch-cutover receipt transaction
49
49
  # WD_CUTOVER_POLICY= restore-previous or wait-for-user (approved pre-stop)
50
50
  # WD_CUTOVER_DELAY_SECONDS=N grace after supervisor claim before old-child stop
51
+ # WD_CUTOVER_PARKED=1 hold the supervision claim while the receipt sits in
52
+ # awaiting-user: never launch the rejected side, consume
53
+ # abort/restore control markers, exit on watchdog-stop,
54
+ # and release the claim when a --cutover-id resume flips
55
+ # the receipt out of awaiting-user
51
56
  # WD_PREVIOUS_CHILD_*=PID/start token authoritative old supervisor child root
52
57
  # WD_PREVIOUS_LISTENER_*=PID/start token listener inside that old child tree
53
58
  # WD_READY_STABILITY_SECONDS=N unchanged child/listener proof window (default 3)
@@ -61,7 +66,9 @@
61
66
  # WD_TEST_BREAK=1 launch a command that always fails (give-up testing)
62
67
  #
63
68
  # Markers under the state directory (written by the app or the agent):
64
- # restart-requested.json -> intentional restart: respawn + canary + clear
69
+ # restart-requested.json -> intentional restart: respawn + canary + clear;
70
+ # an optional integer bootTimeoutMs field overrides
71
+ # WD_BOOT_TIMEOUT for boots attempted while it is pending
65
72
  # watchdog-stop -> exit the watchdog without respawn
66
73
  set -u
67
74
 
@@ -219,6 +226,19 @@ cutover_control_action() {
219
226
  ' "$CONTROL_RESTORE_FILE" "$CONTROL_ABORT_FILE" "$CONTROL_FILE" "$CUTOVER_ID" 2>/dev/null
220
227
  }
221
228
 
229
+ # The durable receipt's current phase, empty when unreadable. The parked hold
230
+ # treats an unreadable receipt as still parked (hold, never flap): only a
231
+ # cleanly read non-awaiting-user phase releases the claim.
232
+ cutover_receipt_phase() {
233
+ node -e '
234
+ const fs = require("fs")
235
+ try {
236
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))
237
+ if (typeof value?.phase === "string") process.stdout.write(value.phase)
238
+ } catch {}
239
+ ' "$STATE_DIR/launch-cutover.json" 2>/dev/null
240
+ }
241
+
222
242
  # The host owns the shape of its per-process launch URL. Discovery is generic:
223
243
  # the first HTTP URL printed by THIS attempt whose authority is exactly the
224
244
  # supervised loopback authority and whose query is non-empty. No parameter
@@ -1239,6 +1259,57 @@ if [ -n "${ANKH_GUARD_TEST_RUN_DIR:-}" ]; then wd_sleep 0.01; fi
1239
1259
  test_event_self "${ANKH_GUARD_TEST_PROCESS_ROLE:-watchdog}" keepalive-first-tick
1240
1260
  test_event_self "${ANKH_GUARD_TEST_PROCESS_ROLE:-watchdog}" ready
1241
1261
 
1262
+ # Parked awaiting-user hold (WD_CUTOVER_PARKED=1, spawned by a bare `supervise`
1263
+ # that found the receipt waiting for a user decision): the selected side was
1264
+ # explicitly rejected, so NOTHING is launched here. The hold keeps exactly one
1265
+ # live consumer for the operator control verbs — without it, a supervisor chain
1266
+ # that died mid-cutover wedges the transaction: abort-cutover/restore-previous
1267
+ # refuse with "no live watchdog", and a bare supervise used to exit 0.
1268
+ if [ "${WD_CUTOVER_PARKED:-0}" = "1" ]; then
1269
+ wd_log "launch cutover $CUTOVER_ID is awaiting user action — holding the supervision claim WITHOUT launching the rejected $CUTOVER_ROLE side; settle: abort-cutover / restore-previous; exit: the watchdog-stop marker; resume the rejected side: supervise --cutover-id $CUTOVER_ID"
1270
+ while true; do
1271
+ # Same ownership discipline as the main loop: self-heal a deleted claim,
1272
+ # yield to a different live owner.
1273
+ if [ "$SUPERVISE" = "1" ]; then
1274
+ if [ ! -f "$PIDFILE" ]; then (set -C; echo $$ > "$PIDFILE") 2>/dev/null || true; fi
1275
+ pidowner=$(cat "$PIDFILE" 2>/dev/null)
1276
+ if [ -n "$pidowner" ] && [ "$pidowner" != "$$" ] && kill -0 "$pidowner" 2>/dev/null; then
1277
+ wd_log "pidfile now owned by live pid $pidowner — yielding the parked hold"
1278
+ yielded=1
1279
+ exit 75
1280
+ fi
1281
+ fi
1282
+ # Deliberate stop: exit without respawn or settlement, exactly like the
1283
+ # main loop's stop-marker path.
1284
+ if [ -f "$STOP_MARKER" ]; then
1285
+ wd_log "stop marker present — exiting the parked hold"
1286
+ rm -f "$STOP_MARKER" "$PIDFILE"
1287
+ exit 0
1288
+ fi
1289
+ # Operator control before the release check: a restore decision consumes
1290
+ # the marker and breaks into the ordinary cutover resume path below, which
1291
+ # boots the previous complete launch specification.
1292
+ if handle_cutover_control; then
1293
+ if [ "$control_result" = "restore" ]; then
1294
+ wd_log "operator control selected the previous complete launch specification"
1295
+ break
1296
+ fi
1297
+ wd_log "operator control consumed; cutover $CUTOVER_ID remains parked awaiting user action"
1298
+ continue
1299
+ fi
1300
+ # A `supervise --cutover-id` resume records driver-started, flipping the
1301
+ # receipt out of awaiting-user — this hold's release signal. Exit non-zero
1302
+ # like a yield so an external supervisor (launchd/systemd) restarts its
1303
+ # stable launcher, which then waits behind the resuming chain.
1304
+ phase=$(cutover_receipt_phase)
1305
+ if [ -n "$phase" ] && [ "$phase" != "awaiting-user" ]; then
1306
+ wd_log "cutover $CUTOVER_ID left awaiting-user (phase $phase) — releasing the claim for the resuming supervisor"
1307
+ exit 75
1308
+ fi
1309
+ wd_sleep 1
1310
+ done
1311
+ fi
1312
+
1242
1313
  write_cutover_restart_marker() {
1243
1314
  node -e '
1244
1315
  const fs = require("fs")
@@ -1252,6 +1323,21 @@ write_cutover_restart_marker() {
1252
1323
  ' "$RESTART_MARKER" "$CUTOVER_ID" "${WD_INITIATOR:-}"
1253
1324
  }
1254
1325
 
1326
+ # A scheduled exit may carry a one-boot readiness budget (schedule-exit
1327
+ # --boot-timeout-ms), recorded in the restart marker as whole milliseconds.
1328
+ # Validate before trusting: an unreadable or out-of-shape marker changes
1329
+ # nothing — the watchdog's own WD_BOOT_TIMEOUT stays in effect.
1330
+ restart_marker_boot_timeout() {
1331
+ node -e '
1332
+ const fs = require("fs")
1333
+ try {
1334
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))
1335
+ const ms = value?.bootTimeoutMs
1336
+ if (Number.isInteger(ms) && ms >= 1000) process.stdout.write(String(Math.ceil(ms / 1000)))
1337
+ } catch {}
1338
+ ' "$RESTART_MARKER" 2>/dev/null
1339
+ }
1340
+
1255
1341
  consume_takeover_control() {
1256
1342
  if handle_cutover_control; then
1257
1343
  if [ "$control_result" = "wait" ]; then
@@ -1460,9 +1546,19 @@ while true; do
1460
1546
  current_listener_start=''
1461
1547
  # Boot window: transport-up is not enough. A protected root can answer 401;
1462
1548
  # ready_probe completes the process's announced launch-URL cookie exchange.
1549
+ # The budget is the watchdog's own WD_BOOT_TIMEOUT unless the pending restart
1550
+ # marker carries a one-boot override (schedule-exit --boot-timeout-ms).
1551
+ attempt_boot_timeout=$BOOT_TIMEOUT
1552
+ if [ -f "$RESTART_MARKER" ]; then
1553
+ marker_boot_timeout=$(restart_marker_boot_timeout)
1554
+ case "$marker_boot_timeout" in
1555
+ ''|*[!0-9]*) ;;
1556
+ *) attempt_boot_timeout=$marker_boot_timeout ;;
1557
+ esac
1558
+ fi
1463
1559
  up=0
1464
- readiness_failure_detail="readiness not proven within ${BOOT_TIMEOUT}s"
1465
- boot_limit=$(( $(date +%s) + BOOT_TIMEOUT ))
1560
+ readiness_failure_detail="readiness not proven within ${attempt_boot_timeout}s"
1561
+ boot_limit=$(( $(date +%s) + attempt_boot_timeout ))
1466
1562
  while [ "$(date +%s)" -lt "$boot_limit" ]; do
1467
1563
  if [ -n "$(cutover_control_action)" ]; then
1468
1564
  readiness_failure_detail="operator control interrupted readiness"
@@ -1499,8 +1595,15 @@ while true; do
1499
1595
  redact_launch_urls_in_output
1500
1596
  # Mirror the captured output into the watchdog log: with plain redirection
1501
1597
  # (see the launch site) the attempt log is the only place the failure was
1502
- # written, and the watchdog log is where an operator looks first.
1503
- sed 's/^/[instance] /' "$ATTEMPT_LOG" 2>/dev/null
1598
+ # written, and the watchdog log is where an operator looks first. An EMPTY
1599
+ # attempt log is itself the signal — "the instance never wrote a line" is
1600
+ # what distinguished a dead-on-arrival boot from a noisy one in the
1601
+ # 2026-09-30 launchd incident, and mirroring nothing hid it for an hour.
1602
+ if [ -s "$ATTEMPT_LOG" ]; then
1603
+ sed 's/^/[instance] /' "$ATTEMPT_LOG" 2>/dev/null
1604
+ else
1605
+ wd_log "attempt $current_attempt produced no output — the instance never wrote a line (captured log $ATTEMPT_LOG is empty)"
1606
+ fi
1504
1607
 
1505
1608
  if [ -n "$CUTOVER_ID" ] && [ -n "$(cutover_control_action)" ]; then
1506
1609
  failures=$((failures + 1))