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/CHANGELOG.md +154 -0
- package/README.md +117 -19
- package/install.mjs +79 -10
- package/lib/client.js +269 -26
- package/lib/index.js +856 -86
- package/lib/mailer.js +50 -9
- package/package.json +1 -1
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
|
-
*
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
/**
|
|
336
|
-
|
|
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
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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=' +
|
|
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', () =>
|
|
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
|
-
//
|
|
2185
|
-
//
|
|
2186
|
-
//
|
|
2187
|
-
//
|
|
2188
|
-
//
|
|
2189
|
-
|
|
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
|
|
2271
|
-
* planHistoryPrune(). Grouping by the device tag
|
|
2272
|
-
* deliberate: two machines sharing one backup folder
|
|
2273
|
-
* setup) each keep their OWN budget, so
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
2752
|
-
if (
|
|
2753
|
-
//
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
}
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
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) => ({
|
|
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
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
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.
|
|
4653
|
-
//
|
|
4654
|
-
|
|
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') {
|