dsh-instruction-memory 1.4.7 → 1.4.8

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, 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,
@@ -148,6 +148,35 @@ const BACKUP_FILE_SUFFIX = '.bak'
148
148
  */
149
149
  const SECTION_ORDER = 950
150
150
  const API_PREFIX = '/instruction-memory/api'
151
+ /**
152
+ * Extra hostnames this route accepts, comma separated (F19).
153
+ *
154
+ * A loopback bind normally only answers to `127.0.0.1`/`localhost`, which is
155
+ * what makes the DNS-rebinding guard meaningful — but it also made the route
156
+ * unusable behind a reverse proxy that forwards the public `Host` (nginx's
157
+ * default `proxy_set_header Host $host`). Listing the published hostname here
158
+ * re-opens exactly that one name and nothing else: the loopback rule still
159
+ * applies to everything else, and `Origin`/`Sec-Fetch-Site` are still enforced.
160
+ */
161
+ const TRUSTED_HOSTS_ENV = 'DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS'
162
+ /**
163
+ * Escape hatch for F18 backups outside `$DSH_HOME` (F19). The default refuses
164
+ * them: an unauthenticated route that can create directories anywhere the host
165
+ * process can write is a disk-fill primitive. Setting this to `1` restores the
166
+ * old permissive behaviour for operators who really keep backups on another
167
+ * volume.
168
+ */
169
+ const ALLOW_EXTERNAL_BACKUP_ENV = 'DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP'
170
+ /**
171
+ * How often the store file is stat-ed as a fallback for `fs.watch` (F19).
172
+ *
173
+ * Inotify/FSEvents/ReadDirectoryChangesW are reliable on a native filesystem
174
+ * and silently dead on DrvFs (`/mnt/c`, `/mnt/d`), SMB and some container
175
+ * overlays — there the watcher installs without error and then never fires, so
176
+ * an external edit to memory.json was simply invisible until a restart. A
177
+ * 5 s stat of one path costs nothing and turns that silence into a reload.
178
+ */
179
+ const WATCH_POLL_MS = 5000
151
180
  /**
152
181
  * Feedback mail: the optional config file next to the store. Kept OUT of
153
182
  * the repository on purpose — the operator's SMTP credentials must never be
@@ -175,13 +204,19 @@ const PLUGIN_VERSION = (() => {
175
204
  })()
176
205
 
177
206
  /**
178
- * Resolve this plugin's data file under the harness home.
207
+ * The harness home: the directory this plugin's data lives under.
179
208
  *
180
209
  * Precedence mirrors `@deepseek-ai/dsh-home-paths`: an explicit `$DSH_HOME`,
181
210
  * then `~/.dsh`. A blank override counts as unset and must never resolve to the
182
211
  * current working directory. Exported so tests can pin it.
212
+ *
213
+ * Resolved here rather than derived from the store path. `dirname(dirname(
214
+ * storePath))` happens to equal this today, but it is a guess about the layout:
215
+ * one extra directory level (or a store moved up a level) would quietly widen
216
+ * the "may I write outside the harness home" fence to the harness home's
217
+ * PARENT. A guard that guesses its own root is not a guard.
183
218
  */
