dsh-instruction-memory 1.4.8 → 1.5.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.
package/lib/index.js CHANGED
@@ -25,7 +25,7 @@
25
25
  * user-chosen backup directory (F18).
26
26
  */
27
27
 
28
- import { chmod, copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
28
+ import { chmod, copyFile, mkdir, open, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
29
29
  import { readFileSync, realpathSync, statSync, watch } from 'node:fs'
30
30
  import { randomUUID } from 'node:crypto'
31
31
  import { isIP } from 'node:net'
@@ -102,6 +102,17 @@ const MAX_HISTORY = 20
102
102
  * when it alone exceeds the budget.
103
103
  */
104
104
  const MAX_HISTORY_BYTES = 32 * 1024 * 1024
105
+ /**
106
+ * The largest single snapshot this build will READ back. Every other read path
107
+ * is bounded (the store by MAX_BODY_CHARS, exports by their own ceilings); the
108
+ * restore path historically was not, so a corrupt or hostile file in the
109
+ * history directory could be pulled whole into memory by one click. Snapshots
110
+ * are serialised stores, so this sits above the store ceiling with room to
111
+ * spare while still refusing an absurd file.
112
+ */
113
+ const MAX_HISTORY_FILE_BYTES = 8 * 1024 * 1024
114
+ /** The feedback config is a handful of keys; anything near this is not one. */
115
+ const MAX_FEEDBACK_CONFIG_BYTES = 1024 * 1024
105
116
  /**
106
117
  * F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
107
118
  * far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
@@ -288,6 +299,14 @@ export function expandDisplayPath(value, home = homedir(), platform = process.pl
288
299
  * .bak — and only the last two may never be treated as "no memory yet".
289
300
  */
290
301
  const EMPTY_STORE_CODE = 'EEMPTYSTORE'
302
+ /**
303
+ * A primary that parses as JSON but is not a store at all. Same role as
304
+ * EMPTY_STORE_CODE, for the state JSON.parse cannot flag: the file is valid
305
+ * JSON, so "it read" says nothing about whether it is a store. Keeping it a
306
+ * distinct code is what lets the .bak rotation refuse to copy it over the last
307
+ * good backup while leaving the file itself untouched for hand repair.
308
+ */
309
+ const NOT_A_STORE_CODE = 'ENOTASTORE'
291
310
 
292
311
  const DEFAULT_BUDGET = 4000
293
312
  const MIN_BUDGET = 800
@@ -371,8 +390,14 @@ export const LIMITS = Object.freeze({
371
390
  maxFeedbackContact: FEEDBACK_LIMITS.maxContact,
372
391
  })
373
392
 
374
- /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
375
- const LOCK_STALE_MS = 10_000
393
+ /**
394
+ * How long a crashed holder's `memory.json.lock` may block writes before it is
395
+ * stolen. This must exceed the worst-case hold time (tmp write + two copies +
396
+ * rename + a large history snapshot), so a LIVE slow holder is not mistaken for
397
+ * debris and swept mid-write: the reclaim path re-checks stat identity, but a
398
+ * wide margin is cheaper than relying on that.
399
+ */
400
+ const LOCK_STALE_MS = 30_000
376
401
  /** How long a write waits for the cross-process lock before proceeding unlocked. */
377
402
  const LOCK_WAIT_MS = 2_000
378
403
  /** Coalescing window for store-directory watcher events before a re-read. */
@@ -775,6 +800,40 @@ const describeValue = (value) => {
775
800
  * `dropped`: the next save persists the corrected values, so the user must be
776
801
  * told their hand edit did not survive verbatim.
777
802
  */
803
+ /**
804
+ * Does this PARSED json carry a store's shape, as opposed to merely being
805
+ * valid JSON?
806
+ *
807
+ * The distinction is load-bearing: a file that parses but is an array, a
808
+ * number, or some other tool's object used to be adopted as "an empty
809
+ * memory" with no warning at all, so the very next save wrote that emptiness
810
+ * back over the user's memory and rotated the result into `.bak`. JSON.parse
811
+ * answering is not the same question as "is this a store", and every place
812
+ * that has to ask the second one now asks it here.
813
+ *
814
+ * @returns 'store' for anything adoptable, 'empty-object' for a truthy but
815
+ * empty store (`{}`, or only option keys — a hand-cleared file), and
816
+ * 'not-object' / 'not-store' for the two states that must never be adopted.
817
+ */
818
+ export function storeShapeVerdict(parsed) {
819
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return 'not-object'
820
+ // A declared, supported version is enough on its own: `{"version":2}` is a
821
+ // store that happens to be empty (and its formatNote says so), not a
822
+ // foreign file. An unsupported one is handled before this test by the
823
+ // version gate in normalizeData.
824
+ if (typeof parsed.version === 'number' && SUPPORTED_VERSIONS.includes(parsed.version)) return 'store'
825
+ if (Array.isArray(parsed.entries) || Array.isArray(parsed.sets)) return 'store'
826
+ // Every key is one the loader itself understands: `{}` and `{"rev":3}` are
827
+ // empty stores, not somebody else's document.
828
+ const OPTION_KEYS = new Set([
829
+ 'version', 'rev', 'enabled', 'disabledUntil', 'budgetChars',
830
+ 'backupDir', 'activeSetId', 'entries', 'sets', 'profiles',
831
+ ])
832
+ const keys = Object.keys(parsed)
833
+ if (keys.length === 0 || keys.every((key) => OPTION_KEYS.has(key))) return 'empty-object'
834
+ return 'not-store'
835
+ }
836
+
778
837
  function normalizeData(raw) {
779
838
  const data = {
780
839
  version: FORMAT_VERSION,
@@ -790,8 +849,18 @@ function normalizeData(raw) {
790
849
  }
791
850
  const loaded = { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], formatNote: null }
792
851
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return loaded
793
- if (typeof raw.version === 'number' && !SUPPORTED_VERSIONS.includes(raw.version)) {
794
- return { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], rejectedVersion: raw.version }
852
+ // A declared version this build does not support is refused whatever its
853
+ // TYPE. The old test was `typeof raw.version === 'number'`, so a file
854
+ // carrying `"2.0"` or `"3"` read as "no version declared" and the next save
855
+ // rewrote it as the current format — the exact downgrade this gate exists
856
+ // to prevent, merely reached through a string.
857
+ if ('version' in raw && raw.version !== null && raw.version !== undefined) {
858
+ if (!(typeof raw.version === 'number' && SUPPORTED_VERSIONS.includes(raw.version))) {
859
+ return {
860
+ data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [],
861
+ rejectedVersion: typeof raw.version === 'number' ? raw.version : String(raw.version),
862
+ }
863
+ }
795
864
  }
796
865
  if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
797
866
  data.rev = Math.round(raw.rev)
@@ -1443,7 +1512,6 @@ function buildProfilesBlock(profiles, budget) {
1443
1512
  const blocks = []
1444
1513
  const items = []
1445
1514
  let used = PERSONA_HEADER.length + 1
1446
- let omitted = 0
1447
1515
  for (const profile of active) {
1448
1516
  const name = typeof profile.name === 'string' && profile.name !== ''
1449
1517
  ? profile.name
@@ -1475,7 +1543,6 @@ function buildProfilesBlock(profiles, budget) {
1475
1543
  items.push({ id, name, chars: head.length + note.length, truncated: true })
1476
1544
  used += sep + head.length + note.length
1477
1545
  }
1478
- omitted = active.length - blocks.length
1479
1546
  break
1480
1547
  }
1481
1548
  const text = PERSONA_HEADER + '\n' + blocks.join('\n\n') + footerFor(Math.max(0, active.length - blocks.length))
@@ -1748,6 +1815,15 @@ export function apply(ctx) {
1748
1815
  */
1749
1816
  versionRejected: false,
1750
1817
  rejectedVersion: null,
1818
+ /**
1819
+ * Set while the file on disk parses as JSON but is not a store at all (an
1820
+ * array, a number, another tool's object). Adopting it as "an empty
1821
+ * memory" and then saving over it is precisely the silent-loss chain the
1822
+ * rolling backup exists to survive, so ordinary edits are refused in this
1823
+ * state; the repair doors (history restore, restore-from-backup, import,
1824
+ * source mode) stay open and clear it on the first successful write.
1825
+ */
1826
+ shapeRejected: false,
1751
1827
  sectionRegistered: false,
1752
1828
  sectionError: null,
1753
1829
  /** Whether a store file is actually present on disk (diagnostics + the panel). */
@@ -2397,8 +2473,36 @@ export function apply(ctx) {
2397
2473
  }
2398
2474
  /** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
2399
2475
  const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
2476
+ /**
2477
+ * The backup directory the WRITE path may use, or '' when the configured one
2478
+ * must be ignored.
2479
+ *
2480
+ * `setOptions` and `exportToDir` each check the subtree rule, but they are
2481
+ * not the only writers: every save also drops a `.bak` and a history
2482
+ * snapshot into this directory. A store hand-edited to point outside
2483
+ * `$DSH_HOME` therefore escaped the fence on the very next save, which
2484
+ * contradicted the promise that every write stays inside the harness home.
2485
+ * The same rule now runs where the writes actually happen; the refusal is
2486
+ * disclosed once per process instead of silently relocated.
2487
+ */
2488
+ let refusedBackupDir = null
2489
+ const effectiveBackupDir = () => {
2490
+ const configured = backupDirOf()
2491
+ if (configured === '') return ''
2492
+ if (allowExternalBackup() || isInsideDshRoot(configured)) return configured
2493
+ if (refusedBackupDir !== configured) {
2494
+ refusedBackupDir = configured
2495
+ state.storageWarning = '备份位置 ' + displayPath(configured) + ' 不在 DSH 主目录内,已忽略(备份与历史快照一律写回默认位置);'
2496
+ + '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主'
2497
+ logInfo('ignoring backupDir outside the harness home: ' + configured)
2498
+ }
2499
+ return ''
2500
+ }
2400
2501
  /** The user-facing copy under a custom backup location, or null (F18). */
2401
- const customBackupPath = () => (backupDirOf() !== '' ? join(backupDirOf(), basename(storePath) + BACKUP_FILE_SUFFIX) : null)
2502
+ const customBackupPath = () => {
2503
+ const dir = effectiveBackupDir()
2504
+ return dir !== '' ? join(dir, basename(storePath) + BACKUP_FILE_SUFFIX) : null
2505
+ }
2402
2506
  /**
2403
2507
  * History snapshots live in a `history/` SUBDIRECTORY of the backup location
2404
2508
  * (default: the store directory). Two reasons for the subdirectory:
@@ -2409,7 +2513,10 @@ export function apply(ctx) {
2409
2513
  * 2. a custom (e.g. cloud-synced) backup directory stays tidy: one known
2410
2514
  * folder holds the history, nothing else is touched.
2411
2515
  */
2412
- const historyDirPath = () => join(backupDirOf() !== '' ? backupDirOf() : dirname(storePath), HISTORY_DIR_NAME)
2516
+ const historyDirPath = () => {
2517
+ const dir = effectiveBackupDir()
2518
+ return join(dir !== '' ? dir : dirname(storePath), HISTORY_DIR_NAME)
2519
+ }
2413
2520
 
2414
2521
  /**
2415
2522
  * Cross-process write lock: a `memory.json.lock` marker created exclusively
@@ -2459,12 +2566,22 @@ export function apply(ctx) {
2459
2566
  if (err.code !== 'EEXIST') throw err
2460
2567
  const info = await stat(lockPath).catch(() => null)
2461
2568
  if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
2462
- // Crashed-holder debris. It may be recreated by a live process
2463
- // between this unlink and the next wx attempt — that case simply
2464
- // loops and re-evaluates freshness instead of forcing its way in.
2465
- // A failed unlink must not become a tight loop, so every attempt
2466
- // still sleeps below.
2467
- await unlink(lockPath).catch(() => {})
2569
+ // Confirm the marker we are about to delete is STILL the one we just
2570
+ // judged stale. Between the stat above and the unlink a live holder
2571
+ // can release it and a new owner can create a fresh marker; deleting
2572
+ // THAT would unlock a live writer, the same TOCTOU the token closed
2573
+ // on release. The mtime+size re-check makes the sweep ownership-safe
2574
+ // without needing to know the dead holder's token.
2575
+ const after = await stat(lockPath).catch(() => null)
2576
+ if (after !== null && after.mtimeMs === info.mtimeMs && after.size === info.size
2577
+ && Date.now() - after.mtimeMs > LOCK_STALE_MS) {
2578
+ // Crashed-holder debris. It may be recreated by a live process
2579
+ // between this unlink and the next wx attempt — that case simply
2580
+ // loops and re-evaluates freshness instead of forcing its way in.
2581
+ // A failed unlink must not become a tight loop, so every attempt
2582
+ // still sleeps below.
2583
+ await unlink(lockPath).catch(() => {})
2584
+ }
2468
2585
  }
2469
2586
  await new Promise((resolve) => setTimeout(resolve, 50))
2470
2587
  }
@@ -2545,14 +2662,22 @@ export function apply(ctx) {
2545
2662
  let lastPersistedKey = null
2546
2663
 
2547
2664
  /**
2548
- * Enforce the per-device retention policy: the count and byte ceilings from
2549
- * planHistoryPrune(). Grouping by the device tag inside the filename is
2550
- * deliberate: two machines sharing one backup folder (the documented F18
2551
- * setup) each keep their OWN budget, so one device can never prune the
2552
- * other's history away. Files that do not match our naming pattern are never
2553
- * touched. Sizes come from stat() rather than a maintained index because the
2554
- * directory is shared with other processes and a stale index would prune the
2555
- * wrong files.
2665
+ * Enforce the retention policy: the count and byte ceilings from
2666
+ * planHistoryPrune(), applied PER DEVICE first. Grouping by the device tag
2667
+ * inside the filename is deliberate: two machines sharing one backup folder
2668
+ * (the documented F18 setup) each keep their OWN budget, so a device that
2669
+ * saves a lot cannot prune a quiet device's history.
2670
+ *
2671
+ * The GLOBAL byte ceiling afterwards is the documented exception to that:
2672
+ * per-device budgets multiply by the number of machines (ten machines = ten
2673
+ * × 32 MB), so the folder as a whole is also bounded, and that pass deletes
2674
+ * the oldest snapshots whichever device they came from. It is disclosed
2675
+ * rather than hidden — the name ordering is chronological across devices
2676
+ * because the stamp is fixed-width UTC.
2677
+ *
2678
+ * Files that do not match our naming pattern are never touched. Sizes come
2679
+ * from stat() rather than a maintained index because the directory is shared
2680
+ * with other processes and a stale index would prune the wrong files.
2556
2681
  */
2557
2682
  const pruneHistory = async (dir) => {
2558
2683
  const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
@@ -2652,7 +2777,19 @@ export function apply(ctx) {
2652
2777
  const source = await readFile(fileURLToPath(new URL('../' + SCHEMA_FILE_NAME, import.meta.url)), 'utf8')
2653
2778
  const target = join(dirname(storePath), SCHEMA_FILE_NAME)
2654
2779
  const existing = await readFile(target, 'utf8').catch(() => null)
2655
- if (existing !== source) await writeFile(target, source, 'utf8')
2780
+ if (existing !== source) {
2781
+ // Atomic like the store itself: a crash mid-copy must not leave a
2782
+ // truncated schema for the user's editor to validate against, and the
2783
+ // 0600 bit matches every other file this plugin writes.
2784
+ const tmp = target + '.tmp-' + randomUUID()
2785
+ try {
2786
+ await writeFile(tmp, source, { encoding: 'utf8', mode: 0o600 })
2787
+ await rename(tmp, target)
2788
+ } catch (err) {
2789
+ await unlink(tmp).catch(() => {})
2790
+ throw err
2791
+ }
2792
+ }
2656
2793
  } catch {
2657
2794
  // Best effort by design.
2658
2795
  }
@@ -2673,6 +2810,21 @@ export function apply(ctx) {
2673
2810
  }
2674
2811
  }
2675
2812
 
2813
+ /**
2814
+ * Best-effort durability fence. `rename` is atomic against a crash but NOT
2815
+ * durable on its own: without an fsync the directory entry can reach disk
2816
+ * after the data does, so power loss may leave the previous file or a 0-byte
2817
+ * one even though the save reported success. A failure here is deliberately
2818
+ * not a save failure (the bytes are already in the page cache, and Windows
2819
+ * refuses to open directories at all), so it degrades durability only.
2820
+ */
2821
+ const syncPath = async (target) => {
2822
+ try {
2823
+ const fh = await open(target, 'r')
2824
+ try { await fh.sync() } finally { await fh.close() }
2825
+ } catch { /* unsupported here: durability degrades, correctness does not */ }
2826
+ }
2827
+
2676
2828
  const writeToDisk = async () => {
2677
2829
  // Atomic replace (write a sibling temp file, then rename) with a rolling
2678
2830
  // one-version backup: a crash mid-write must never leave a truncated
@@ -2687,7 +2839,13 @@ export function apply(ctx) {
2687
2839
  // user's memory while their real data sat untouched in .bak. Copying keeps
2688
2840
  // the primary in place until the atomic replace succeeds, so a failure at
2689
2841
  // any step leaves the previous store readable at its own path.
2690
- const tmpPath = storePath + '.tmp'
2842
+ // A UNIQUE tmp name per attempt: two processes sharing a $DSH_HOME must
2843
+ // never interleave their writes into one temp file. The fixed
2844
+ // `storePath + '.tmp'` meant the second writer's rename could publish a
2845
+ // half-written mix of both serialisations. The lock narrows the window; a
2846
+ // per-attempt name removes the shared resource, so the window cannot
2847
+ // corrupt anything even when the lock is unavailable.
2848
+ const tmpPath = storePath + '.tmp-' + process.pid + '-' + randomUUID()
2691
2849
  let lock = null
2692
2850
  let previousRev = null
2693
2851
  try {
@@ -2730,6 +2888,10 @@ export function apply(ctx) {
2730
2888
  const contentKey = JSON.stringify({ ...persistableData(), rev: 0 })
2731
2889
  if (state.storeExists && lastPersistedKey !== null && contentKey === lastPersistedKey) {
2732
2890
  logDebug('save skipped: the store content did not change')
2891
+ // The caller's "上次保存" clock reports the last successful SAVE, and
2892
+ // this call is one, even though the bytes were already correct. Leaving
2893
+ // it untouched made the panel look stalled after a no-op save.
2894
+ state.savedAt = Date.now()
2733
2895
  return true
2734
2896
  }
2735
2897
  // The rev is bumped before serialising so the file on disk and the state
@@ -2747,7 +2909,15 @@ export function apply(ctx) {
2747
2909
  // user profile) personal notes, so on a multi-user POSIX host it must not
2748
2910
  // land world-readable the way a bare writeFile (0666 & ~umask = 0644)
2749
2911
  // would. Windows ignores the bit and inherits the directory ACL instead.
2750
- await writeFile(tmpPath, text, { encoding: 'utf8', mode: 0o600 })
2912
+ const tmpHandle = await open(tmpPath, 'w', 0o600)
2913
+ try {
2914
+ await tmpHandle.writeFile(text, 'utf8')
2915
+ // Flush the temp file BEFORE the rename publishes it: otherwise a
2916
+ // power cut can leave the new name pointing at unwritten blocks.
2917
+ await tmpHandle.sync()
2918
+ } finally {
2919
+ await tmpHandle.close()
2920
+ }
2751
2921
  // Both rolling copies stand for the PREVIOUS good version, so both are
2752
2922
  // taken before the atomic replace, and neither may ever be renamed into
2753
2923
  // place (see the long note above).
@@ -2762,8 +2932,22 @@ export function apply(ctx) {
2762
2932
  // discloses it (the .bak may be stale, and the panel says so).
2763
2933
  let hadPrevious = true
2764
2934
  try {
2765
- await readStoreFile(storePath)
2935
+ const previous = await readStoreFile(storePath)
2936
+ // "Reads as a store", not merely "reads as JSON" — this is the test the
2937
+ // comment above promises. A foreign-but-parseable primary (an array, a
2938
+ // scalar, another tool's object) must NOT be copied over the last good
2939
+ // backup: that is exactly what turns a recoverable state into a
2940
+ // permanent one.
2941
+ const previousShape = storeShapeVerdict(previous)
2942
+ if (previousShape === 'not-object' || previousShape === 'not-store') {
2943
+ const shapeError = new Error('memory.json is not a store (' + previousShape + ')')
2944
+ shapeError.code = NOT_A_STORE_CODE
2945
+ throw shapeError
2946
+ }
2766
2947
  await copyFile(storePath, localBackupPath())
2948
+ // The recovery copy exists to survive a crash; an unflushed copy may
2949
+ // roll back with the very crash it is meant to answer.
2950
+ await syncPath(localBackupPath())
2767
2951
  state.backupError = null
2768
2952
  } catch (err) {
2769
2953
  if (err !== null && err !== undefined && err.code === 'ENOENT') {
@@ -2772,6 +2956,12 @@ export function apply(ctx) {
2772
2956
  // would adopt as an "empty store" — the trap this guard exists for.
2773
2957
  hadPrevious = false
2774
2958
  state.backupError = null
2959
+ } else if (err !== null && err !== undefined && err.code === NOT_A_STORE_CODE) {
2960
+ hadPrevious = false
2961
+ const previous = await stat(localBackupPath()).catch(() => null)
2962
+ state.backupError = previous !== null && previous.size > 0
2963
+ ? 'memory.json 的内容不是记忆存储,已保留上一版备份 ' + localBackupPath() + ' 不被覆盖'
2964
+ : 'memory.json 的内容不是记忆存储,且没有可用的上一版备份'
2775
2965
  } else {
2776
2966
  hadPrevious = false
2777
2967
  const previous = await stat(localBackupPath()).catch(() => null)
@@ -2800,6 +2990,9 @@ export function apply(ctx) {
2800
2990
  await unlink(tmpPath).catch(() => {})
2801
2991
  throw err
2802
2992
  }
2993
+ // Make the directory entry itself durable: without this the rename can be
2994
+ // lost on power failure even though the file contents were flushed.
2995
+ await syncPath(dirname(storePath))
2803
2996
  state.storagePath = storePath
2804
2997
  state.storageDisplay = storePath
2805
2998
  state.storageError = null
@@ -2816,6 +3009,9 @@ export function apply(ctx) {
2816
3009
  // committed, and the store is on disk whatever happens next.
2817
3010
  await writeHistorySnapshot(text, contentKey)
2818
3011
  lastPersistedKey = contentKey
3012
+ // The disk holds a store again, so a "primary is not a store" refusal no
3013
+ // longer describes reality — this is the one place that can prove it.
3014
+ state.shapeRejected = false
2819
3015
  await ensureSchemaFile()
2820
3016
  logDebug('store written: ' + storePath + ' bytes=' + text.length
2821
3017
  + ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
@@ -2891,6 +3087,13 @@ export function apply(ctx) {
2891
3087
  state.storeExists = false
2892
3088
  state.versionRejected = false
2893
3089
  state.rejectedVersion = null
3090
+ state.shapeRejected = false
3091
+ // A reload replaces what "the last persisted content" means. Leaving the
3092
+ // old identity in place made a later save that happened to match the
3093
+ // PRE-reload content skip the write while reporting saved:true, so the disk
3094
+ // and the panel silently disagreed. Forcing the next save to write is the
3095
+ // cheap side of that trade.
3096
+ lastPersistedKey = null
2894
3097
 
2895
3098
  /**
2896
3099
  * A store declaring an unsupported format version: adopt nothing, write
@@ -2908,12 +3111,66 @@ export function apply(ctx) {
2908
3111
  const rejectVersion = (version, source) => {
2909
3112
  state.versionRejected = true
2910
3113
  state.rejectedVersion = version
2911
- state.storageError = '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
3114
+ // A string that spells a supported number ("2") produced the absurd
3115
+ // "declared version 2, supported versions 1 / 2". The TYPE is the
3116
+ // problem, so say that instead of comparing it to the list.
3117
+ state.storageError = (typeof version === 'number'
3118
+ ? '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
3119
+ : '存储文件的 version 字段类型不正确:必须是数字,当前是 ' + JSON.stringify(version === undefined ? null : version)
3120
+ + ';本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / '))
2912
3121
  + ';已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回受支持的版本后点「重新载入」'
2913
3122
  state.loadedFrom = source + ' (unsupported version ' + version + ')'
2914
3123
  state.storeExists = true
2915
3124
  }
2916
3125
 
3126
+ /**
3127
+ * The primary parses as JSON but is not a store (an array, a scalar, or
3128
+ * another tool's object). Adopt NOTHING from it: the in-memory data — a
3129
+ * freshly booted empty store, or whatever this process already had — stays
3130
+ * authoritative, ordinary edits are refused, and both files stay on disk.
3131
+ * The repair doors (history restore, restore-from-backup, import, source
3132
+ * mode) remain open and clear the flag on the first successful write.
3133
+ */
3134
+ const rejectShape = (describe) => {
3135
+ state.shapeRejected = true
3136
+ state.storageError = 'memory.json 的内容不是本插件的记忆存储:' + describe
3137
+ + '。已暂停写入以免覆盖原文件(原文件已保留)。请用「历史回滚」或「用备份整体替换」恢复,或修正该文件后点「重新载入」'
3138
+ state.loadedFrom = 'memory.json (not a store; writes paused)'
3139
+ state.storeExists = true
3140
+ }
3141
+
3142
+ /**
3143
+ * A primary that is not a store still leaves the rolling backup intact, so
3144
+ * the last good version is usually one file away. Promote it into memory
3145
+ * (never back onto disk here) and disclose both facts: the primary is
3146
+ * foreign, and the panel is temporarily showing the backup.
3147
+ */
3148
+ const recoverFromForeignPrimary = async (shape) => {
3149
+ state.storeExists = true
3150
+ const describe = shape === 'not-object'
3151
+ ? '顶层不是对象(是数组或标量)'
3152
+ : '顶层对象里没有 entries / sets / version 等记忆字段'
3153
+ try {
3154
+ const backup = await readStoreFile(storePath + '.bak')
3155
+ if (backup !== null && storeShapeVerdict(backup) === 'store') {
3156
+ const loaded = normalizeData(backup)
3157
+ if (loaded.rejectedVersion !== undefined) {
3158
+ rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
3159
+ return
3160
+ }
3161
+ state.data = loaded.data
3162
+ const warnings = loadWarnings(loaded)
3163
+ state.storageWarning = (warnings !== null ? warnings + ';' : '')
3164
+ + 'memory.json ' + describe + ',不是本插件的记忆存储(原文件已保留);已临时回退到最近备份 memory.json.bak'
3165
+ state.loadedFrom = 'memory.json.bak (primary is not a store)'
3166
+ return
3167
+ }
3168
+ } catch {
3169
+ // No usable backup either; refuse rather than guess.
3170
+ }
3171
+ rejectShape(describe)
3172
+ }
3173
+
2917
3174
  // 1. The canonical location under the harness home.
2918
3175
  try {
2919
3176
  const parsed = await readStoreFile(storePath)
@@ -2923,6 +3180,14 @@ export function apply(ctx) {
2923
3180
  rejectVersion(loaded.rejectedVersion, 'memory.json')
2924
3181
  return false
2925
3182
  }
3183
+ // Parsing is not the same as being a store: an array or a foreign
3184
+ // object used to be adopted as "empty memory" with no warning, and the
3185
+ // next save wrote that emptiness back. Never adopt it.
3186
+ const shape = storeShapeVerdict(parsed)
3187
+ if (shape === 'not-object' || shape === 'not-store') {
3188
+ await recoverFromForeignPrimary(shape)
3189
+ return false
3190
+ }
2926
3191
  state.data = loaded.data
2927
3192
  state.storageWarning = loadWarnings(loaded)
2928
3193
  state.loadedFrom = 'memory.json'
@@ -2964,7 +3229,7 @@ export function apply(ctx) {
2964
3229
  : 'memory.json 解析失败(' + describeErrorSafe(err) + ')'
2965
3230
  try {
2966
3231
  const backup = await readStoreFile(storePath + '.bak')
2967
- if (backup !== null) {
3232
+ if (backup !== null && storeShapeVerdict(backup) === 'store') {
2968
3233
  const loaded = normalizeData(backup)
2969
3234
  if (loaded.rejectedVersion !== undefined) {
2970
3235
  rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
@@ -3001,7 +3266,7 @@ export function apply(ctx) {
3001
3266
  // canonical path so the next boot finds it normally.
3002
3267
  try {
3003
3268
  const backup = await readStoreFile(storePath + '.bak')
3004
- if (backup !== null) {
3269
+ if (backup !== null && storeShapeVerdict(backup) === 'store') {
3005
3270
  const loaded = normalizeData(backup)
3006
3271
  if (loaded.rejectedVersion !== undefined) {
3007
3272
  rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
@@ -3026,22 +3291,29 @@ export function apply(ctx) {
3026
3291
  try {
3027
3292
  const parsed = await readStoreFile(legacyPath)
3028
3293
  if (parsed !== null) {
3029
- const loaded = normalizeData(parsed)
3030
- if (loaded.rejectedVersion !== undefined) {
3031
- // A legacy file in a future format is not ours to migrate or judge.
3032
- state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
3033
- return false
3034
- }
3035
- state.data = loaded.data
3036
- state.storageWarning = loadWarnings(loaded)
3037
- const migrated = await writeToDisk()
3038
- if (migrated) {
3039
- state.migratedFrom = legacyPath
3040
- // Keep the original as evidence instead of deleting user data.
3041
- await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
3294
+ const legacyShape = storeShapeVerdict(parsed)
3295
+ if (legacyShape === 'not-object' || legacyShape === 'not-store') {
3296
+ // Some other program's JSON beside the old store path is not a store
3297
+ // to migrate, and must never become memory.json.
3298
+ state.storageError = '旧位置文件不是本插件的记忆存储,已忽略'
3299
+ } else {
3300
+ const loaded = normalizeData(parsed)
3301
+ if (loaded.rejectedVersion !== undefined) {
3302
+ // A legacy file in a future format is not ours to migrate or judge.
3303
+ state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
3304
+ return false
3305
+ }
3306
+ state.data = loaded.data
3307
+ state.storageWarning = loadWarnings(loaded)
3308
+ const migrated = await writeToDisk()
3309
+ if (migrated) {
3310
+ state.migratedFrom = legacyPath
3311
+ // Keep the original as evidence instead of deleting user data.
3312
+ await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
3313
+ }
3314
+ state.loadedFrom = 'legacy cwd file (migrated)'
3315
+ return migrated
3042
3316
  }
3043
- state.loadedFrom = 'legacy cwd file (migrated)'
3044
- return migrated
3045
3317
  }
3046
3318
  } catch (err) {
3047
3319
  // A missing or unreadable legacy file must never block a fresh store.
@@ -3243,6 +3515,13 @@ export function apply(ctx) {
3243
3515
  * to say which of the two situations it is looking at.
3244
3516
  */
3245
3517
  authFence: connectionGate !== null,
3518
+ /**
3519
+ * How many hostnames DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS widened the
3520
+ * Host/Origin check to. The list relaxes a HOSTNAME rule; it is not an
3521
+ * identity check, so the panel must be able to say when a widened
3522
+ * allow-list is the only thing standing in front of a fence-less route.
3523
+ */
3524
+ trustedHostCount: trustedHosts.length,
3246
3525
  },
3247
3526
  /**
3248
3527
  * F12: load-side diagnostics that used to be log-only. `loadedFrom` says
@@ -3301,9 +3580,16 @@ export function apply(ctx) {
3301
3580
  * Persona section detail: rendered profiles (id/name/chars, in order)
3302
3581
  * and their total size. `entryChars` is the instruction half alone,
3303
3582
  * which is what the budget meter measures; `chars` above stays the
3304
- * whole section (personas + instructions) for diagnostics.
3583
+ * whole section (personas + instructions) for diagnostics. `truncated`
3584
+ * mirrors the entry list: a persona cut by the injection ceiling has to
3585
+ * be DISCLOSED, or the panel shows full text the model never received.
3305
3586
  */
3306
- profiles: plan.profiles.map((item) => ({ id: item.id, name: item.name, chars: item.chars })),
3587
+ profiles: plan.profiles.map((item) => ({
3588
+ id: item.id,
3589
+ name: item.name,
3590
+ chars: item.chars,
3591
+ truncated: item.truncated === true,
3592
+ })),
3307
3593
  profileChars: plan.profileChars,
3308
3594
  entryChars: plan.entryChars,
3309
3595
  /**
@@ -3394,6 +3680,20 @@ export function apply(ctx) {
3394
3680
  snapshot: snapshot(),
3395
3681
  })
3396
3682
 
3683
+ /**
3684
+ * Refusal while memory.json parses as JSON but is not a store at all (an
3685
+ * array, a scalar, another tool's object). Same reason as versionRefusal:
3686
+ * a save would rewrite the file in this build's format over content this
3687
+ * build never understood. The repair doors stay open — that is what
3688
+ * `shapeRejected` is for, and why they deliberately skip this check.
3689
+ */
3690
+ const shapeRefusal = () => ({
3691
+ ok: false,
3692
+ saved: false,
3693
+ message: 'memory.json 的内容不是本插件的记忆存储,修改已暂停以免覆盖原文件。请用「历史回滚」或「用备份整体替换」恢复,或修正该文件后点「重新载入」',
3694
+ snapshot: snapshot(),
3695
+ })
3696
+
3397
3697
  /**
3398
3698
  * Adopt a whole parsed store — the shared core of 源码模式 (F16) and the
3399
3699
  * 整体替换 restore (F14). Both hand the user the SAME safety story as every
@@ -3407,17 +3707,23 @@ export function apply(ctx) {
3407
3707
  * before the path existed — must not silently redirect where backups go.
3408
3708
  */
3409
3709
  const adoptParsedStore = (parsed) => {
3410
- const looksLikeStore = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
3411
- && ['version', 'rev', 'enabled', 'budgetChars', 'backupDir', 'activeSetId', 'sets', 'entries', 'profiles']
3412
- .some((key) => key in parsed)
3413
- if (!looksLikeStore) {
3710
+ // Version first. `{"version":3}` is a FUTURE store, and the honest answer
3711
+ // is "unsupported version", not "not a store" — an unsupported version is
3712
+ // by definition not a shape this build recognises, so the shape test below
3713
+ // would mislabel it.
3714
+ const loaded = normalizeData(parsed)
3715
+ if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
3716
+ // Then shape, with the loader's own verdict rather than a looser one. A
3717
+ // file carrying a single stray option key (`{"version":2}`,
3718
+ // `{"enabled":false}`) is NOT a store to adopt wholesale: on the
3719
+ // import/restore path that would replace real memory with near-nothing and
3720
+ // report saved:true.
3721
+ if (storeShapeVerdict(parsed) !== 'store') {
3414
3722
  return {
3415
- error: '内容不像本插件的存储文件:顶层需要是对象,且至少包含 entries / profiles / sets / version 之一。'
3723
+ error: '内容不像本插件的存储文件:顶层需要是对象,且必须包含 entries / sets 数组(或受支持的 version)。'
3416
3724
  + '若要清空记忆,请使用界面上的删除操作,而不是保存一个空对象',
3417
3725
  }
3418
3726
  }
3419
- const loaded = normalizeData(parsed)
3420
- if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
3421
3727
  loaded.data.backupDir = backupDirOf()
3422
3728
  return { loaded }
3423
3729
  }
@@ -3489,6 +3795,7 @@ export function apply(ctx) {
3489
3795
 
3490
3796
  const saveEntry = (raw, baseRev) => enqueue(async () => {
3491
3797
  if (state.versionRejected) return versionRefusal()
3798
+ if (state.shapeRejected) return shapeRefusal()
3492
3799
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3493
3800
  const entry = sanitizeEntry(raw)
3494
3801
  if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
@@ -3540,6 +3847,7 @@ export function apply(ctx) {
3540
3847
 
3541
3848
  const deleteEntry = (id, baseRev) => enqueue(async () => {
3542
3849
  if (state.versionRejected) return versionRefusal()
3850
+ if (state.shapeRejected) return shapeRefusal()
3543
3851
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3544
3852
  // Deleting an id that is not there changes nothing, so it must not touch
3545
3853
  // disk, bump rev, or push an echo frame — otherwise two panels deleting
@@ -3562,6 +3870,7 @@ export function apply(ctx) {
3562
3870
 
3563
3871
  const setOptions = (patch, baseRev) => enqueue(async () => {
3564
3872
  if (state.versionRejected) return versionRefusal()
3873
+ if (state.shapeRejected) return shapeRefusal()
3565
3874
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3566
3875
  // The custom backup location (F18) is validated BEFORE anything is queued
3567
3876
  // into the store: an unusable path must be refused with a reason, not
@@ -3587,7 +3896,7 @@ export function apply(ctx) {
3587
3896
  return {
3588
3897
  ok: false,
3589
3898
  saved: false,
3590
- message: '备份位置必须位于 DSH 主目录内(' + dshRootPath() + ');要使用其他磁盘请设置环境变量 '
3899
+ message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要使用其他磁盘请设置环境变量 '
3591
3900
  + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
3592
3901
  snapshot: snapshot(),
3593
3902
  }
@@ -3711,6 +4020,7 @@ export function apply(ctx) {
3711
4020
  */
3712
4021
  const batchEntries = (op, ids, value, baseRev) => enqueue(async () => {
3713
4022
  if (state.versionRejected) return versionRefusal()
4023
+ if (state.shapeRejected) return shapeRefusal()
3714
4024
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3715
4025
  if (typeof op !== 'string' || !BATCH_OPS.includes(op)) {
3716
4026
  return { ok: false, saved: false, message: '未知的批量操作:' + String(op), snapshot: snapshot() }
@@ -3818,6 +4128,7 @@ export function apply(ctx) {
3818
4128
 
3819
4129
  const saveProfile = (raw, baseRev) => enqueue(async () => {
3820
4130
  if (state.versionRejected) return versionRefusal()
4131
+ if (state.shapeRejected) return shapeRefusal()
3821
4132
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3822
4133
  const profile = sanitizeProfile(raw)
3823
4134
  if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
@@ -3848,6 +4159,7 @@ export function apply(ctx) {
3848
4159
 
3849
4160
  const deleteProfile = (id, baseRev) => enqueue(async () => {
3850
4161
  if (state.versionRejected) return versionRefusal()
4162
+ if (state.shapeRejected) return shapeRefusal()
3851
4163
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3852
4164
  // Mirror deleteEntry: an absent id changes nothing and must not touch disk,
3853
4165
  // and an id belonging to a different set is not this panel's to delete.
@@ -3875,6 +4187,7 @@ export function apply(ctx) {
3875
4187
  */
3876
4188
  const saveSet = (raw, baseRev) => enqueue(async () => {
3877
4189
  if (state.versionRejected) return versionRefusal()
4190
+ if (state.shapeRejected) return shapeRefusal()
3878
4191
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3879
4192
  const wanted = sanitizeSet(raw)
3880
4193
  if (wanted === null) {
@@ -3915,6 +4228,7 @@ export function apply(ctx) {
3915
4228
  */
3916
4229
  const deleteSet = (id, baseRev) => enqueue(async () => {
3917
4230
  if (state.versionRejected) return versionRefusal()
4231
+ if (state.shapeRejected) return shapeRefusal()
3918
4232
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3919
4233
  const index = state.data.sets.findIndex((set) => set.id === id)
3920
4234
  if (index < 0) {
@@ -3943,6 +4257,7 @@ export function apply(ctx) {
3943
4257
  */
3944
4258
  const setActiveSet = (id, baseRev) => enqueue(async () => {
3945
4259
  if (state.versionRejected) return versionRefusal()
4260
+ if (state.shapeRejected) return shapeRefusal()
3946
4261
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3947
4262
  const target = state.data.sets.find((set) => set.id === id)
3948
4263
  if (target === undefined) {
@@ -3964,6 +4279,7 @@ export function apply(ctx) {
3964
4279
  */
3965
4280
  const duplicateSet = (id, name, baseRev) => enqueue(async () => {
3966
4281
  if (state.versionRejected) return versionRefusal()
4282
+ if (state.shapeRejected) return shapeRefusal()
3967
4283
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3968
4284
  const source = state.data.sets.find((set) => set.id === id)
3969
4285
  if (source === undefined) {
@@ -4093,13 +4409,28 @@ export function apply(ctx) {
4093
4409
  */
4094
4410
  const historyRestore = (name, baseRev) => enqueue(async () => {
4095
4411
  if (state.versionRejected) return versionRefusal()
4412
+ // Deliberately NOT gated on shapeRejected: restoring a snapshot is the
4413
+ // repair door for a primary that is not a store.
4096
4414
  if (isStaleBaseRev(baseRev)) return conflictResponse()
4097
4415
  if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
4098
4416
  return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
4099
4417
  }
4100
4418
  let parsed = null
4419
+ const snapshotPath = join(historyDirPath(), name)
4101
4420
  try {
4102
- parsed = JSON.parse(await readFile(join(historyDirPath(), name), 'utf8'))
4421
+ // Bounded read: stat first, so an absurd file in a shared backup folder
4422
+ // (or a corrupted one) is refused instead of pulled into memory whole.
4423
+ const info = await stat(snapshotPath)
4424
+ if (!info.isFile()) throw new Error('不是普通文件')
4425
+ if (info.size > MAX_HISTORY_FILE_BYTES) {
4426
+ return {
4427
+ ok: false,
4428
+ saved: false,
4429
+ message: '该历史快照过大(' + info.size + ' 字节,上限 ' + MAX_HISTORY_FILE_BYTES + ' 字节),已拒绝读取',
4430
+ snapshot: snapshot(),
4431
+ }
4432
+ }
4433
+ parsed = JSON.parse(await readFile(snapshotPath, 'utf8'))
4103
4434
  } catch (err) {
4104
4435
  return { ok: false, saved: false, message: '无法读取该历史快照:' + describeErrorSafe(err), snapshot: snapshot() }
4105
4436
  }
@@ -4199,7 +4530,7 @@ export function apply(ctx) {
4199
4530
  return {
4200
4531
  ok: false,
4201
4532
  saved: false,
4202
- message: '导出目录必须位于 DSH 主目录内(' + dshRootPath() + ');要写入其他磁盘请设置环境变量 '
4533
+ message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要写入其他磁盘请设置环境变量 '
4203
4534
  + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
4204
4535
  snapshot: snapshot(),
4205
4536
  }
@@ -4294,6 +4625,8 @@ export function apply(ctx) {
4294
4625
 
4295
4626
  const writeSource = (text, baseRev) => enqueue(async () => {
4296
4627
  if (state.versionRejected) return versionRefusal()
4628
+ // Deliberately NOT gated on shapeRejected: hand-editing the source is how
4629
+ // a user repairs a primary that is not a store.
4297
4630
  if (isStaleBaseRev(baseRev)) return conflictResponse()
4298
4631
  if (typeof text !== 'string') {
4299
4632
  return { ok: false, saved: false, message: '源码必须是 JSON 文本', snapshot: snapshot() }
@@ -4324,6 +4657,8 @@ export function apply(ctx) {
4324
4657
  */
4325
4658
  const restoreData = (payload, baseRev) => enqueue(async () => {
4326
4659
  if (state.versionRejected) return versionRefusal()
4660
+ // Deliberately NOT gated on shapeRejected: wholesale replacement from a
4661
+ // backup is the repair door for a primary that is not a store.
4327
4662
  if (isStaleBaseRev(baseRev)) return conflictResponse()
4328
4663
  if (payload === null || typeof payload !== 'object') {
4329
4664
  return { ok: false, saved: false, message: '恢复内容不是有效的 JSON 对象', snapshot: snapshot() }
@@ -4337,6 +4672,8 @@ export function apply(ctx) {
4337
4672
 
4338
4673
  const importData = (payload, baseRev) => enqueue(async () => {
4339
4674
  if (state.versionRejected) return versionRefusal()
4675
+ // Deliberately NOT gated on shapeRejected: importing a real store is a
4676
+ // repair path for a primary that is not one.
4340
4677
  if (isStaleBaseRev(baseRev)) return conflictResponse()
4341
4678
  if (payload === null || typeof payload !== 'object') {
4342
4679
  return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
@@ -4624,6 +4961,14 @@ export function apply(ctx) {
4624
4961
  const readFeedbackConfigFile = async (path) => {
4625
4962
  let text
4626
4963
  try {
4964
+ // Bounded read: this file is operator-authored, but a wrong path (a log,
4965
+ // a database) must not be dragged into memory just because the send
4966
+ // button was pressed.
4967
+ const info = await stat(path)
4968
+ if (!info.isFile()) throw new Error('不是普通文件')
4969
+ if (info.size > MAX_FEEDBACK_CONFIG_BYTES) {
4970
+ throw new Error('文件过大(' + info.size + ' 字节,上限 ' + MAX_FEEDBACK_CONFIG_BYTES + ' 字节)')
4971
+ }
4627
4972
  text = await readFile(path, 'utf8')
4628
4973
  } catch (err) {
4629
4974
  if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
@@ -5005,9 +5350,16 @@ export function apply(ctx) {
5005
5350
  logInfo('connection fence threw, refusing the request: ' + describeError(err))
5006
5351
  rejection = 401
5007
5352
  }
5008
- if (typeof rejection === 'number' && rejection >= 400) {
5009
- logInfo('route refused an unauthenticated request (' + rejection + ')')
5010
- res.writeHead(rejection, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5353
+ // Only the documented "admit" values pass. Anything else — a future
5354
+ // `{status:401}`, an Error, a string — is treated as a refusal, because
5355
+ // an authentication gate that silently opens on an unexpected shape is
5356
+ // not a gate. The numeric status is preserved when it is plausible.
5357
+ const admitted = rejection === undefined || rejection === null || rejection === false
5358
+ if (!admitted) {
5359
+ const status = typeof rejection === 'number' && rejection >= 400 && rejection <= 599 ? rejection : 401
5360
+ logInfo('route refused a request (' + status + ', fence returned '
5361
+ + (typeof rejection === 'number' ? rejection : typeof rejection) + ')')
5362
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5011
5363
  res.end(JSON.stringify({
5012
5364
  ok: false,
5013
5365
  message: rejection === 403