dsh-instruction-memory 1.4.7 → 1.5.0

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,12 +25,12 @@
25
25
  * user-chosen backup directory (F18).
26
26
  */
27
27
 
28
- import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
29
- import { readFileSync, watch } from 'node:fs'
28
+ import { chmod, copyFile, mkdir, open, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
29
+ import { readFileSync, realpathSync, statSync, watch } from 'node:fs'
30
30
  import { randomUUID } from 'node:crypto'
31
31
  import { isIP } from 'node:net'
32
32
  import { homedir, hostname } from 'node:os'
33
- import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
33
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
35
  import {
36
36
  FEEDBACK_ENV,
@@ -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
@@ -148,6 +159,35 @@ const BACKUP_FILE_SUFFIX = '.bak'
148
159
  */
149
160
  const SECTION_ORDER = 950
150
161
  const API_PREFIX = '/instruction-memory/api'
162
+ /**
163
+ * Extra hostnames this route accepts, comma separated (F19).
164
+ *
165
+ * A loopback bind normally only answers to `127.0.0.1`/`localhost`, which is
166
+ * what makes the DNS-rebinding guard meaningful — but it also made the route
167
+ * unusable behind a reverse proxy that forwards the public `Host` (nginx's
168
+ * default `proxy_set_header Host $host`). Listing the published hostname here
169
+ * re-opens exactly that one name and nothing else: the loopback rule still
170
+ * applies to everything else, and `Origin`/`Sec-Fetch-Site` are still enforced.
171
+ */
172
+ const TRUSTED_HOSTS_ENV = 'DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS'
173
+ /**
174
+ * Escape hatch for F18 backups outside `$DSH_HOME` (F19). The default refuses
175
+ * them: an unauthenticated route that can create directories anywhere the host
176
+ * process can write is a disk-fill primitive. Setting this to `1` restores the
177
+ * old permissive behaviour for operators who really keep backups on another
178
+ * volume.
179
+ */
180
+ const ALLOW_EXTERNAL_BACKUP_ENV = 'DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP'
181
+ /**
182
+ * How often the store file is stat-ed as a fallback for `fs.watch` (F19).
183
+ *
184
+ * Inotify/FSEvents/ReadDirectoryChangesW are reliable on a native filesystem
185
+ * and silently dead on DrvFs (`/mnt/c`, `/mnt/d`), SMB and some container
186
+ * overlays — there the watcher installs without error and then never fires, so
187
+ * an external edit to memory.json was simply invisible until a restart. A
188
+ * 5 s stat of one path costs nothing and turns that silence into a reload.
189
+ */
190
+ const WATCH_POLL_MS = 5000
151
191
  /**
152
192
  * Feedback mail: the optional config file next to the store. Kept OUT of
153
193
  * the repository on purpose — the operator's SMTP credentials must never be
@@ -175,13 +215,19 @@ const PLUGIN_VERSION = (() => {
175
215
  })()
176
216
 
177
217
  /**
178
- * Resolve this plugin's data file under the harness home.
218
+ * The harness home: the directory this plugin's data lives under.
179
219
  *
180
220
  * Precedence mirrors `@deepseek-ai/dsh-home-paths`: an explicit `$DSH_HOME`,
181
221
  * then `~/.dsh`. A blank override counts as unset and must never resolve to the
182
222
  * current working directory. Exported so tests can pin it.
223
+ *
224
+ * Resolved here rather than derived from the store path. `dirname(dirname(
225
+ * storePath))` happens to equal this today, but it is a guess about the layout:
226
+ * one extra directory level (or a store moved up a level) would quietly widen
227
+ * the "may I write outside the harness home" fence to the harness home's
228
+ * PARENT. A guard that guesses its own root is not a guard.
183
229
  */
184
- export function resolveStorePath(env = process.env, home = homedir()) {
230
+ export function resolveDshRoot(env = process.env, home = homedir()) {
185
231
  const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
186
232
  // A relative override (`DSH_HOME=./data`) is resolved ONCE against the
187
233
  // process cwd. Without this the store path stayed relative for the whole
@@ -190,8 +236,12 @@ export function resolveStorePath(env = process.env, home = homedir()) {
190
236
  // how the path was spelled. Resolving does not make a relative override
191
237
  // cwd-independent — it cannot be — but it pins one absolute location for
192
238
  // this process and keeps every displayed path absolute.
193
- const root = configured !== '' ? resolve(configured) : join(home, '.dsh')
194
- return join(root, STORE_DIR_NAME, STORE_FILE_NAME)
239
+ return configured !== '' ? resolve(configured) : join(home, '.dsh')
240
+ }
241
+
242
+ /** Resolve this plugin's data file under the harness home. */
243
+ export function resolveStorePath(env = process.env, home = homedir()) {
244
+ return join(resolveDshRoot(env, home), STORE_DIR_NAME, STORE_FILE_NAME)
195
245
  }
196
246
 
197
247
  /**
@@ -249,6 +299,14 @@ export function expandDisplayPath(value, home = homedir(), platform = process.pl
249
299
  * .bak — and only the last two may never be treated as "no memory yet".
250
300
  */
251
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'
252
310
 
253
311
  const DEFAULT_BUDGET = 4000
254
312
  const MIN_BUDGET = 800
@@ -332,8 +390,14 @@ export const LIMITS = Object.freeze({
332
390
  maxFeedbackContact: FEEDBACK_LIMITS.maxContact,
333
391
  })
334
392
 
335
- /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
336
- 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
337
401
  /** How long a write waits for the cross-process lock before proceeding unlocked. */
338
402
  const LOCK_WAIT_MS = 2_000
339
403
  /** Coalescing window for store-directory watcher events before a re-read. */
@@ -736,6 +800,40 @@ const describeValue = (value) => {
736
800
  * `dropped`: the next save persists the corrected values, so the user must be
737
801
  * told their hand edit did not survive verbatim.
738
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
+
739
837
  function normalizeData(raw) {
740
838
  const data = {
741
839
  version: FORMAT_VERSION,
@@ -751,8 +849,18 @@ function normalizeData(raw) {
751
849
  }
752
850
  const loaded = { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], formatNote: null }
753
851
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return loaded
754
- if (typeof raw.version === 'number' && !SUPPORTED_VERSIONS.includes(raw.version)) {
755
- 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
+ }
756
864
  }
757
865
  if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
758
866
  data.rev = Math.round(raw.rev)
@@ -1163,7 +1271,51 @@ export function isWildcardBindHost(value) {
1163
1271
  * `Host: attacker.example` plus `Origin: attacker.example`. Wildcard binds keep
1164
1272
  * the old comparison, because the address the client used is unknowable then.
1165
1273
  */
1166
- export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
1274
+ /**
1275
+ * Parse the trusted-host list into canonical hostnames.
1276
+ *
1277
+ * Accepts what an operator actually types: a bare `app.example`, a full
1278
+ * origin (`https://app.example:8443`), bracketed IPv6, comma or whitespace
1279
+ * separated. Everything is folded to the same lowercase, bracket-free,
1280
+ * port-free form `hostnameOf()`/`normalizeHostname()` produce, so a trusted
1281
+ * entry compares equal to a parsed `Host` or `Origin` without further work.
1282
+ *
1283
+ * @param {string|string[]} value raw environment value or an array.
1284
+ * @returns {string[]} canonical hostnames, deduplicated, order preserved.
1285
+ */
1286
+ export function normalizeTrustedHosts(value) {
1287
+ const raw = Array.isArray(value) ? value.join(',') : typeof value === 'string' ? value : ''
1288
+ const out = []
1289
+ for (const piece of raw.split(',')) {
1290
+ let text = piece.trim()
1291
+ if (text === '') continue
1292
+ // A full origin is common in copy-pasted docs; keep only its host.
1293
+ const scheme = text.indexOf('://')
1294
+ if (scheme !== -1) {
1295
+ try {
1296
+ text = new URL(text).hostname
1297
+ } catch {
1298
+ text = text.slice(scheme + 3)
1299
+ }
1300
+ }
1301
+ text = text.replace(/^\/+/, '')
1302
+ const slash = text.indexOf('/')
1303
+ if (slash !== -1) text = text.slice(0, slash)
1304
+ // hostnameOf() already drops a port and the IPv6 brackets.
1305
+ const name = hostnameOf(text)
1306
+ if (name === '') continue
1307
+ if (!out.includes(name)) out.push(name)
1308
+ }
1309
+ return out
1310
+ }
1311
+
1312
+ /** Is this canonical hostname one the operator explicitly trusted? */
1313
+ function isTrustedHostName(name, trusted) {
1314
+ return name !== '' && Array.isArray(trusted) && trusted.includes(name)
1315
+ }
1316
+
1317
+ export function requestOriginVerdict(headers, guarded, expectedHostname = '', trustedHosts = []) {
1318
+ const trusted = Array.isArray(trustedHosts) ? trustedHosts : []
1167
1319
  const read = (name) => {
1168
1320
  if (headers === null || typeof headers !== 'object') return ''
1169
1321
  const value = headers[name]
@@ -1172,7 +1324,7 @@ export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
1172
1324
  const requestHost = read('host').trim().toLowerCase()
1173
1325
  const requestHostname = hostnameOf(requestHost)
1174
1326
 
1175
- if (guarded && !isLoopbackName(requestHostname)) {
1327
+ if (guarded && !isLoopbackName(requestHostname) && !isTrustedHostName(requestHostname, trusted)) {
1176
1328
  return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
1177
1329
  }
1178
1330
 
@@ -1213,7 +1365,7 @@ export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
1213
1365
  // is what keeps those spellings working (the mapped/hex forms canonicalise).
1214
1366
  const expected = !guarded && typeof expectedHostname === 'string' ? expectedHostname : ''
1215
1367
  const authority = expected !== '' ? expected : requestHostname
1216
- if (originHostname === '' || !sameHostname(originHostname, authority)) {
1368
+ if (originHostname === '' || (!sameHostname(originHostname, authority) && !isTrustedHostName(originHostname, trusted))) {
1217
1369
  return {
1218
1370
  ok: false,
1219
1371
  reason: expected !== ''
@@ -1360,7 +1512,6 @@ function buildProfilesBlock(profiles, budget) {
1360
1512
  const blocks = []
1361
1513
  const items = []
1362
1514
  let used = PERSONA_HEADER.length + 1
1363
- let omitted = 0
1364
1515
  for (const profile of active) {
1365
1516
  const name = typeof profile.name === 'string' && profile.name !== ''
1366
1517
  ? profile.name
@@ -1392,7 +1543,6 @@ function buildProfilesBlock(profiles, budget) {
1392
1543
  items.push({ id, name, chars: head.length + note.length, truncated: true })
1393
1544
  used += sep + head.length + note.length
1394
1545
  }
1395
- omitted = active.length - blocks.length
1396
1546
  break
1397
1547
  }
1398
1548
  const text = PERSONA_HEADER + '\n' + blocks.join('\n\n') + footerFor(Math.max(0, active.length - blocks.length))
@@ -1665,6 +1815,15 @@ export function apply(ctx) {
1665
1815
  */
1666
1816
  versionRejected: false,
1667
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,
1668
1827
  sectionRegistered: false,
1669
1828
  sectionError: null,
1670
1829
  /** Whether a store file is actually present on disk (diagnostics + the panel). */
@@ -1688,6 +1847,13 @@ export function apply(ctx) {
1688
1847
  * temporal-dead-zone hazard (F12 publishes it as `security.hostGuarded`).
1689
1848
  */
1690
1849
  let guardHost = true
1850
+ /**
1851
+ * F19: hostnames the operator explicitly allowed (see TRUSTED_HOSTS_ENV).
1852
+ * Read once at mount from the environment; a restart is required to change it,
1853
+ * which keeps the allowlist out of the settings page (a route that can widen
1854
+ * its own trust boundary is not a boundary).
1855
+ */
1856
+ const trustedHosts = normalizeTrustedHosts(process.env[TRUSTED_HOSTS_ENV])
1691
1857
  /**
1692
1858
  * The authority a browser had to use to reach this route, when the bind
1693
1859
  * address is concrete (`192.168.1.5`, `[::1]`). Empty for a wildcard/unknown
@@ -1747,6 +1913,13 @@ export function apply(ctx) {
1747
1913
  let text = describeError(err)
1748
1914
  const storeDir = dirname(storePath)
1749
1915
  if (storeDir !== '' && storeDir !== '.') text = text.split(storeDir).join('<记忆目录>')
1916
+ // F19: a custom backup location is just as revealing (it is usually a
1917
+ // cloud-sync path with an account name in it) and used to survive the fold
1918
+ // whenever it was not under the home directory.
1919
+ const configuredBackup = typeof state.data.backupDir === 'string' ? state.data.backupDir.trim() : ''
1920
+ if (configuredBackup !== '' && configuredBackup !== storeDir) {
1921
+ text = text.split(configuredBackup).join('<备份目录>')
1922
+ }
1750
1923
  try {
1751
1924
  const home = homedir()
1752
1925
  // Same token as `displayPath` so a folded path looks identical wherever
@@ -1802,6 +1975,41 @@ export function apply(ctx) {
1802
1975
  else console.error('[instruction-memory] ' + message)
1803
1976
  }
1804
1977
 
1978
+ /**
1979
+ * Create (or repair) a directory this plugin owns and make sure it is 0700.
1980
+ *
1981
+ * `mkdir(..., {mode: 0o700})` only sets the mode on a directory it actually
1982
+ * creates: a store first written by 1.4.7 stays 0755 forever, because every
1983
+ * later start just resolves the path that already exists. The snapshots in
1984
+ * `history/` were therefore still listable by other local users after the
1985
+ * mode fix shipped, which the review called out as a real (if minor) gap.
1986
+ * The chmod is deliberately best effort: a directory owned by someone else,
1987
+ * or a filesystem without POSIX modes (Windows, some network mounts), must
1988
+ * not turn a save or a boot into a failure.
1989
+ */
1990
+ const repairDirMode = async (dir) => {
1991
+ if (process.platform === 'win32') return
1992
+ try {
1993
+ const info = await stat(dir)
1994
+ if ((info.mode & 0o777) === 0o700) return
1995
+ await chmod(dir, 0o700)
1996
+ logInfo('tightened ' + displayPath(dir) + ' to 0700 (was ' + (info.mode & 0o777).toString(8) + ')')
1997
+ } catch {
1998
+ // Not our directory, or no POSIX modes here: the write itself matters more.
1999
+ }
2000
+ }
2001
+
2002
+ /**
2003
+ * Create the directory if needed and make sure it ends up 0700. Boot-time
2004
+ * repair deliberately uses `repairDirMode` alone: a first run must not create
2005
+ * the store directory just to look at it (pinned by the "booting does not
2006
+ * even create the store directory" contract).
2007
+ */
2008
+ const hardenDir = async (dir) => {
2009
+ await mkdir(dir, { recursive: true, mode: 0o700 })
2010
+ await repairDirMode(dir)
2011
+ }
2012
+
1805
2013
  const logDebug = (message) => {
1806
2014
  if (logger !== null) {
1807
2015
  if (typeof logger.debug === 'function') logger.debug(message)
@@ -1849,7 +2057,14 @@ export function apply(ctx) {
1849
2057
  */
1850
2058
  const assembledSectionText = () => {
1851
2059
  sectionPulls += 1
1852
- logDebug('section pulled into a prompt (pull #' + sectionPulls + ')')
2060
+ // The host calls this while assembling the prompt and does NOT wrap it in
2061
+ // try/catch, so a throwing logger would abort the whole turn. Counting the
2062
+ // pull is diagnostics; it must never be the reason a prompt fails.
2063
+ try {
2064
+ logDebug('section pulled into a prompt (pull #' + sectionPulls + ')')
2065
+ } catch {
2066
+ // Diagnostics only.
2067
+ }
1853
2068
  return sectionText()
1854
2069
  }
1855
2070
 
@@ -1889,6 +2104,29 @@ export function apply(ctx) {
1889
2104
  * when something must be injected, absent otherwise. An empty memory must
1890
2105
  * leave the prompt byte-identical to a deployment without this plugin.
1891
2106
  */
2107
+ /**
2108
+ * Where the host wants this section to sit, asked of the host itself.
2109
+ *
2110
+ * `950` is the documented gap between FILE_REFERENCE (900) and TOOL_BASH
2111
+ * (1000) in `dsh-system-prompt`'s central `SECTION_ORDERS` table. Hardcoding
2112
+ * it works until that table grows an entry at 950 — then the placement is a
2113
+ * silent tie, decided by dictionary order, and nothing reports it. The
2114
+ * service exposes `getSectionOrder(name)`, so take the midpoint of the two
2115
+ * neighbours we actually anchor against and fall back to 950 if the method
2116
+ * is gone or answers something unusable.
2117
+ */
2118
+ const resolveSectionOrder = () => {
2119
+ try {
2120
+ if (systemPrompt === null || typeof systemPrompt.getSectionOrder !== 'function') return SECTION_ORDER
2121
+ const lo = systemPrompt.getSectionOrder('FILE_REFERENCE')
2122
+ const hi = systemPrompt.getSectionOrder('TOOL_BASH')
2123
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return SECTION_ORDER
2124
+ return lo + (hi - lo) / 2
2125
+ } catch {
2126
+ return SECTION_ORDER
2127
+ }
2128
+ }
2129
+
1892
2130
  const syncSection = () => {
1893
2131
  if (disposed) return
1894
2132
  const wanted = sectionText() !== ''
@@ -1902,15 +2140,16 @@ export function apply(ctx) {
1902
2140
  // template or any `{{…}}` pair — so the default would abort the whole
1903
2141
  // agent turn at renderPrompt(). This section is literal text and never
1904
2142
  // references a host variable, so opt out explicitly.
2143
+ const order = resolveSectionOrder()
1905
2144
  sectionDispose = systemPrompt.section({
1906
2145
  name: SECTION_NAME,
1907
- order: SECTION_ORDER,
2146
+ order,
1908
2147
  text: assembledSectionText,
1909
2148
  interpolate: false,
1910
2149
  })
1911
2150
  state.sectionRegistered = true
1912
2151
  state.sectionError = null
1913
- logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
2152
+ logDebug('prompt section registered: order=' + order + ' chars=' + sectionText().length)
1914
2153
  } catch (err) {
1915
2154
  sectionDispose = null
1916
2155
  state.sectionRegistered = false
@@ -1968,6 +2207,13 @@ export function apply(ctx) {
1968
2207
  const sseStreams = new Map()
1969
2208
  let storeWatcher = null
1970
2209
  let watchDebounce = null
2210
+ /**
2211
+ * Fallback poller for filesystems where `fs.watch` installs but never fires
2212
+ * (DrvFs `/mnt/*`, SMB/NFS, some overlays). Null when not running.
2213
+ */
2214
+ let storePoll = null
2215
+ /** Last `size:mtime:ino` signature the poller saw, so it only reacts to a change. */
2216
+ let storeSignature = ''
1971
2217
 
1972
2218
  const pushSnapshot = () => {
1973
2219
  if (disposed || sseStreams.size === 0) return
@@ -2042,11 +2288,51 @@ export function apply(ctx) {
2042
2288
  }, WATCH_DEBOUNCE_MS)
2043
2289
  }
2044
2290
 
2291
+ /** The stat signature a poller compares, or '' when the file is absent. */
2292
+ const currentStoreSignature = () => {
2293
+ try {
2294
+ const info = statSync(storePath)
2295
+ return String(info.size) + ':' + String(info.mtimeMs) + ':' + String(info.ino)
2296
+ } catch {
2297
+ return ''
2298
+ }
2299
+ }
2300
+
2301
+ /**
2302
+ * Start the fallback poller (idempotent).
2303
+ *
2304
+ * It is ALWAYS started alongside the watcher, not only after a failure: an
2305
+ * `fs.watch` that never fires cannot be distinguished from a quiet store, so
2306
+ * there is no event to trigger a downgrade. On a native filesystem the poller
2307
+ * finds the same signature the watcher just acted on and does nothing —
2308
+ * `onStoreFileEvent` already drops no-op reloads by comparing content.
2309
+ */
2310
+ const startPolling = () => {
2311
+ if (disposed || storePoll !== null) return
2312
+ storeSignature = currentStoreSignature()
2313
+ storePoll = setInterval(() => {
2314
+ if (disposed) return
2315
+ const signature = currentStoreSignature()
2316
+ if (signature === storeSignature) return
2317
+ storeSignature = signature
2318
+ // A vanished file is not an edit; the next save recreates it.
2319
+ if (signature === '') return
2320
+ onStoreFileEvent()
2321
+ }, WATCH_POLL_MS)
2322
+ // A background timer must never be a reason for the host process to stay
2323
+ // alive after the plugin is unmounted.
2324
+ if (typeof storePoll.unref === 'function') storePoll.unref()
2325
+ }
2326
+
2045
2327
  const stopWatching = () => {
2046
2328
  if (watchDebounce !== null) {
2047
2329
  clearTimeout(watchDebounce)
2048
2330
  watchDebounce = null
2049
2331
  }
2332
+ if (storePoll !== null) {
2333
+ clearInterval(storePoll)
2334
+ storePoll = null
2335
+ }
2050
2336
  if (storeWatcher !== null) {
2051
2337
  const watcher = storeWatcher
2052
2338
  storeWatcher = null
@@ -2075,10 +2361,20 @@ export function apply(ctx) {
2075
2361
  if (typeof filename === 'string' && filename !== basename(storePath)) return
2076
2362
  onStoreFileEvent()
2077
2363
  })
2078
- storeWatcher.on('error', () => stopWatching())
2364
+ storeWatcher.on('error', (err) => {
2365
+ // Losing the OS watcher is survivable (the poller below still notices
2366
+ // an external edit) but it must not happen silently: the downgrade is
2367
+ // invisible otherwise.
2368
+ logInfo('store watcher failed, falling back to the 5s poller: ' + describeError(err))
2369
+ stopWatching()
2370
+ })
2079
2371
  } catch {
2080
2372
  storeWatcher = null
2081
2373
  }
2374
+ // Started whether or not the watcher attached: on a filesystem where the
2375
+ // watch handle exists but never fires this is the ONLY reload signal, and
2376
+ // on a healthy one it is a cheap no-op.
2377
+ startPolling()
2082
2378
  }
2083
2379
 
2084
2380
  const disposeStreams = () => {
@@ -2117,6 +2413,64 @@ export function apply(ctx) {
2117
2413
  /* ---------------- backup location + history snapshots (F14 / F18) ---------------- */
2118
2414
 
2119
2415
  const backupDirOf = () => (typeof state.data.backupDir === 'string' ? state.data.backupDir : '')
2416
+ /** The harness home this plugin's data lives under (`$DSH_HOME` or `~/.dsh`). */
2417
+ const dshRootPath = () => resolveDshRoot()
2418
+ /**
2419
+ * F19: are we allowed to write outside `$DSH_HOME`?
2420
+ *
2421
+ * The settings route can be reached without a login on profiles with no
2422
+ * `connection` service, and `backupDir` used to accept ANY absolute path (it
2423
+ * was probe-written on set and then created on every save). That is a
2424
+ * disk-fill primitive, so the default is now "inside the harness home only";
2425
+ * an operator who really keeps backups on another volume opts in explicitly.
2426
+ */
2427
+ const allowExternalBackup = () => {
2428
+ const raw = process.env[ALLOW_EXTERNAL_BACKUP_ENV]
2429
+ return typeof raw === 'string' && raw.trim() === '1'
2430
+ }
2431
+ /** `path`'s real location, or null when it (or a parent) is unavailable. */
2432
+ const realPathOrNull = (path) => {
2433
+ try {
2434
+ return realpathSync(path)
2435
+ } catch {
2436
+ return null
2437
+ }
2438
+ }
2439
+ /** Is `inner` the same as `root` or below it? */
2440
+ const containsPath = (root, inner) => {
2441
+ const rel = relative(root, inner)
2442
+ return rel === '' || (!rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel))
2443
+ }
2444
+ /**
2445
+ * Is `target` inside `$DSH_HOME` (equal to it counts)?
2446
+ *
2447
+ * Spelling alone is not containment. A junction or symlink INSIDE the
2448
+ * harness home can point anywhere — on Windows a junction needs no privilege
2449
+ * at all — so the lexical check answers "does this text start with the root"
2450
+ * while the write lands in another volume. Both ends are therefore compared
2451
+ * by real path; a target that does not exist yet is judged by the real path
2452
+ * of its deepest existing ancestor plus the part still to be created.
2453
+ */
2454
+ const isInsideDshRoot = (target) => {
2455
+ if (typeof target !== 'string' || target === '') return false
2456
+ const root = dshRootPath()
2457
+ const absolute = resolve(target)
2458
+ if (!containsPath(root, absolute)) return false
2459
+ const realRoot = realPathOrNull(root)
2460
+ if (realRoot === null) return true
2461
+ let existing = absolute
2462
+ for (let depth = 0; depth < 64; depth += 1) {
2463
+ const realExisting = realPathOrNull(existing)
2464
+ if (realExisting !== null) {
2465
+ const rest = absolute.slice(existing.length)
2466
+ return containsPath(realRoot, rest === '' ? realExisting : join(realExisting, rest))
2467
+ }
2468
+ const parent = dirname(existing)
2469
+ if (parent === existing) return false
2470
+ existing = parent
2471
+ }
2472
+ return false
2473
+ }
2120
2474
  /** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
2121
2475
  const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
2122
2476
  /** The user-facing copy under a custom backup location, or null (F18). */
@@ -2181,12 +2535,22 @@ export function apply(ctx) {
2181
2535
  if (err.code !== 'EEXIST') throw err
2182
2536
  const info = await stat(lockPath).catch(() => null)
2183
2537
  if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
2184
- // Crashed-holder debris. It may be recreated by a live process
2185
- // between this unlink and the next wx attempt — that case simply
2186
- // loops and re-evaluates freshness instead of forcing its way in.
2187
- // A failed unlink must not become a tight loop, so every attempt
2188
- // still sleeps below.
2189
- await unlink(lockPath).catch(() => {})
2538
+ // Confirm the marker we are about to delete is STILL the one we just
2539
+ // judged stale. Between the stat above and the unlink a live holder
2540
+ // can release it and a new owner can create a fresh marker; deleting
2541
+ // THAT would unlock a live writer, the same TOCTOU the token closed
2542
+ // on release. The mtime+size re-check makes the sweep ownership-safe
2543
+ // without needing to know the dead holder's token.
2544
+ const after = await stat(lockPath).catch(() => null)
2545
+ if (after !== null && after.mtimeMs === info.mtimeMs && after.size === info.size
2546
+ && Date.now() - after.mtimeMs > LOCK_STALE_MS) {
2547
+ // Crashed-holder debris. It may be recreated by a live process
2548
+ // between this unlink and the next wx attempt — that case simply
2549
+ // loops and re-evaluates freshness instead of forcing its way in.
2550
+ // A failed unlink must not become a tight loop, so every attempt
2551
+ // still sleeps below.
2552
+ await unlink(lockPath).catch(() => {})
2553
+ }
2190
2554
  }
2191
2555
  await new Promise((resolve) => setTimeout(resolve, 50))
2192
2556
  }
@@ -2267,14 +2631,22 @@ export function apply(ctx) {
2267
2631
  let lastPersistedKey = null
2268
2632
 
2269
2633
  /**
2270
- * Enforce the per-device retention policy: the count and byte ceilings from
2271
- * planHistoryPrune(). Grouping by the device tag inside the filename is
2272
- * deliberate: two machines sharing one backup folder (the documented F18
2273
- * setup) each keep their OWN budget, so one device can never prune the
2274
- * other's history away. Files that do not match our naming pattern are never
2275
- * touched. Sizes come from stat() rather than a maintained index because the
2276
- * directory is shared with other processes and a stale index would prune the
2277
- * wrong files.
2634
+ * Enforce the retention policy: the count and byte ceilings from
2635
+ * planHistoryPrune(), applied PER DEVICE first. Grouping by the device tag
2636
+ * inside the filename is deliberate: two machines sharing one backup folder
2637
+ * (the documented F18 setup) each keep their OWN budget, so a device that
2638
+ * saves a lot cannot prune a quiet device's history.
2639
+ *
2640
+ * The GLOBAL byte ceiling afterwards is the documented exception to that:
2641
+ * per-device budgets multiply by the number of machines (ten machines = ten
2642
+ * × 32 MB), so the folder as a whole is also bounded, and that pass deletes
2643
+ * the oldest snapshots whichever device they came from. It is disclosed
2644
+ * rather than hidden — the name ordering is chronological across devices
2645
+ * because the stamp is fixed-width UTC.
2646
+ *
2647
+ * Files that do not match our naming pattern are never touched. Sizes come
2648
+ * from stat() rather than a maintained index because the directory is shared
2649
+ * with other processes and a stale index would prune the wrong files.
2278
2650
  */
2279
2651
  const pruneHistory = async (dir) => {
2280
2652
  const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
@@ -2339,7 +2711,7 @@ export function apply(ctx) {
2339
2711
  if (typeof text !== 'string' || text === '' || identity === lastHistoryKey) return
2340
2712
  try {
2341
2713
  const dir = historyDirPath()
2342
- await mkdir(dir, { recursive: true })
2714
+ await hardenDir(dir)
2343
2715
  const epoch = Date.now()
2344
2716
  let written = false
2345
2717
  // `wx` never overwrites: same-millisecond saves (two rapid bulk actions)
@@ -2374,7 +2746,19 @@ export function apply(ctx) {
2374
2746
  const source = await readFile(fileURLToPath(new URL('../' + SCHEMA_FILE_NAME, import.meta.url)), 'utf8')
2375
2747
  const target = join(dirname(storePath), SCHEMA_FILE_NAME)
2376
2748
  const existing = await readFile(target, 'utf8').catch(() => null)
2377
- if (existing !== source) await writeFile(target, source, 'utf8')
2749
+ if (existing !== source) {
2750
+ // Atomic like the store itself: a crash mid-copy must not leave a
2751
+ // truncated schema for the user's editor to validate against, and the
2752
+ // 0600 bit matches every other file this plugin writes.
2753
+ const tmp = target + '.tmp-' + randomUUID()
2754
+ try {
2755
+ await writeFile(tmp, source, { encoding: 'utf8', mode: 0o600 })
2756
+ await rename(tmp, target)
2757
+ } catch (err) {
2758
+ await unlink(tmp).catch(() => {})
2759
+ throw err
2760
+ }
2761
+ }
2378
2762
  } catch {
2379
2763
  // Best effort by design.
2380
2764
  }
@@ -2395,6 +2779,21 @@ export function apply(ctx) {
2395
2779
  }
2396
2780
  }
2397
2781
 
2782
+ /**
2783
+ * Best-effort durability fence. `rename` is atomic against a crash but NOT
2784
+ * durable on its own: without an fsync the directory entry can reach disk
2785
+ * after the data does, so power loss may leave the previous file or a 0-byte
2786
+ * one even though the save reported success. A failure here is deliberately
2787
+ * not a save failure (the bytes are already in the page cache, and Windows
2788
+ * refuses to open directories at all), so it degrades durability only.
2789
+ */
2790
+ const syncPath = async (target) => {
2791
+ try {
2792
+ const fh = await open(target, 'r')
2793
+ try { await fh.sync() } finally { await fh.close() }
2794
+ } catch { /* unsupported here: durability degrades, correctness does not */ }
2795
+ }
2796
+
2398
2797
  const writeToDisk = async () => {
2399
2798
  // Atomic replace (write a sibling temp file, then rename) with a rolling
2400
2799
  // one-version backup: a crash mid-write must never leave a truncated
@@ -2409,11 +2808,17 @@ export function apply(ctx) {
2409
2808
  // user's memory while their real data sat untouched in .bak. Copying keeps
2410
2809
  // the primary in place until the atomic replace succeeds, so a failure at
2411
2810
  // any step leaves the previous store readable at its own path.
2412
- const tmpPath = storePath + '.tmp'
2811
+ // A UNIQUE tmp name per attempt: two processes sharing a $DSH_HOME must
2812
+ // never interleave their writes into one temp file. The fixed
2813
+ // `storePath + '.tmp'` meant the second writer's rename could publish a
2814
+ // half-written mix of both serialisations. The lock narrows the window; a
2815
+ // per-attempt name removes the shared resource, so the window cannot
2816
+ // corrupt anything even when the lock is unavailable.
2817
+ const tmpPath = storePath + '.tmp-' + process.pid + '-' + randomUUID()
2413
2818
  let lock = null
2414
2819
  let previousRev = null
2415
2820
  try {
2416
- await mkdir(dirname(storePath), { recursive: true })
2821
+ await hardenDir(dirname(storePath))
2417
2822
  lock = await acquireStoreLock()
2418
2823
  if (lock === null) {
2419
2824
  // The lock could not be taken within the wait. That is either a stale
@@ -2452,6 +2857,10 @@ export function apply(ctx) {
2452
2857
  const contentKey = JSON.stringify({ ...persistableData(), rev: 0 })
2453
2858
  if (state.storeExists && lastPersistedKey !== null && contentKey === lastPersistedKey) {
2454
2859
  logDebug('save skipped: the store content did not change')
2860
+ // The caller's "上次保存" clock reports the last successful SAVE, and
2861
+ // this call is one, even though the bytes were already correct. Leaving
2862
+ // it untouched made the panel look stalled after a no-op save.
2863
+ state.savedAt = Date.now()
2455
2864
  return true
2456
2865
  }
2457
2866
  // The rev is bumped before serialising so the file on disk and the state
@@ -2469,7 +2878,15 @@ export function apply(ctx) {
2469
2878
  // user profile) personal notes, so on a multi-user POSIX host it must not
2470
2879
  // land world-readable the way a bare writeFile (0666 & ~umask = 0644)
2471
2880
  // would. Windows ignores the bit and inherits the directory ACL instead.
2472
- await writeFile(tmpPath, text, { encoding: 'utf8', mode: 0o600 })
2881
+ const tmpHandle = await open(tmpPath, 'w', 0o600)
2882
+ try {
2883
+ await tmpHandle.writeFile(text, 'utf8')
2884
+ // Flush the temp file BEFORE the rename publishes it: otherwise a
2885
+ // power cut can leave the new name pointing at unwritten blocks.
2886
+ await tmpHandle.sync()
2887
+ } finally {
2888
+ await tmpHandle.close()
2889
+ }
2473
2890
  // Both rolling copies stand for the PREVIOUS good version, so both are
2474
2891
  // taken before the atomic replace, and neither may ever be renamed into
2475
2892
  // place (see the long note above).
@@ -2484,8 +2901,22 @@ export function apply(ctx) {
2484
2901
  // discloses it (the .bak may be stale, and the panel says so).
2485
2902
  let hadPrevious = true
2486
2903
  try {
2487
- await readStoreFile(storePath)
2904
+ const previous = await readStoreFile(storePath)
2905
+ // "Reads as a store", not merely "reads as JSON" — this is the test the
2906
+ // comment above promises. A foreign-but-parseable primary (an array, a
2907
+ // scalar, another tool's object) must NOT be copied over the last good
2908
+ // backup: that is exactly what turns a recoverable state into a
2909
+ // permanent one.
2910
+ const previousShape = storeShapeVerdict(previous)
2911
+ if (previousShape === 'not-object' || previousShape === 'not-store') {
2912
+ const shapeError = new Error('memory.json is not a store (' + previousShape + ')')
2913
+ shapeError.code = NOT_A_STORE_CODE
2914
+ throw shapeError
2915
+ }
2488
2916
  await copyFile(storePath, localBackupPath())
2917
+ // The recovery copy exists to survive a crash; an unflushed copy may
2918
+ // roll back with the very crash it is meant to answer.
2919
+ await syncPath(localBackupPath())
2489
2920
  state.backupError = null
2490
2921
  } catch (err) {
2491
2922
  if (err !== null && err !== undefined && err.code === 'ENOENT') {
@@ -2494,6 +2925,12 @@ export function apply(ctx) {
2494
2925
  // would adopt as an "empty store" — the trap this guard exists for.
2495
2926
  hadPrevious = false
2496
2927
  state.backupError = null
2928
+ } else if (err !== null && err !== undefined && err.code === NOT_A_STORE_CODE) {
2929
+ hadPrevious = false
2930
+ const previous = await stat(localBackupPath()).catch(() => null)
2931
+ state.backupError = previous !== null && previous.size > 0
2932
+ ? 'memory.json 的内容不是记忆存储,已保留上一版备份 ' + localBackupPath() + ' 不被覆盖'
2933
+ : 'memory.json 的内容不是记忆存储,且没有可用的上一版备份'
2497
2934
  } else {
2498
2935
  hadPrevious = false
2499
2936
  const previous = await stat(localBackupPath()).catch(() => null)
@@ -2522,6 +2959,9 @@ export function apply(ctx) {
2522
2959
  await unlink(tmpPath).catch(() => {})
2523
2960
  throw err
2524
2961
  }
2962
+ // Make the directory entry itself durable: without this the rename can be
2963
+ // lost on power failure even though the file contents were flushed.
2964
+ await syncPath(dirname(storePath))
2525
2965
  state.storagePath = storePath
2526
2966
  state.storageDisplay = storePath
2527
2967
  state.storageError = null
@@ -2538,6 +2978,9 @@ export function apply(ctx) {
2538
2978
  // committed, and the store is on disk whatever happens next.
2539
2979
  await writeHistorySnapshot(text, contentKey)
2540
2980
  lastPersistedKey = contentKey
2981
+ // The disk holds a store again, so a "primary is not a store" refusal no
2982
+ // longer describes reality — this is the one place that can prove it.
2983
+ state.shapeRejected = false
2541
2984
  await ensureSchemaFile()
2542
2985
  logDebug('store written: ' + storePath + ' bytes=' + text.length
2543
2986
  + ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
@@ -2613,6 +3056,13 @@ export function apply(ctx) {
2613
3056
  state.storeExists = false
2614
3057
  state.versionRejected = false
2615
3058
  state.rejectedVersion = null
3059
+ state.shapeRejected = false
3060
+ // A reload replaces what "the last persisted content" means. Leaving the
3061
+ // old identity in place made a later save that happened to match the
3062
+ // PRE-reload content skip the write while reporting saved:true, so the disk
3063
+ // and the panel silently disagreed. Forcing the next save to write is the
3064
+ // cheap side of that trade.
3065
+ lastPersistedKey = null
2616
3066
 
2617
3067
  /**
2618
3068
  * A store declaring an unsupported format version: adopt nothing, write
@@ -2636,6 +3086,54 @@ export function apply(ctx) {
2636
3086
  state.storeExists = true
2637
3087
  }
2638
3088
 
3089
+ /**
3090
+ * The primary parses as JSON but is not a store (an array, a scalar, or
3091
+ * another tool's object). Adopt NOTHING from it: the in-memory data — a
3092
+ * freshly booted empty store, or whatever this process already had — stays
3093
+ * authoritative, ordinary edits are refused, and both files stay on disk.
3094
+ * The repair doors (history restore, restore-from-backup, import, source
3095
+ * mode) remain open and clear the flag on the first successful write.
3096
+ */
3097
+ const rejectShape = (describe) => {
3098
+ state.shapeRejected = true
3099
+ state.storageError = 'memory.json 的内容不是本插件的记忆存储:' + describe
3100
+ + '。已暂停写入以免覆盖原文件(原文件已保留)。请用「历史回滚」或「用备份整体替换」恢复,或修正该文件后点「重新载入」'
3101
+ state.loadedFrom = 'memory.json (not a store; writes paused)'
3102
+ state.storeExists = true
3103
+ }
3104
+
3105
+ /**
3106
+ * A primary that is not a store still leaves the rolling backup intact, so
3107
+ * the last good version is usually one file away. Promote it into memory
3108
+ * (never back onto disk here) and disclose both facts: the primary is
3109
+ * foreign, and the panel is temporarily showing the backup.
3110
+ */
3111
+ const recoverFromForeignPrimary = async (shape) => {
3112
+ state.storeExists = true
3113
+ const describe = shape === 'not-object'
3114
+ ? '顶层不是对象(是数组或标量)'
3115
+ : '顶层对象里没有 entries / sets / version 等记忆字段'
3116
+ try {
3117
+ const backup = await readStoreFile(storePath + '.bak')
3118
+ if (backup !== null && storeShapeVerdict(backup) === 'store') {
3119
+ const loaded = normalizeData(backup)
3120
+ if (loaded.rejectedVersion !== undefined) {
3121
+ rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
3122
+ return
3123
+ }
3124
+ state.data = loaded.data
3125
+ const warnings = loadWarnings(loaded)
3126
+ state.storageWarning = (warnings !== null ? warnings + ';' : '')
3127
+ + 'memory.json ' + describe + ',不是本插件的记忆存储(原文件已保留);已临时回退到最近备份 memory.json.bak'
3128
+ state.loadedFrom = 'memory.json.bak (primary is not a store)'
3129
+ return
3130
+ }
3131
+ } catch {
3132
+ // No usable backup either; refuse rather than guess.
3133
+ }
3134
+ rejectShape(describe)
3135
+ }
3136
+
2639
3137
  // 1. The canonical location under the harness home.
2640
3138
  try {
2641
3139
  const parsed = await readStoreFile(storePath)
@@ -2645,6 +3143,14 @@ export function apply(ctx) {
2645
3143
  rejectVersion(loaded.rejectedVersion, 'memory.json')
2646
3144
  return false
2647
3145
  }
3146
+ // Parsing is not the same as being a store: an array or a foreign
3147
+ // object used to be adopted as "empty memory" with no warning, and the
3148
+ // next save wrote that emptiness back. Never adopt it.
3149
+ const shape = storeShapeVerdict(parsed)
3150
+ if (shape === 'not-object' || shape === 'not-store') {
3151
+ await recoverFromForeignPrimary(shape)
3152
+ return false
3153
+ }
2648
3154
  state.data = loaded.data
2649
3155
  state.storageWarning = loadWarnings(loaded)
2650
3156
  state.loadedFrom = 'memory.json'
@@ -2686,7 +3192,7 @@ export function apply(ctx) {
2686
3192
  : 'memory.json 解析失败(' + describeErrorSafe(err) + ')'
2687
3193
  try {
2688
3194
  const backup = await readStoreFile(storePath + '.bak')
2689
- if (backup !== null) {
3195
+ if (backup !== null && storeShapeVerdict(backup) === 'store') {
2690
3196
  const loaded = normalizeData(backup)
2691
3197
  if (loaded.rejectedVersion !== undefined) {
2692
3198
  rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
@@ -2723,7 +3229,7 @@ export function apply(ctx) {
2723
3229
  // canonical path so the next boot finds it normally.
2724
3230
  try {
2725
3231
  const backup = await readStoreFile(storePath + '.bak')
2726
- if (backup !== null) {
3232
+ if (backup !== null && storeShapeVerdict(backup) === 'store') {
2727
3233
  const loaded = normalizeData(backup)
2728
3234
  if (loaded.rejectedVersion !== undefined) {
2729
3235
  rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
@@ -2748,22 +3254,29 @@ export function apply(ctx) {
2748
3254
  try {
2749
3255
  const parsed = await readStoreFile(legacyPath)
2750
3256
  if (parsed !== null) {
2751
- const loaded = normalizeData(parsed)
2752
- if (loaded.rejectedVersion !== undefined) {
2753
- // A legacy file in a future format is not ours to migrate or judge.
2754
- state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
2755
- return false
2756
- }
2757
- state.data = loaded.data
2758
- state.storageWarning = loadWarnings(loaded)
2759
- const migrated = await writeToDisk()
2760
- if (migrated) {
2761
- state.migratedFrom = legacyPath
2762
- // Keep the original as evidence instead of deleting user data.
2763
- await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
3257
+ const legacyShape = storeShapeVerdict(parsed)
3258
+ if (legacyShape === 'not-object' || legacyShape === 'not-store') {
3259
+ // Some other program's JSON beside the old store path is not a store
3260
+ // to migrate, and must never become memory.json.
3261
+ state.storageError = '旧位置文件不是本插件的记忆存储,已忽略'
3262
+ } else {
3263
+ const loaded = normalizeData(parsed)
3264
+ if (loaded.rejectedVersion !== undefined) {
3265
+ // A legacy file in a future format is not ours to migrate or judge.
3266
+ state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
3267
+ return false
3268
+ }
3269
+ state.data = loaded.data
3270
+ state.storageWarning = loadWarnings(loaded)
3271
+ const migrated = await writeToDisk()
3272
+ if (migrated) {
3273
+ state.migratedFrom = legacyPath
3274
+ // Keep the original as evidence instead of deleting user data.
3275
+ await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
3276
+ }
3277
+ state.loadedFrom = 'legacy cwd file (migrated)'
3278
+ return migrated
2764
3279
  }
2765
- state.loadedFrom = 'legacy cwd file (migrated)'
2766
- return migrated
2767
3280
  }
2768
3281
  } catch (err) {
2769
3282
  // A missing or unreadable legacy file must never block a fresh store.
@@ -2845,6 +3358,11 @@ export function apply(ctx) {
2845
3358
  })
2846
3359
  .then(async () => {
2847
3360
  bootSettled = true
3361
+ // Upgrade path: directories created by an older version keep their old
3362
+ // mode because `mkdir` never re-applies it to an existing path. Repair
3363
+ // them on every start; a failure here is a warning, never a boot error.
3364
+ await repairDirMode(dirname(storePath))
3365
+ await repairDirMode(historyDirPath())
2848
3366
  syncSection()
2849
3367
  await refreshHistoryCount()
2850
3368
  // The one always-on line: it answers "is memory mounted, where does it
@@ -2932,6 +3450,14 @@ export function apply(ctx) {
2932
3450
  profiles: state.data.profiles.filter((profile) => belongsTo(profile, set.id)).length,
2933
3451
  })),
2934
3452
  },
3453
+ /**
3454
+ * The running plugin version. The panel renders it in the mount-failure
3455
+ * notice, which is the one place a user reads a number off the screen
3456
+ * when the settings page never appears — reading it from the store would
3457
+ * be wrong (the store survives upgrades) and the client cannot import
3458
+ * package.json.
3459
+ */
3460
+ pluginVersion: PLUGIN_VERSION,
2935
3461
  /**
2936
3462
  * F12: the one security signal the panel was missing. `hostGuarded` is
2937
3463
  * the SAME flag the route enforces on every request — false means the
@@ -2943,6 +3469,15 @@ export function apply(ctx) {
2943
3469
  security: {
2944
3470
  hostGuarded: guardHost === true,
2945
3471
  boundHost: state.boundHost,
3472
+ /**
3473
+ * F19 follow-up: whether the harness authentication fence is attached
3474
+ * RIGHT NOW. `hostGuarded === false` on its own reads as "no
3475
+ * authentication at all", which is only true when this is false too:
3476
+ * with the fence attached the route still requires a logged-in browser
3477
+ * session (`connection.requestRejection`), and the panel must be able
3478
+ * to say which of the two situations it is looking at.
3479
+ */
3480
+ authFence: connectionGate !== null,
2946
3481
  },
2947
3482
  /**
2948
3483
  * F12: load-side diagnostics that used to be log-only. `loadedFrom` says
@@ -3001,9 +3536,16 @@ export function apply(ctx) {
3001
3536
  * Persona section detail: rendered profiles (id/name/chars, in order)
3002
3537
  * and their total size. `entryChars` is the instruction half alone,
3003
3538
  * which is what the budget meter measures; `chars` above stays the
3004
- * whole section (personas + instructions) for diagnostics.
3539
+ * whole section (personas + instructions) for diagnostics. `truncated`
3540
+ * mirrors the entry list: a persona cut by the injection ceiling has to
3541
+ * be DISCLOSED, or the panel shows full text the model never received.
3005
3542
  */
3006
- profiles: plan.profiles.map((item) => ({ id: item.id, name: item.name, chars: item.chars })),
3543
+ profiles: plan.profiles.map((item) => ({
3544
+ id: item.id,
3545
+ name: item.name,
3546
+ chars: item.chars,
3547
+ truncated: item.truncated === true,
3548
+ })),
3007
3549
  profileChars: plan.profileChars,
3008
3550
  entryChars: plan.entryChars,
3009
3551
  /**
@@ -3094,6 +3636,20 @@ export function apply(ctx) {
3094
3636
  snapshot: snapshot(),
3095
3637
  })
3096
3638
 
3639
+ /**
3640
+ * Refusal while memory.json parses as JSON but is not a store at all (an
3641
+ * array, a scalar, another tool's object). Same reason as versionRefusal:
3642
+ * a save would rewrite the file in this build's format over content this
3643
+ * build never understood. The repair doors stay open — that is what
3644
+ * `shapeRejected` is for, and why they deliberately skip this check.
3645
+ */
3646
+ const shapeRefusal = () => ({
3647
+ ok: false,
3648
+ saved: false,
3649
+ message: 'memory.json 的内容不是本插件的记忆存储,修改已暂停以免覆盖原文件。请用「历史回滚」或「用备份整体替换」恢复,或修正该文件后点「重新载入」',
3650
+ snapshot: snapshot(),
3651
+ })
3652
+
3097
3653
  /**
3098
3654
  * Adopt a whole parsed store — the shared core of 源码模式 (F16) and the
3099
3655
  * 整体替换 restore (F14). Both hand the user the SAME safety story as every
@@ -3107,17 +3663,23 @@ export function apply(ctx) {
3107
3663
  * before the path existed — must not silently redirect where backups go.
3108
3664
  */
3109
3665
  const adoptParsedStore = (parsed) => {
3110
- const looksLikeStore = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
3111
- && ['version', 'rev', 'enabled', 'budgetChars', 'backupDir', 'activeSetId', 'sets', 'entries', 'profiles']
3112
- .some((key) => key in parsed)
3113
- if (!looksLikeStore) {
3666
+ // Version first. `{"version":3}` is a FUTURE store, and the honest answer
3667
+ // is "unsupported version", not "not a store" — an unsupported version is
3668
+ // by definition not a shape this build recognises, so the shape test below
3669
+ // would mislabel it.
3670
+ const loaded = normalizeData(parsed)
3671
+ if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
3672
+ // Then shape, with the loader's own verdict rather than a looser one. A
3673
+ // file carrying a single stray option key (`{"version":2}`,
3674
+ // `{"enabled":false}`) is NOT a store to adopt wholesale: on the
3675
+ // import/restore path that would replace real memory with near-nothing and
3676
+ // report saved:true.
3677
+ if (storeShapeVerdict(parsed) !== 'store') {
3114
3678
  return {
3115
- error: '内容不像本插件的存储文件:顶层需要是对象,且至少包含 entries / profiles / sets / version 之一。'
3679
+ error: '内容不像本插件的存储文件:顶层需要是对象,且必须包含 entries / sets 数组(或受支持的 version)。'
3116
3680
  + '若要清空记忆,请使用界面上的删除操作,而不是保存一个空对象',
3117
3681
  }
3118
3682
  }
3119
- const loaded = normalizeData(parsed)
3120
- if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
3121
3683
  loaded.data.backupDir = backupDirOf()
3122
3684
  return { loaded }
3123
3685
  }
@@ -3189,6 +3751,7 @@ export function apply(ctx) {
3189
3751
 
3190
3752
  const saveEntry = (raw, baseRev) => enqueue(async () => {
3191
3753
  if (state.versionRejected) return versionRefusal()
3754
+ if (state.shapeRejected) return shapeRefusal()
3192
3755
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3193
3756
  const entry = sanitizeEntry(raw)
3194
3757
  if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
@@ -3240,6 +3803,7 @@ export function apply(ctx) {
3240
3803
 
3241
3804
  const deleteEntry = (id, baseRev) => enqueue(async () => {
3242
3805
  if (state.versionRejected) return versionRefusal()
3806
+ if (state.shapeRejected) return shapeRefusal()
3243
3807
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3244
3808
  // Deleting an id that is not there changes nothing, so it must not touch
3245
3809
  // disk, bump rev, or push an echo frame — otherwise two panels deleting
@@ -3262,6 +3826,7 @@ export function apply(ctx) {
3262
3826
 
3263
3827
  const setOptions = (patch, baseRev) => enqueue(async () => {
3264
3828
  if (state.versionRejected) return versionRefusal()
3829
+ if (state.shapeRejected) return shapeRefusal()
3265
3830
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3266
3831
  // The custom backup location (F18) is validated BEFORE anything is queued
3267
3832
  // into the store: an unusable path must be refused with a reason, not
@@ -3280,6 +3845,18 @@ export function apply(ctx) {
3280
3845
  snapshot: snapshot(),
3281
3846
  }
3282
3847
  }
3848
+ // F19: an unauthenticated caller must not be able to create
3849
+ // directories outside the harness home. Checked BEFORE the probe, so a
3850
+ // refused path is never touched at all.
3851
+ if (!allowExternalBackup() && !isInsideDshRoot(wanted)) {
3852
+ return {
3853
+ ok: false,
3854
+ saved: false,
3855
+ message: '备份位置必须位于 DSH 主目录内(' + dshRootPath() + ');要使用其他磁盘请设置环境变量 '
3856
+ + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
3857
+ snapshot: snapshot(),
3858
+ }
3859
+ }
3283
3860
  const problem = await probeBackupDir(wanted)
3284
3861
  if (problem !== null) {
3285
3862
  return { ok: false, saved: false, message: '备份位置不可用(' + problem + '):' + wanted, snapshot: snapshot() }
@@ -3399,6 +3976,7 @@ export function apply(ctx) {
3399
3976
  */
3400
3977
  const batchEntries = (op, ids, value, baseRev) => enqueue(async () => {
3401
3978
  if (state.versionRejected) return versionRefusal()
3979
+ if (state.shapeRejected) return shapeRefusal()
3402
3980
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3403
3981
  if (typeof op !== 'string' || !BATCH_OPS.includes(op)) {
3404
3982
  return { ok: false, saved: false, message: '未知的批量操作:' + String(op), snapshot: snapshot() }
@@ -3506,6 +4084,7 @@ export function apply(ctx) {
3506
4084
 
3507
4085
  const saveProfile = (raw, baseRev) => enqueue(async () => {
3508
4086
  if (state.versionRejected) return versionRefusal()
4087
+ if (state.shapeRejected) return shapeRefusal()
3509
4088
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3510
4089
  const profile = sanitizeProfile(raw)
3511
4090
  if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
@@ -3536,6 +4115,7 @@ export function apply(ctx) {
3536
4115
 
3537
4116
  const deleteProfile = (id, baseRev) => enqueue(async () => {
3538
4117
  if (state.versionRejected) return versionRefusal()
4118
+ if (state.shapeRejected) return shapeRefusal()
3539
4119
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3540
4120
  // Mirror deleteEntry: an absent id changes nothing and must not touch disk,
3541
4121
  // and an id belonging to a different set is not this panel's to delete.
@@ -3563,6 +4143,7 @@ export function apply(ctx) {
3563
4143
  */
3564
4144
  const saveSet = (raw, baseRev) => enqueue(async () => {
3565
4145
  if (state.versionRejected) return versionRefusal()
4146
+ if (state.shapeRejected) return shapeRefusal()
3566
4147
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3567
4148
  const wanted = sanitizeSet(raw)
3568
4149
  if (wanted === null) {
@@ -3603,6 +4184,7 @@ export function apply(ctx) {
3603
4184
  */
3604
4185
  const deleteSet = (id, baseRev) => enqueue(async () => {
3605
4186
  if (state.versionRejected) return versionRefusal()
4187
+ if (state.shapeRejected) return shapeRefusal()
3606
4188
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3607
4189
  const index = state.data.sets.findIndex((set) => set.id === id)
3608
4190
  if (index < 0) {
@@ -3631,6 +4213,7 @@ export function apply(ctx) {
3631
4213
  */
3632
4214
  const setActiveSet = (id, baseRev) => enqueue(async () => {
3633
4215
  if (state.versionRejected) return versionRefusal()
4216
+ if (state.shapeRejected) return shapeRefusal()
3634
4217
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3635
4218
  const target = state.data.sets.find((set) => set.id === id)
3636
4219
  if (target === undefined) {
@@ -3652,6 +4235,7 @@ export function apply(ctx) {
3652
4235
  */
3653
4236
  const duplicateSet = (id, name, baseRev) => enqueue(async () => {
3654
4237
  if (state.versionRejected) return versionRefusal()
4238
+ if (state.shapeRejected) return shapeRefusal()
3655
4239
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3656
4240
  const source = state.data.sets.find((set) => set.id === id)
3657
4241
  if (source === undefined) {
@@ -3781,13 +4365,28 @@ export function apply(ctx) {
3781
4365
  */
3782
4366
  const historyRestore = (name, baseRev) => enqueue(async () => {
3783
4367
  if (state.versionRejected) return versionRefusal()
4368
+ // Deliberately NOT gated on shapeRejected: restoring a snapshot is the
4369
+ // repair door for a primary that is not a store.
3784
4370
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3785
4371
  if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
3786
4372
  return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
3787
4373
  }
3788
4374
  let parsed = null
4375
+ const snapshotPath = join(historyDirPath(), name)
3789
4376
  try {
3790
- parsed = JSON.parse(await readFile(join(historyDirPath(), name), 'utf8'))
4377
+ // Bounded read: stat first, so an absurd file in a shared backup folder
4378
+ // (or a corrupted one) is refused instead of pulled into memory whole.
4379
+ const info = await stat(snapshotPath)
4380
+ if (!info.isFile()) throw new Error('不是普通文件')
4381
+ if (info.size > MAX_HISTORY_FILE_BYTES) {
4382
+ return {
4383
+ ok: false,
4384
+ saved: false,
4385
+ message: '该历史快照过大(' + info.size + ' 字节,上限 ' + MAX_HISTORY_FILE_BYTES + ' 字节),已拒绝读取',
4386
+ snapshot: snapshot(),
4387
+ }
4388
+ }
4389
+ parsed = JSON.parse(await readFile(snapshotPath, 'utf8'))
3791
4390
  } catch (err) {
3792
4391
  return { ok: false, saved: false, message: '无法读取该历史快照:' + describeErrorSafe(err), snapshot: snapshot() }
3793
4392
  }
@@ -3868,6 +4467,11 @@ export function apply(ctx) {
3868
4467
  snapshot: snapshot(),
3869
4468
  }))
3870
4469
 
4470
+ /** F19: timestamp of the last export-to-dir, for the frequency guard. */
4471
+ let lastExportAt = 0
4472
+ /** F19: minimum spacing between export-to-dir writes. */
4473
+ const EXPORT_COOLDOWN_MS = 2000
4474
+
3871
4475
  /**
3872
4476
  * Write an export straight into the backup directory (F18) — the "land the
3873
4477
  * backup in my cloud-synced folder" button. The default directory is the
@@ -3875,9 +4479,34 @@ export function apply(ctx) {
3875
4479
  */
3876
4480
  const exportToDir = () => enqueue(async () => {
3877
4481
  const dir = backupDirOf() !== '' ? backupDirOf() : dirname(storePath)
3878
- const target = join(dir, exportFileName(Date.now()))
4482
+ // F19: the same subtree rule the setting enforces, re-checked at write
4483
+ // time — a store hand-edited to point elsewhere must not become a way to
4484
+ // create arbitrary directories through this route either.
4485
+ if (!allowExternalBackup() && !isInsideDshRoot(dir)) {
4486
+ return {
4487
+ ok: false,
4488
+ saved: false,
4489
+ message: '导出目录必须位于 DSH 主目录内(' + dshRootPath() + ');要写入其他磁盘请设置环境变量 '
4490
+ + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
4491
+ snapshot: snapshot(),
4492
+ }
4493
+ }
4494
+ // Each export writes a fixed-name JSON file of the whole store. Refusing a
4495
+ // second export inside the cooldown keeps a script from using the button
4496
+ // as a disk-fill loop.
4497
+ const now = Date.now()
4498
+ if (now - lastExportAt < EXPORT_COOLDOWN_MS) {
4499
+ return {
4500
+ ok: false,
4501
+ saved: false,
4502
+ message: '导出过于频繁,请 ' + Math.ceil((EXPORT_COOLDOWN_MS - (now - lastExportAt)) / 1000) + ' 秒后重试',
4503
+ snapshot: snapshot(),
4504
+ }
4505
+ }
4506
+ lastExportAt = now
4507
+ const target = join(dir, exportFileName(now))
3879
4508
  try {
3880
- await mkdir(dir, { recursive: true })
4509
+ await hardenDir(dir)
3881
4510
  await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
3882
4511
  } catch (err) {
3883
4512
  return { ok: false, saved: false, message: '写入备份目录失败:' + describeErrorSafe(err), snapshot: snapshot() }
@@ -3952,6 +4581,8 @@ export function apply(ctx) {
3952
4581
 
3953
4582
  const writeSource = (text, baseRev) => enqueue(async () => {
3954
4583
  if (state.versionRejected) return versionRefusal()
4584
+ // Deliberately NOT gated on shapeRejected: hand-editing the source is how
4585
+ // a user repairs a primary that is not a store.
3955
4586
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3956
4587
  if (typeof text !== 'string') {
3957
4588
  return { ok: false, saved: false, message: '源码必须是 JSON 文本', snapshot: snapshot() }
@@ -3982,6 +4613,8 @@ export function apply(ctx) {
3982
4613
  */
3983
4614
  const restoreData = (payload, baseRev) => enqueue(async () => {
3984
4615
  if (state.versionRejected) return versionRefusal()
4616
+ // Deliberately NOT gated on shapeRejected: wholesale replacement from a
4617
+ // backup is the repair door for a primary that is not a store.
3985
4618
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3986
4619
  if (payload === null || typeof payload !== 'object') {
3987
4620
  return { ok: false, saved: false, message: '恢复内容不是有效的 JSON 对象', snapshot: snapshot() }
@@ -3995,6 +4628,8 @@ export function apply(ctx) {
3995
4628
 
3996
4629
  const importData = (payload, baseRev) => enqueue(async () => {
3997
4630
  if (state.versionRejected) return versionRefusal()
4631
+ // Deliberately NOT gated on shapeRejected: importing a real store is a
4632
+ // repair path for a primary that is not one.
3998
4633
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3999
4634
  if (payload === null || typeof payload !== 'object') {
4000
4635
  return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
@@ -4282,11 +4917,29 @@ export function apply(ctx) {
4282
4917
  const readFeedbackConfigFile = async (path) => {
4283
4918
  let text
4284
4919
  try {
4920
+ // Bounded read: this file is operator-authored, but a wrong path (a log,
4921
+ // a database) must not be dragged into memory just because the send
4922
+ // button was pressed.
4923
+ const info = await stat(path)
4924
+ if (!info.isFile()) throw new Error('不是普通文件')
4925
+ if (info.size > MAX_FEEDBACK_CONFIG_BYTES) {
4926
+ throw new Error('文件过大(' + info.size + ' 字节,上限 ' + MAX_FEEDBACK_CONFIG_BYTES + ' 字节)')
4927
+ }
4285
4928
  text = await readFile(path, 'utf8')
4286
4929
  } catch (err) {
4287
4930
  if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
4288
4931
  throw new Error('无法读取反馈配置文件 ' + path + ':' + describeErrorSafe(err))
4289
4932
  }
4933
+ // This file can hold SMTP credentials. The plugin does not create it (the
4934
+ // operator does), so it cannot set its mode — it can only say something
4935
+ // when the mode is wrong, which is the part that used to be silent.
4936
+ if (process.platform !== 'win32') {
4937
+ const info = await stat(path).catch(() => null)
4938
+ if (info !== null && (info.mode & 0o077) !== 0) {
4939
+ logInfo('feedback config ' + displayPath(path) + ' is readable by group/others (mode '
4940
+ + (info.mode & 0o777).toString(8) + '); it may hold SMTP credentials')
4941
+ }
4942
+ }
4290
4943
  try {
4291
4944
  return JSON.parse(text)
4292
4945
  } catch (err) {
@@ -4504,6 +5157,21 @@ export function apply(ctx) {
4504
5157
  // `security.hostGuarded`); only `routeDispose` lives here.
4505
5158
 
4506
5159
  let routeDispose = null
5160
+ /**
5161
+ * F19: the composition's `connection` service, when the profile has one.
5162
+ *
5163
+ * It is the harness's single authentication fence: `requestRejection(req)`
5164
+ * applies the Host/Origin trust check AND requires the browser login cookie
5165
+ * (the same gate that makes `/api` answer 401 to an anonymous caller). This
5166
+ * route used to rely on the Origin guesswork below alone, which left it as
5167
+ * the only unauthenticated read/write endpoint on the instance — a stored
5168
+ * prompt-injection primitive for any local process.
5169
+ *
5170
+ * Read through a NON-BLOCKING child fiber for the same reason `webServer` is
5171
+ * (headless/tui profiles must still inject the prompt), and kept nullable:
5172
+ * when the service is absent the loopback-only rule below still applies.
5173
+ */
5174
+ let connectionGate = null
4507
5175
 
4508
5176
  /**
4509
5177
  * Does this stream subscriber want lightweight frames?
@@ -4543,13 +5211,11 @@ export function apply(ctx) {
4543
5211
  * `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
4544
5212
  */
4545
5213
  const handleEventStream = (req, res) => {
4546
- const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname)
4547
- if (!verdict.ok) {
4548
- logInfo('route rejected a request: ' + verdict.reason)
4549
- res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
4550
- res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
4551
- return
4552
- }
5214
+ // Same fence as the POST path: an EventSource carries the login cookie, so
5215
+ // an authenticated stream keeps working while an anonymous one is refused.
5216
+ const fence = rejectUntrustedCaller(req, res)
5217
+ if (fence === 'rejected') return
5218
+ if (originVerdictRefuses(req, res, fence)) return
4553
5219
  // Every subscriber holds a Map entry, a `req.on('close')` listener and a
4554
5220
  // live socket until it disconnects. A tab that is duplicated, a client that
4555
5221
  // never closes, or a script that opens streams in a loop would otherwise
@@ -4609,7 +5275,94 @@ export function apply(ctx) {
4609
5275
  }
4610
5276
  }
4611
5277
 
5278
+ /**
5279
+ * F19: refuse a caller the harness has not authenticated.
5280
+ *
5281
+ * Order matters. The harness fence runs FIRST when it exists: it is the only
5282
+ * check that knows about the browser login cookie, and it also owns the
5283
+ * Host/Origin trust rules for every other route on the instance. The
5284
+ * hand-rolled Origin verdict below stays as the second line of defence (it
5285
+ * covers profiles that publish a web server without a `connection` service).
5286
+ *
5287
+ * When there is no fence at all, a NON-loopback bind fails closed: publishing
5288
+ * an unauthenticated endpoint that can rewrite the instructions injected into
5289
+ * every later turn is not a trade-off worth defaulting to. The loopback bind
5290
+ * keeps working, which is the documented deployment (and what the Android
5291
+ * build's route-guard patch assumes).
5292
+ *
5293
+ * @returns 'rejected' when this handler already answered the request (401,
5294
+ * 403 or 503 and the response is complete), 'gate' when the harness fence
5295
+ * accepted it (so the Host/Origin question is already settled and the
5296
+ * plugin's own Origin verdict must NOT run again), 'open' when there is no
5297
+ * fence and the caller still has to pass the local Origin rules.
5298
+ */
5299
+ const rejectUntrustedCaller = (req, res) => {
5300
+ if (connectionGate !== null) {
5301
+ let rejection
5302
+ try {
5303
+ rejection = connectionGate.requestRejection(req)
5304
+ } catch (err) {
5305
+ // A throwing fence must not become an open door: treat it as 401.
5306
+ logInfo('connection fence threw, refusing the request: ' + describeError(err))
5307
+ rejection = 401
5308
+ }
5309
+ if (typeof rejection === 'number' && rejection >= 400) {
5310
+ logInfo('route refused an unauthenticated request (' + rejection + ')')
5311
+ res.writeHead(rejection, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5312
+ res.end(JSON.stringify({
5313
+ ok: false,
5314
+ message: rejection === 403
5315
+ ? '请求未被宿主信任(Host/Origin 校验失败)'
5316
+ : '需要登录 DSH 后的浏览器会话才能访问该路由',
5317
+ }))
5318
+ return 'rejected'
5319
+ }
5320
+ return 'gate'
5321
+ }
5322
+ if (!guardHost) {
5323
+ logInfo('route refused a request: non-loopback bind without an authentication fence')
5324
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5325
+ res.end(JSON.stringify({
5326
+ ok: false,
5327
+ message: '该路由在非回环绑定下需要宿主的 connection 服务提供鉴权;当前 profile 没有该服务,已拒绝请求',
5328
+ }))
5329
+ return 'rejected'
5330
+ }
5331
+ return 'open'
5332
+ }
5333
+
5334
+ /**
5335
+ * Run the plugin's OWN Origin/Host verdict, but only when the harness fence
5336
+ * did not already answer the same question (F19 follow-up).
5337
+ *
5338
+ * Order: the fence runs first. When it accepts a request it has already
5339
+ * applied the host's Host / `Origin` / `Sec-Fetch-Site` rules AND verified
5340
+ * the browser login cookie (`dsh-client-connection`: `isTrustedApiRequest`
5341
+ * then `browserAuth`). It also knows the deployment's public name. This
5342
+ * plugin knows only its bind address, so re-judging `Origin` here produced a
5343
+ * false 403 for every legitimate reverse-proxy client: the proxy forwards
5344
+ * `Host: dsh.example.com`, the listener binds `192.168.1.5`, and the Origin
5345
+ * host could never equal the bind IP. The local verdict stays as the second
5346
+ * line of defence for profiles that publish a web server WITHOUT a
5347
+ * `connection` service — there it is the only fence there is.
5348
+ *
5349
+ * @returns true when the request was answered with 403 and must not continue.
5350
+ */
5351
+ const originVerdictRefuses = (req, res, fence) => {
5352
+ if (fence === 'gate') return false
5353
+ const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname, trustedHosts)
5354
+ if (verdict.ok) return false
5355
+ logInfo('route rejected a request: ' + verdict.reason)
5356
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5357
+ res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
5358
+ return true
5359
+ }
5360
+
4612
5361
  const handler = (req, res) => {
5362
+ // Authentication before anything else: not the method, not the body, not
5363
+ // the size. A rejection must not leak which methods exist.
5364
+ const fence = rejectUntrustedCaller(req, res)
5365
+ if (fence === 'rejected') return
4613
5366
  if (req.method === 'GET') {
4614
5367
  const accept = String(
4615
5368
  (req.headers !== null && typeof req.headers === 'object') ? (req.headers.accept || '') : '',
@@ -4649,16 +5402,9 @@ export function apply(ctx) {
4649
5402
  return
4650
5403
  }
4651
5404
  }
4652
- // Origin/Host guard, before anything else is read. See
4653
- // requestOriginVerdict() for why the content-type rule below is not enough
4654
- // on its own.
4655
- const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname)
4656
- if (!verdict.ok) {
4657
- logInfo('route rejected a request: ' + verdict.reason)
4658
- res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
4659
- res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
4660
- return
4661
- }
5405
+ // Origin/Host guard, before anything else is read. Skipped when the host
5406
+ // fence already accepted this request — see originVerdictRefuses().
5407
+ if (originVerdictRefuses(req, res, fence)) return
4662
5408
  // The settings page always posts application/json. Enforcing it turns
4663
5409
  // the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
4664
5410
  // body) into a hard rule: a cross-site or malformed post now gets a
@@ -4736,6 +5482,30 @@ export function apply(ctx) {
4736
5482
  // losing the prompt injection along with the settings page. The callback also
4737
5483
  // fixes the original race properly — it runs whenever the service is
4738
5484
  // published, not only if it happened to beat this row to the mount.
5485
+ // F19: take the harness authentication fence when the profile has one.
5486
+ // Same non-blocking shape as the web server below — a profile without a
5487
+ // browser never publishes `connection`, and the prompt injection must not
5488
+ // wait for one.
5489
+ ctx.inject(['connection'], (scope) => {
5490
+ const service = scope.connection
5491
+ if (service === undefined || service === null || typeof service.requestRejection !== 'function') {
5492
+ logDebug('no connection service; the route falls back to the loopback-only rule')
5493
+ return
5494
+ }
5495
+ connectionGate = service
5496
+ logDebug('connection fence attached; the route now requires an authenticated browser session')
5497
+ scope.effect(() => () => {
5498
+ if (connectionGate !== service) return
5499
+ connectionGate = null
5500
+ // A host that republishes (or disposes) the service would otherwise let
5501
+ // the route fall back to the loopback-only rule with nothing said about
5502
+ // it. That is not an open door on a loopback bind, but it IS a silent
5503
+ // authentication downgrade, and `snapshot.security.authFence` now shows
5504
+ // it for as long as it lasts.
5505
+ logInfo('connection fence detached while the plugin stayed mounted; the route falls back to the loopback-only rule until the service is published again')
5506
+ })
5507
+ })
5508
+
4739
5509
  ctx.inject(['webServer'], (scope) => {
4740
5510
  const webServer = scope.webServer
4741
5511
  if (webServer === undefined || typeof webServer.register !== 'function') {