184
- export function resolveStorePath(env = process.env, home = homedir()) {
219
+ export function resolveDshRoot(env = process.env, home = homedir()) {
185
220
  const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
186
221
  // A relative override (`DSH_HOME=./data`) is resolved ONCE against the
187
222
  // process cwd. Without this the store path stayed relative for the whole
@@ -190,8 +225,12 @@ export function resolveStorePath(env = process.env, home = homedir()) {
190
225
  // how the path was spelled. Resolving does not make a relative override
191
226
  // cwd-independent — it cannot be — but it pins one absolute location for
192
227
  // 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)
228
+ return configured !== '' ? resolve(configured) : join(home, '.dsh')
229
+ }
230
+
231
+ /** Resolve this plugin's data file under the harness home. */
232
+ export function resolveStorePath(env = process.env, home = homedir()) {
233
+ return join(resolveDshRoot(env, home), STORE_DIR_NAME, STORE_FILE_NAME)
195
234
  }
196
235
 
197
236
  /**
@@ -1163,7 +1202,51 @@ export function isWildcardBindHost(value) {
1163
1202
  * `Host: attacker.example` plus `Origin: attacker.example`. Wildcard binds keep
1164
1203
  * the old comparison, because the address the client used is unknowable then.
1165
1204
  */
1166
- export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
1205
+ /**
1206
+ * Parse the trusted-host list into canonical hostnames.
1207
+ *
1208
+ * Accepts what an operator actually types: a bare `app.example`, a full
1209
+ * origin (`https://app.example:8443`), bracketed IPv6, comma or whitespace
1210
+ * separated. Everything is folded to the same lowercase, bracket-free,
1211
+ * port-free form `hostnameOf()`/`normalizeHostname()` produce, so a trusted
1212
+ * entry compares equal to a parsed `Host` or `Origin` without further work.
1213
+ *
1214
+ * @param {string|string[]} value raw environment value or an array.
1215
+ * @returns {string[]} canonical hostnames, deduplicated, order preserved.
1216
+ */
1217
+ export function normalizeTrustedHosts(value) {
1218
+ const raw = Array.isArray(value) ? value.join(',') : typeof value === 'string' ? value : ''
1219
+ const out = []
1220
+ for (const piece of raw.split(',')) {
1221
+ let text = piece.trim()
1222
+ if (text === '') continue
1223
+ // A full origin is common in copy-pasted docs; keep only its host.
1224
+ const scheme = text.indexOf('://')
1225
+ if (scheme !== -1) {
1226
+ try {
1227
+ text = new URL(text).hostname
1228
+ } catch {
1229
+ text = text.slice(scheme + 3)
1230
+ }
1231
+ }
1232
+ text = text.replace(/^\/+/, '')
1233
+ const slash = text.indexOf('/')
1234
+ if (slash !== -1) text = text.slice(0, slash)
1235
+ // hostnameOf() already drops a port and the IPv6 brackets.
1236
+ const name = hostnameOf(text)
1237
+ if (name === '') continue
1238
+ if (!out.includes(name)) out.push(name)
1239
+ }
1240
+ return out
1241
+ }
1242
+
1243
+ /** Is this canonical hostname one the operator explicitly trusted? */
1244
+ function isTrustedHostName(name, trusted) {
1245
+ return name !== '' && Array.isArray(trusted) && trusted.includes(name)
1246
+ }
1247
+
1248
+ export function requestOriginVerdict(headers, guarded, expectedHostname = '', trustedHosts = []) {
1249
+ const trusted = Array.isArray(trustedHosts) ? trustedHosts : []
1167
1250
  const read = (name) => {
1168
1251
  if (headers === null || typeof headers !== 'object') return ''
1169
1252
  const value = headers[name]
@@ -1172,7 +1255,7 @@ export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
1172
1255
  const requestHost = read('host').trim().toLowerCase()
1173
1256
  const requestHostname = hostnameOf(requestHost)
1174
1257
 
1175
- if (guarded && !isLoopbackName(requestHostname)) {
1258
+ if (guarded && !isLoopbackName(requestHostname) && !isTrustedHostName(requestHostname, trusted)) {
1176
1259
  return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
1177
1260
  }
1178
1261
 
@@ -1213,7 +1296,7 @@ export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
1213
1296
  // is what keeps those spellings working (the mapped/hex forms canonicalise).
1214
1297
  const expected = !guarded && typeof expectedHostname === 'string' ? expectedHostname : ''
1215
1298
  const authority = expected !== '' ? expected : requestHostname
1216
- if (originHostname === '' || !sameHostname(originHostname, authority)) {
1299
+ if (originHostname === '' || (!sameHostname(originHostname, authority) && !isTrustedHostName(originHostname, trusted))) {
1217
1300
  return {
1218
1301
  ok: false,
1219
1302
  reason: expected !== ''
@@ -1688,6 +1771,13 @@ export function apply(ctx) {
1688
1771
  * temporal-dead-zone hazard (F12 publishes it as `security.hostGuarded`).
1689
1772
  */
1690
1773
  let guardHost = true
1774
+ /**
1775
+ * F19: hostnames the operator explicitly allowed (see TRUSTED_HOSTS_ENV).
1776
+ * Read once at mount from the environment; a restart is required to change it,
1777
+ * which keeps the allowlist out of the settings page (a route that can widen
1778
+ * its own trust boundary is not a boundary).
1779
+ */
1780
+ const trustedHosts = normalizeTrustedHosts(process.env[TRUSTED_HOSTS_ENV])
1691
1781
  /**
1692
1782
  * The authority a browser had to use to reach this route, when the bind
1693
1783
  * address is concrete (`192.168.1.5`, `[::1]`). Empty for a wildcard/unknown
@@ -1747,6 +1837,13 @@ export function apply(ctx) {
1747
1837
  let text = describeError(err)
1748
1838
  const storeDir = dirname(storePath)
1749
1839
  if (storeDir !== '' && storeDir !== '.') text = text.split(storeDir).join('<记忆目录>')
1840
+ // F19: a custom backup location is just as revealing (it is usually a
1841
+ // cloud-sync path with an account name in it) and used to survive the fold
1842
+ // whenever it was not under the home directory.
1843
+ const configuredBackup = typeof state.data.backupDir === 'string' ? state.data.backupDir.trim() : ''
1844
+ if (configuredBackup !== '' && configuredBackup !== storeDir) {
1845
+ text = text.split(configuredBackup).join('<备份目录>')
1846
+ }
1750
1847
  try {
1751
1848
  const home = homedir()
1752
1849
  // Same token as `displayPath` so a folded path looks identical wherever
@@ -1802,6 +1899,41 @@ export function apply(ctx) {
1802
1899
  else console.error('[instruction-memory] ' + message)
1803
1900
  }
1804
1901
 
1902
+ /**
1903
+ * Create (or repair) a directory this plugin owns and make sure it is 0700.
1904
+ *
1905
+ * `mkdir(..., {mode: 0o700})` only sets the mode on a directory it actually
1906
+ * creates: a store first written by 1.4.7 stays 0755 forever, because every
1907
+ * later start just resolves the path that already exists. The snapshots in
1908
+ * `history/` were therefore still listable by other local users after the
1909
+ * mode fix shipped, which the review called out as a real (if minor) gap.
1910
+ * The chmod is deliberately best effort: a directory owned by someone else,
1911
+ * or a filesystem without POSIX modes (Windows, some network mounts), must
1912
+ * not turn a save or a boot into a failure.
1913
+ */
1914
+ const repairDirMode = async (dir) => {
1915
+ if (process.platform === 'win32') return
1916
+ try {
1917
+ const info = await stat(dir)
1918
+ if ((info.mode & 0o777) === 0o700) return
1919
+ await chmod(dir, 0o700)
1920
+ logInfo('tightened ' + displayPath(dir) + ' to 0700 (was ' + (info.mode & 0o777).toString(8) + ')')
1921
+ } catch {
1922
+ // Not our directory, or no POSIX modes here: the write itself matters more.
1923
+ }
1924
+ }
1925
+
1926
+ /**
1927
+ * Create the directory if needed and make sure it ends up 0700. Boot-time
1928
+ * repair deliberately uses `repairDirMode` alone: a first run must not create
1929
+ * the store directory just to look at it (pinned by the "booting does not
1930
+ * even create the store directory" contract).
1931
+ */
1932
+ const hardenDir = async (dir) => {
1933
+ await mkdir(dir, { recursive: true, mode: 0o700 })
1934
+ await repairDirMode(dir)
1935
+ }
1936
+
1805
1937
  const logDebug = (message) => {
1806
1938
  if (logger !== null) {
1807
1939
  if (typeof logger.debug === 'function') logger.debug(message)
@@ -1849,7 +1981,14 @@ export function apply(ctx) {
1849
1981
  */
1850
1982
  const assembledSectionText = () => {
1851
1983
  sectionPulls += 1
1852
- logDebug('section pulled into a prompt (pull #' + sectionPulls + ')')
1984
+ // The host calls this while assembling the prompt and does NOT wrap it in
1985
+ // try/catch, so a throwing logger would abort the whole turn. Counting the
1986
+ // pull is diagnostics; it must never be the reason a prompt fails.
1987
+ try {
1988
+ logDebug('section pulled into a prompt (pull #' + sectionPulls + ')')
1989
+ } catch {
1990
+ // Diagnostics only.
1991
+ }
1853
1992
  return sectionText()
1854
1993
  }
1855
1994
 
@@ -1889,6 +2028,29 @@ export function apply(ctx) {
1889
2028
  * when something must be injected, absent otherwise. An empty memory must
1890
2029
  * leave the prompt byte-identical to a deployment without this plugin.
1891
2030
  */
2031
+ /**
2032
+ * Where the host wants this section to sit, asked of the host itself.
2033
+ *
2034
+ * `950` is the documented gap between FILE_REFERENCE (900) and TOOL_BASH
2035
+ * (1000) in `dsh-system-prompt`'s central `SECTION_ORDERS` table. Hardcoding
2036
+ * it works until that table grows an entry at 950 — then the placement is a
2037
+ * silent tie, decided by dictionary order, and nothing reports it. The
2038
+ * service exposes `getSectionOrder(name)`, so take the midpoint of the two
2039
+ * neighbours we actually anchor against and fall back to 950 if the method
2040
+ * is gone or answers something unusable.
2041
+ */
2042
+ const resolveSectionOrder = () => {
2043
+ try {
2044
+ if (systemPrompt === null || typeof systemPrompt.getSectionOrder !== 'function') return SECTION_ORDER
2045
+ const lo = systemPrompt.getSectionOrder('FILE_REFERENCE')
2046
+ const hi = systemPrompt.getSectionOrder('TOOL_BASH')
2047
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return SECTION_ORDER
2048
+ return lo + (hi - lo) / 2
2049
+ } catch {
2050
+ return SECTION_ORDER
2051
+ }
2052
+ }
2053
+
1892
2054
  const syncSection = () => {
1893
2055
  if (disposed) return
1894
2056
  const wanted = sectionText() !== ''
@@ -1902,15 +2064,16 @@ export function apply(ctx) {
1902
2064
  // template or any `{{…}}` pair — so the default would abort the whole
1903
2065
  // agent turn at renderPrompt(). This section is literal text and never
1904
2066
  // references a host variable, so opt out explicitly.
2067
+ const order = resolveSectionOrder()
1905
2068
  sectionDispose = systemPrompt.section({
1906
2069
  name: SECTION_NAME,
1907
- order: SECTION_ORDER,
2070
+ order,
1908
2071
  text: assembledSectionText,
1909
2072
  interpolate: false,
1910
2073
  })
1911
2074
  state.sectionRegistered = true
1912
2075
  state.sectionError = null
1913
- logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
2076
+ logDebug('prompt section registered: order=' + order + ' chars=' + sectionText().length)
1914
2077
  } catch (err) {
1915
2078
  sectionDispose = null
1916
2079
  state.sectionRegistered = false
@@ -1968,6 +2131,13 @@ export function apply(ctx) {
1968
2131
  const sseStreams = new Map()
1969
2132
  let storeWatcher = null
1970
2133
  let watchDebounce = null
2134
+ /**
2135
+ * Fallback poller for filesystems where `fs.watch` installs but never fires
2136
+ * (DrvFs `/mnt/*`, SMB/NFS, some overlays). Null when not running.
2137
+ */
2138
+ let storePoll = null
2139
+ /** Last `size:mtime:ino` signature the poller saw, so it only reacts to a change. */
2140
+ let storeSignature = ''
1971
2141
 
1972
2142
  const pushSnapshot = () => {
1973
2143
  if (disposed || sseStreams.size === 0) return
@@ -2042,11 +2212,51 @@ export function apply(ctx) {
2042
2212
  }, WATCH_DEBOUNCE_MS)
2043
2213
  }
2044
2214
 
2215
+ /** The stat signature a poller compares, or '' when the file is absent. */
2216
+ const currentStoreSignature = () => {
2217
+ try {
2218
+ const info = statSync(storePath)
2219
+ return String(info.size) + ':' + String(info.mtimeMs) + ':' + String(info.ino)
2220
+ } catch {
2221
+ return ''
2222
+ }
2223
+ }
2224
+
2225
+ /**
2226
+ * Start the fallback poller (idempotent).
2227
+ *
2228
+ * It is ALWAYS started alongside the watcher, not only after a failure: an
2229
+ * `fs.watch` that never fires cannot be distinguished from a quiet store, so
2230
+ * there is no event to trigger a downgrade. On a native filesystem the poller
2231
+ * finds the same signature the watcher just acted on and does nothing —
2232
+ * `onStoreFileEvent` already drops no-op reloads by comparing content.
2233
+ */
2234
+ const startPolling = () => {
2235
+ if (disposed || storePoll !== null) return
2236
+ storeSignature = currentStoreSignature()
2237
+ storePoll = setInterval(() => {
2238
+ if (disposed) return
2239
+ const signature = currentStoreSignature()
2240
+ if (signature === storeSignature) return
2241
+ storeSignature = signature
2242
+ // A vanished file is not an edit; the next save recreates it.
2243
+ if (signature === '') return
2244
+ onStoreFileEvent()
2245
+ }, WATCH_POLL_MS)
2246
+ // A background timer must never be a reason for the host process to stay
2247
+ // alive after the plugin is unmounted.
2248
+ if (typeof storePoll.unref === 'function') storePoll.unref()
2249
+ }
2250
+
2045
2251
  const stopWatching = () => {
2046
2252
  if (watchDebounce !== null) {
2047
2253
  clearTimeout(watchDebounce)
2048
2254
  watchDebounce = null
2049
2255
  }
2256
+ if (storePoll !== null) {
2257
+ clearInterval(storePoll)
2258
+ storePoll = null
2259
+ }
2050
2260
  if (storeWatcher !== null) {
2051
2261
  const watcher = storeWatcher
2052
2262
  storeWatcher = null
@@ -2075,10 +2285,20 @@ export function apply(ctx) {
2075
2285
  if (typeof filename === 'string' && filename !== basename(storePath)) return
2076
2286
  onStoreFileEvent()
2077
2287
  })
2078
- storeWatcher.on('error', () => stopWatching())
2288
+ storeWatcher.on('error', (err) => {
2289
+ // Losing the OS watcher is survivable (the poller below still notices
2290
+ // an external edit) but it must not happen silently: the downgrade is
2291
+ // invisible otherwise.
2292
+ logInfo('store watcher failed, falling back to the 5s poller: ' + describeError(err))
2293
+ stopWatching()
2294
+ })
2079
2295
  } catch {
2080
2296
  storeWatcher = null
2081
2297
  }
2298
+ // Started whether or not the watcher attached: on a filesystem where the
2299
+ // watch handle exists but never fires this is the ONLY reload signal, and
2300
+ // on a healthy one it is a cheap no-op.
2301
+ startPolling()
2082
2302
  }
2083
2303
 
2084
2304
  const disposeStreams = () => {
@@ -2117,6 +2337,64 @@ export function apply(ctx) {
2117
2337
  /* ---------------- backup location + history snapshots (F14 / F18) ---------------- */
2118
2338
 
2119
2339
  const backupDirOf = () => (typeof state.data.backupDir === 'string' ? state.data.backupDir : '')
2340
+ /** The harness home this plugin's data lives under (`$DSH_HOME` or `~/.dsh`). */
2341
+ const dshRootPath = () => resolveDshRoot()
2342
+ /**
2343
+ * F19: are we allowed to write outside `$DSH_HOME`?
2344
+ *
2345
+ * The settings route can be reached without a login on profiles with no
2346
+ * `connection` service, and `backupDir` used to accept ANY absolute path (it
2347
+ * was probe-written on set and then created on every save). That is a
2348
+ * disk-fill primitive, so the default is now "inside the harness home only";
2349
+ * an operator who really keeps backups on another volume opts in explicitly.
2350
+ */
2351
+ const allowExternalBackup = () => {
2352
+ const raw = process.env[ALLOW_EXTERNAL_BACKUP_ENV]
2353
+ return typeof raw === 'string' && raw.trim() === '1'
2354
+ }
2355
+ /** `path`'s real location, or null when it (or a parent) is unavailable. */
2356
+ const realPathOrNull = (path) => {
2357
+ try {
2358
+ return realpathSync(path)
2359
+ } catch {
2360
+ return null
2361
+ }
2362
+ }
2363
+ /** Is `inner` the same as `root` or below it? */
2364
+ const containsPath = (root, inner) => {
2365
+ const rel = relative(root, inner)
2366
+ return rel === '' || (!rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel))
2367
+ }
2368
+ /**
2369
+ * Is `target` inside `$DSH_HOME` (equal to it counts)?
2370
+ *
2371
+ * Spelling alone is not containment. A junction or symlink INSIDE the
2372
+ * harness home can point anywhere — on Windows a junction needs no privilege
2373
+ * at all — so the lexical check answers "does this text start with the root"
2374
+ * while the write lands in another volume. Both ends are therefore compared
2375
+ * by real path; a target that does not exist yet is judged by the real path
2376
+ * of its deepest existing ancestor plus the part still to be created.
2377
+ */
2378
+ const isInsideDshRoot = (target) => {
2379
+ if (typeof target !== 'string' || target === '') return false
2380
+ const root = dshRootPath()
2381
+ const absolute = resolve(target)
2382
+ if (!containsPath(root, absolute)) return false
2383
+ const realRoot = realPathOrNull(root)
2384
+ if (realRoot === null) return true
2385
+ let existing = absolute
2386
+ for (let depth = 0; depth < 64; depth += 1) {
2387
+ const realExisting = realPathOrNull(existing)
2388
+ if (realExisting !== null) {
2389
+ const rest = absolute.slice(existing.length)
2390
+ return containsPath(realRoot, rest === '' ? realExisting : join(realExisting, rest))
2391
+ }
2392
+ const parent = dirname(existing)
2393
+ if (parent === existing) return false
2394
+ existing = parent
2395
+ }
2396
+ return false
2397
+ }
2120
2398
  /** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
2121
2399
  const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
2122
2400
  /** The user-facing copy under a custom backup location, or null (F18). */
@@ -2339,7 +2617,7 @@ export function apply(ctx) {
2339
2617
  if (typeof text !== 'string' || text === '' || identity === lastHistoryKey) return
2340
2618
  try {
2341
2619
  const dir = historyDirPath()
2342
- await mkdir(dir, { recursive: true })
2620
+ await hardenDir(dir)
2343
2621
  const epoch = Date.now()
2344
2622
  let written = false
2345
2623
  // `wx` never overwrites: same-millisecond saves (two rapid bulk actions)
@@ -2413,7 +2691,7 @@ export function apply(ctx) {
2413
2691
  let lock = null
2414
2692
  let previousRev = null
2415
2693
  try {
2416
- await mkdir(dirname(storePath), { recursive: true })
2694
+ await hardenDir(dirname(storePath))
2417
2695
  lock = await acquireStoreLock()
2418
2696
  if (lock === null) {
2419
2697
  // The lock could not be taken within the wait. That is either a stale
@@ -2845,6 +3123,11 @@ export function apply(ctx) {
2845
3123
  })
2846
3124
  .then(async () => {
2847
3125
  bootSettled = true
3126
+ // Upgrade path: directories created by an older version keep their old
3127
+ // mode because `mkdir` never re-applies it to an existing path. Repair
3128
+ // them on every start; a failure here is a warning, never a boot error.
3129
+ await repairDirMode(dirname(storePath))
3130
+ await repairDirMode(historyDirPath())
2848
3131
  syncSection()
2849
3132
  await refreshHistoryCount()
2850
3133
  // The one always-on line: it answers "is memory mounted, where does it
@@ -2932,6 +3215,14 @@ export function apply(ctx) {
2932
3215
  profiles: state.data.profiles.filter((profile) => belongsTo(profile, set.id)).length,
2933
3216
  })),
2934
3217
  },
3218
+ /**
3219
+ * The running plugin version. The panel renders it in the mount-failure
3220
+ * notice, which is the one place a user reads a number off the screen
3221
+ * when the settings page never appears — reading it from the store would
3222
+ * be wrong (the store survives upgrades) and the client cannot import
3223
+ * package.json.
3224
+ */
3225
+ pluginVersion: PLUGIN_VERSION,
2935
3226
  /**
2936
3227
  * F12: the one security signal the panel was missing. `hostGuarded` is
2937
3228
  * the SAME flag the route enforces on every request — false means the
@@ -2943,6 +3234,15 @@ export function apply(ctx) {
2943
3234
  security: {
2944
3235
  hostGuarded: guardHost === true,
2945
3236
  boundHost: state.boundHost,
3237
+ /**
3238
+ * F19 follow-up: whether the harness authentication fence is attached
3239
+ * RIGHT NOW. `hostGuarded === false` on its own reads as "no
3240
+ * authentication at all", which is only true when this is false too:
3241
+ * with the fence attached the route still requires a logged-in browser
3242
+ * session (`connection.requestRejection`), and the panel must be able
3243
+ * to say which of the two situations it is looking at.
3244
+ */
3245
+ authFence: connectionGate !== null,
2946
3246
  },
2947
3247
  /**
2948
3248
  * F12: load-side diagnostics that used to be log-only. `loadedFrom` says
@@ -3280,6 +3580,18 @@ export function apply(ctx) {
3280
3580
  snapshot: snapshot(),
3281
3581
  }
3282
3582
  }
3583
+ // F19: an unauthenticated caller must not be able to create
3584
+ // directories outside the harness home. Checked BEFORE the probe, so a
3585
+ // refused path is never touched at all.
3586
+ if (!allowExternalBackup() && !isInsideDshRoot(wanted)) {
3587
+ return {
3588
+ ok: false,
3589
+ saved: false,
3590
+ message: '备份位置必须位于 DSH 主目录内(' + dshRootPath() + ');要使用其他磁盘请设置环境变量 '
3591
+ + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
3592
+ snapshot: snapshot(),
3593
+ }
3594
+ }
3283
3595
  const problem = await probeBackupDir(wanted)
3284
3596
  if (problem !== null) {
3285
3597
  return { ok: false, saved: false, message: '备份位置不可用(' + problem + '):' + wanted, snapshot: snapshot() }
@@ -3868,6 +4180,11 @@ export function apply(ctx) {
3868
4180
  snapshot: snapshot(),
3869
4181
  }))
3870
4182
 
4183
+ /** F19: timestamp of the last export-to-dir, for the frequency guard. */
4184
+ let lastExportAt = 0
4185
+ /** F19: minimum spacing between export-to-dir writes. */
4186
+ const EXPORT_COOLDOWN_MS = 2000
4187
+
3871
4188
  /**
3872
4189
  * Write an export straight into the backup directory (F18) — the "land the
3873
4190
  * backup in my cloud-synced folder" button. The default directory is the
@@ -3875,9 +4192,34 @@ export function apply(ctx) {
3875
4192
  */
3876
4193
  const exportToDir = () => enqueue(async () => {
3877
4194
  const dir = backupDirOf() !== '' ? backupDirOf() : dirname(storePath)
3878
- const target = join(dir, exportFileName(Date.now()))
4195
+ // F19: the same subtree rule the setting enforces, re-checked at write
4196
+ // time — a store hand-edited to point elsewhere must not become a way to
4197
+ // create arbitrary directories through this route either.
4198
+ if (!allowExternalBackup() && !isInsideDshRoot(dir)) {
4199
+ return {
4200
+ ok: false,
4201
+ saved: false,
4202
+ message: '导出目录必须位于 DSH 主目录内(' + dshRootPath() + ');要写入其他磁盘请设置环境变量 '
4203
+ + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
4204
+ snapshot: snapshot(),
4205
+ }
4206
+ }
4207
+ // Each export writes a fixed-name JSON file of the whole store. Refusing a
4208
+ // second export inside the cooldown keeps a script from using the button
4209
+ // as a disk-fill loop.
4210
+ const now = Date.now()
4211
+ if (now - lastExportAt < EXPORT_COOLDOWN_MS) {
4212
+ return {
4213
+ ok: false,
4214
+ saved: false,
4215
+ message: '导出过于频繁,请 ' + Math.ceil((EXPORT_COOLDOWN_MS - (now - lastExportAt)) / 1000) + ' 秒后重试',
4216
+ snapshot: snapshot(),
4217
+ }
4218
+ }
4219
+ lastExportAt = now
4220
+ const target = join(dir, exportFileName(now))
3879
4221
  try {
3880
- await mkdir(dir, { recursive: true })
4222
+ await hardenDir(dir)
3881
4223
  await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
3882
4224
  } catch (err) {
3883
4225
  return { ok: false, saved: false, message: '写入备份目录失败:' + describeErrorSafe(err), snapshot: snapshot() }
@@ -4287,6 +4629,16 @@ export function apply(ctx) {
4287
4629
  if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
4288
4630
  throw new Error('无法读取反馈配置文件 ' + path + ':' + describeErrorSafe(err))
4289
4631
  }
4632
+ // This file can hold SMTP credentials. The plugin does not create it (the
4633
+ // operator does), so it cannot set its mode — it can only say something
4634
+ // when the mode is wrong, which is the part that used to be silent.
4635
+ if (process.platform !== 'win32') {
4636
+ const info = await stat(path).catch(() => null)
4637
+ if (info !== null && (info.mode & 0o077) !== 0) {
4638
+ logInfo('feedback config ' + displayPath(path) + ' is readable by group/others (mode '
4639
+ + (info.mode & 0o777).toString(8) + '); it may hold SMTP credentials')
4640
+ }
4641
+ }
4290
4642
  try {
4291
4643
  return JSON.parse(text)
4292
4644
  } catch (err) {
@@ -4504,6 +4856,21 @@ export function apply(ctx) {
4504
4856
  // `security.hostGuarded`); only `routeDispose` lives here.
4505
4857
 
4506
4858
  let routeDispose = null
4859
+ /**
4860
+ * F19: the composition's `connection` service, when the profile has one.
4861
+ *
4862
+ * It is the harness's single authentication fence: `requestRejection(req)`
4863
+ * applies the Host/Origin trust check AND requires the browser login cookie
4864
+ * (the same gate that makes `/api` answer 401 to an anonymous caller). This
4865
+ * route used to rely on the Origin guesswork below alone, which left it as
4866
+ * the only unauthenticated read/write endpoint on the instance — a stored
4867
+ * prompt-injection primitive for any local process.
4868
+ *
4869
+ * Read through a NON-BLOCKING child fiber for the same reason `webServer` is
4870
+ * (headless/tui profiles must still inject the prompt), and kept nullable:
4871
+ * when the service is absent the loopback-only rule below still applies.
4872
+ */
4873
+ let connectionGate = null
4507
4874
 
4508
4875
  /**
4509
4876
  * Does this stream subscriber want lightweight frames?
@@ -4543,13 +4910,11 @@ export function apply(ctx) {
4543
4910
  * `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
4544
4911
  */
4545
4912
  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
- }
4913
+ // Same fence as the POST path: an EventSource carries the login cookie, so
4914
+ // an authenticated stream keeps working while an anonymous one is refused.
4915
+ const fence = rejectUntrustedCaller(req, res)
4916
+ if (fence === 'rejected') return
4917
+ if (originVerdictRefuses(req, res, fence)) return
4553
4918
  // Every subscriber holds a Map entry, a `req.on('close')` listener and a
4554
4919
  // live socket until it disconnects. A tab that is duplicated, a client that
4555
4920
  // never closes, or a script that opens streams in a loop would otherwise
@@ -4609,7 +4974,94 @@ export function apply(ctx) {
4609
4974
  }
4610
4975
  }
4611
4976
 
4977
+ /**
4978
+ * F19: refuse a caller the harness has not authenticated.
4979
+ *
4980
+ * Order matters. The harness fence runs FIRST when it exists: it is the only
4981
+ * check that knows about the browser login cookie, and it also owns the
4982
+ * Host/Origin trust rules for every other route on the instance. The
4983
+ * hand-rolled Origin verdict below stays as the second line of defence (it
4984
+ * covers profiles that publish a web server without a `connection` service).
4985
+ *
4986
+ * When there is no fence at all, a NON-loopback bind fails closed: publishing
4987
+ * an unauthenticated endpoint that can rewrite the instructions injected into
4988
+ * every later turn is not a trade-off worth defaulting to. The loopback bind
4989
+ * keeps working, which is the documented deployment (and what the Android
4990
+ * build's route-guard patch assumes).
4991
+ *
4992
+ * @returns 'rejected' when this handler already answered the request (401,
4993
+ * 403 or 503 and the response is complete), 'gate' when the harness fence
4994
+ * accepted it (so the Host/Origin question is already settled and the
4995
+ * plugin's own Origin verdict must NOT run again), 'open' when there is no
4996
+ * fence and the caller still has to pass the local Origin rules.
4997
+ */
4998
+ const rejectUntrustedCaller = (req, res) => {
4999
+ if (connectionGate !== null) {
5000
+ let rejection
5001
+ try {
5002
+ rejection = connectionGate.requestRejection(req)
5003
+ } catch (err) {
5004
+ // A throwing fence must not become an open door: treat it as 401.
5005
+ logInfo('connection fence threw, refusing the request: ' + describeError(err))
5006
+ rejection = 401
5007
+ }
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' })
5011
+ res.end(JSON.stringify({
5012
+ ok: false,
5013
+ message: rejection === 403
5014
+ ? '请求未被宿主信任(Host/Origin 校验失败)'
5015
+ : '需要登录 DSH 后的浏览器会话才能访问该路由',
5016
+ }))
5017
+ return 'rejected'
5018
+ }
5019
+ return 'gate'
5020
+ }
5021
+ if (!guardHost) {
5022
+ logInfo('route refused a request: non-loopback bind without an authentication fence')
5023
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5024
+ res.end(JSON.stringify({
5025
+ ok: false,
5026
+ message: '该路由在非回环绑定下需要宿主的 connection 服务提供鉴权;当前 profile 没有该服务,已拒绝请求',
5027
+ }))
5028
+ return 'rejected'
5029
+ }
5030
+ return 'open'
5031
+ }
5032
+
5033
+ /**
5034
+ * Run the plugin's OWN Origin/Host verdict, but only when the harness fence
5035
+ * did not already answer the same question (F19 follow-up).
5036
+ *
5037
+ * Order: the fence runs first. When it accepts a request it has already
5038
+ * applied the host's Host / `Origin` / `Sec-Fetch-Site` rules AND verified
5039
+ * the browser login cookie (`dsh-client-connection`: `isTrustedApiRequest`
5040
+ * then `browserAuth`). It also knows the deployment's public name. This
5041
+ * plugin knows only its bind address, so re-judging `Origin` here produced a
5042
+ * false 403 for every legitimate reverse-proxy client: the proxy forwards
5043
+ * `Host: dsh.example.com`, the listener binds `192.168.1.5`, and the Origin
5044
+ * host could never equal the bind IP. The local verdict stays as the second
5045
+ * line of defence for profiles that publish a web server WITHOUT a
5046
+ * `connection` service — there it is the only fence there is.
5047
+ *
5048
+ * @returns true when the request was answered with 403 and must not continue.
5049
+ */
5050
+ const originVerdictRefuses = (req, res, fence) => {
5051
+ if (fence === 'gate') return false
5052
+ const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname, trustedHosts)
5053
+ if (verdict.ok) return false
5054
+ logInfo('route rejected a request: ' + verdict.reason)
5055
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5056
+ res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
5057
+ return true
5058
+ }
5059
+
4612
5060
  const handler = (req, res) => {
5061
+ // Authentication before anything else: not the method, not the body, not
5062
+ // the size. A rejection must not leak which methods exist.
5063
+ const fence = rejectUntrustedCaller(req, res)
5064
+ if (fence === 'rejected') return
4613
5065
  if (req.method === 'GET') {
4614
5066
  const accept = String(
4615
5067
  (req.headers !== null && typeof req.headers === 'object') ? (req.headers.accept || '') : '',
@@ -4649,16 +5101,9 @@ export function apply(ctx) {
4649
5101
  return
4650
5102
  }
4651
5103
  }
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
- }
5104
+ // Origin/Host guard, before anything else is read. Skipped when the host
5105
+ // fence already accepted this request — see originVerdictRefuses().
5106
+ if (originVerdictRefuses(req, res, fence)) return
4662
5107
  // The settings page always posts application/json. Enforcing it turns
4663
5108
  // the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
4664
5109
  // body) into a hard rule: a cross-site or malformed post now gets a
@@ -4736,6 +5181,30 @@ export function apply(ctx) {
4736
5181
  // losing the prompt injection along with the settings page. The callback also
4737
5182
  // fixes the original race properly — it runs whenever the service is
4738
5183
  // published, not only if it happened to beat this row to the mount.
5184
+ // F19: take the harness authentication fence when the profile has one.
5185
+ // Same non-blocking shape as the web server below — a profile without a
5186
+ // browser never publishes `connection`, and the prompt injection must not
5187
+ // wait for one.
5188
+ ctx.inject(['connection'], (scope) => {
5189
+ const service = scope.connection
5190
+ if (service === undefined || service === null || typeof service.requestRejection !== 'function') {
5191
+ logDebug('no connection service; the route falls back to the loopback-only rule')
5192
+ return
5193
+ }
5194
+ connectionGate = service
5195
+ logDebug('connection fence attached; the route now requires an authenticated browser session')
5196
+ scope.effect(() => () => {
5197
+ if (connectionGate !== service) return
5198
+ connectionGate = null
5199
+ // A host that republishes (or disposes) the service would otherwise let
5200
+ // the route fall back to the loopback-only rule with nothing said about
5201
+ // it. That is not an open door on a loopback bind, but it IS a silent
5202
+ // authentication downgrade, and `snapshot.security.authFence` now shows
5203
+ // it for as long as it lasts.
5204
+ logInfo('connection fence detached while the plugin stayed mounted; the route falls back to the loopback-only rule until the service is published again')
5205
+ })
5206
+ })
5207
+
4739
5208
  ctx.inject(['webServer'], (scope) => {
4740
5209
  const webServer = scope.webServer
4741
5210
  if (webServer === undefined || typeof webServer.register !== 'function') {