dsh-instruction-memory 1.4.2 → 1.4.4
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 +88 -0
- package/README.md +26 -7
- package/install.mjs +186 -0
- package/lib/client.js +10 -1
- package/lib/index.js +598 -94
- package/package.json +8 -4
package/lib/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import { readFileSync, 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 } from 'node:path'
|
|
33
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
34
34
|
import { fileURLToPath } from 'node:url'
|
|
35
35
|
import {
|
|
36
36
|
FEEDBACK_ENV,
|
|
@@ -108,6 +108,15 @@ const MAX_HISTORY_BYTES = 32 * 1024 * 1024
|
|
|
108
108
|
* overflows Node's timer range.
|
|
109
109
|
*/
|
|
110
110
|
const MAX_MUTE_MS = 7 * 24 * 60 * 60 * 1000
|
|
111
|
+
/**
|
|
112
|
+
* The most event streams one host will hold open at once.
|
|
113
|
+
*
|
|
114
|
+
* Each open stream keeps a Map entry, a socket and listeners until the client
|
|
115
|
+
* disconnects; the panel opens one per window and browsers retry on their own,
|
|
116
|
+
* so a handful is normal. The ceiling exists so a misbehaving client (or a
|
|
117
|
+
* duplicated tab left open for weeks) cannot grow the set without bound.
|
|
118
|
+
*/
|
|
119
|
+
const MAX_SSE_STREAMS = 32
|
|
111
120
|
const HISTORY_DIR_NAME = 'history'
|
|
112
121
|
/** Name of the JSON Schema shipped with the package and copied next to the store. */
|
|
113
122
|
const SCHEMA_FILE_NAME = 'memory.schema.json'
|
|
@@ -185,6 +194,32 @@ export function resolveStorePath(env = process.env, home = homedir()) {
|
|
|
185
194
|
return join(root, STORE_DIR_NAME, STORE_FILE_NAME)
|
|
186
195
|
}
|
|
187
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Fold the user's home directory to `~` for every path published in the
|
|
199
|
+
* snapshot. The settings page only needs to show WHICH file is in use; the
|
|
200
|
+
* absolute path adds nothing there but does put the account name on screen
|
|
201
|
+
* (and into any screenshot or support paste). The real paths stay untouched
|
|
202
|
+
* everywhere else — writes, backups and the watcher all use the absolute form.
|
|
203
|
+
*/
|
|
204
|
+
export function displayPath(value, home = homedir()) {
|
|
205
|
+
if (typeof value !== 'string' || value === '') return value
|
|
206
|
+
const base = home.endsWith(sep) ? home.slice(0, -sep.length) : home
|
|
207
|
+
if (base === '') return value
|
|
208
|
+
if (value === base) return '~'
|
|
209
|
+
if (value.startsWith(base + sep)) return '~' + value.slice(base.length)
|
|
210
|
+
return value
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Error code for "the store file exists but holds nothing usable".
|
|
215
|
+
*
|
|
216
|
+
* It is a distinct code (not a SyntaxError) because the recovery path needs to
|
|
217
|
+
* tell the three states apart: file missing (first run), file present but
|
|
218
|
+
* unparsable, file present and EMPTY. Only the last two are recoverable from
|
|
219
|
+
* .bak — and only the last two may never be treated as "no memory yet".
|
|
220
|
+
*/
|
|
221
|
+
const EMPTY_STORE_CODE = 'EEMPTYSTORE'
|
|
222
|
+
|
|
188
223
|
const DEFAULT_BUDGET = 4000
|
|
189
224
|
const MIN_BUDGET = 800
|
|
190
225
|
const MAX_BUDGET = 40000
|
|
@@ -201,12 +236,19 @@ const MAX_TAG_LENGTH = 24
|
|
|
201
236
|
* preferences, personality, goals) used to tilt how answers are framed. They
|
|
202
237
|
* are NOT roles the model is asked to play, and they are injected ahead of the
|
|
203
238
|
* instruction block under a header that demotes them to "tilt only, never
|
|
204
|
-
* override correctness or the question itself".
|
|
205
|
-
*
|
|
239
|
+
* override correctness or the question itself".
|
|
240
|
+
*
|
|
241
|
+
* They are deliberately kept OUT of the per-entry priority trimming — an
|
|
242
|
+
* identity must never be silently dropped for a low-priority instruction — but
|
|
243
|
+
* they are NOT exempt from the injection ceiling: MAX_PROFILE_INJECT_CHARS caps
|
|
244
|
+
* the whole persona section (and planInjection gives it at most the remaining
|
|
245
|
+
* share of budgetChars), so the number the panel calls 注入字数上限 applies to
|
|
246
|
+
* everything that reaches the prompt.
|
|
206
247
|
*/
|
|
207
248
|
const MAX_PROFILES = 50
|
|
208
249
|
const MAX_PROFILE_NAME = 40
|
|
209
250
|
const MAX_PROFILE_CONTENT = 4000
|
|
251
|
+
const MAX_PROFILE_INJECT_CHARS = 4000
|
|
210
252
|
|
|
211
253
|
/**
|
|
212
254
|
* The batch operations the `batch` route accepts. Kept here so the command and
|
|
@@ -278,10 +320,32 @@ function clampBudget(value) {
|
|
|
278
320
|
return n
|
|
279
321
|
}
|
|
280
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Cut a string to `max` UTF-16 units without ever ending on half a surrogate
|
|
325
|
+
* pair.
|
|
326
|
+
*
|
|
327
|
+
* `String.prototype.slice` counts UTF-16 units, so cutting at `max` can land
|
|
328
|
+
* between the high and low half of an emoji or a CJK ext-B character. The
|
|
329
|
+
* orphaned high surrogate is then written to memory.json verbatim (JSON
|
|
330
|
+
* escapes it as \ud83d), renders as U+FFFD in the panel, and is injected into
|
|
331
|
+
* every later prompt. The injection path already backed off one unit; this is
|
|
332
|
+
* the same rule, in ONE place, for every other caller (storage sanitising,
|
|
333
|
+
* single-line titles, history keys).
|
|
334
|
+
*/
|
|
335
|
+
export function cutText(value, max) {
|
|
336
|
+
if (typeof value !== 'string') return ''
|
|
337
|
+
if (!Number.isFinite(max) || max <= 0) return ''
|
|
338
|
+
if (value.length <= max) return value
|
|
339
|
+
let head = value.slice(0, max)
|
|
340
|
+
const last = head.charCodeAt(head.length - 1)
|
|
341
|
+
if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1)
|
|
342
|
+
return head
|
|
343
|
+
}
|
|
344
|
+
|
|
281
345
|
function cleanText(value, max) {
|
|
282
346
|
if (typeof value !== 'string') return ''
|
|
283
347
|
const trimmed = value.split('\r\n').join('\n').split('\r').join('\n').trim()
|
|
284
|
-
return trimmed
|
|
348
|
+
return cutText(trimmed, max)
|
|
285
349
|
}
|
|
286
350
|
|
|
287
351
|
/**
|
|
@@ -907,14 +971,20 @@ export function hostnameOf(value) {
|
|
|
907
971
|
if (typeof value !== 'string') return ''
|
|
908
972
|
const trimmed = value.trim().toLowerCase()
|
|
909
973
|
if (trimmed === '') return ''
|
|
974
|
+
// A single trailing dot is the FQDN spelling of the SAME name (`localhost.`,
|
|
975
|
+
// `127.0.0.1.`). Browsers and some proxies emit it, and leaving it in made
|
|
976
|
+
// the loopback check fail closed — the panel 403'd against its own host. The
|
|
977
|
+
// dot is not a bypass either way (the guard is an allowlist), so normalising
|
|
978
|
+
// it is strictly a compatibility fix.
|
|
979
|
+
const stripTrailingDot = (name) => (name.endsWith('.') ? name.slice(0, -1) : name)
|
|
910
980
|
if (trimmed.startsWith('[')) {
|
|
911
981
|
const end = trimmed.indexOf(']')
|
|
912
|
-
return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
|
|
982
|
+
return stripTrailingDot(end === -1 ? trimmed.slice(1) : trimmed.slice(1, end))
|
|
913
983
|
}
|
|
914
984
|
// Unbracketed IPv6 literal: the colons are part of the address, not a port.
|
|
915
|
-
if (trimmed.indexOf(':') !== trimmed.lastIndexOf(':')) return trimmed
|
|
985
|
+
if (trimmed.indexOf(':') !== trimmed.lastIndexOf(':')) return stripTrailingDot(trimmed)
|
|
916
986
|
const colon = trimmed.lastIndexOf(':')
|
|
917
|
-
return colon === -1 ? trimmed : trimmed.slice(0, colon)
|
|
987
|
+
return stripTrailingDot(colon === -1 ? trimmed : trimmed.slice(0, colon))
|
|
918
988
|
}
|
|
919
989
|
|
|
920
990
|
/**
|
|
@@ -1029,11 +1099,18 @@ const isLoopbackName = (name) => {
|
|
|
1029
1099
|
* and pre-2020 browsers, which is exactly why it can only ADD rejections, never
|
|
1030
1100
|
* replace the checks above): a modern browser stamps every request with it, so
|
|
1031
1101
|
* a page at evil.com talking to this route — even through tricks that drop the
|
|
1032
|
-
* `Origin` header — still says `cross-site`.
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1102
|
+
* `Origin` header — still says `cross-site`.
|
|
1103
|
+
*
|
|
1104
|
+
* `same-site` is refused too, but ONLY on a guarded (loopback-bound) server.
|
|
1105
|
+
* Schemeful same-site ignores the port, so `same-site` is also what a page at
|
|
1106
|
+
* 127.0.0.1:5555 — any other local app, or a rebinding page that landed on a
|
|
1107
|
+
* different port — sends when it talks to this route. The hostname comparison
|
|
1108
|
+
* below cannot see that difference (it deliberately ignores ports), so
|
|
1109
|
+
* `same-site` used to sail straight through and let any local origin rewrite
|
|
1110
|
+
* long-lived instructions. On loopback the GUI and the route are always the
|
|
1111
|
+
* same origin, so nothing legitimate is lost. On a non-loopback bind the server
|
|
1112
|
+
* may sit behind a proxy that legitimately rewrites ports, so the same-site
|
|
1113
|
+
* rule is left off there and the README documents the trade-off.
|
|
1037
1114
|
*/
|
|
1038
1115
|
export function requestOriginVerdict(headers, guarded) {
|
|
1039
1116
|
const read = (name) => {
|
|
@@ -1052,15 +1129,21 @@ export function requestOriginVerdict(headers, guarded) {
|
|
|
1052
1129
|
if (fetchSite === 'cross-site') {
|
|
1053
1130
|
return { ok: false, reason: '请求被浏览器标记为跨站(Sec-Fetch-Site: cross-site)' }
|
|
1054
1131
|
}
|
|
1132
|
+
if (guarded && fetchSite === 'same-site') {
|
|
1133
|
+
return { ok: false, reason: '请求来自同一站点但不同源(Sec-Fetch-Site: same-site),回环服务只接受同源请求' }
|
|
1134
|
+
}
|
|
1055
1135
|
|
|
1056
1136
|
const origin = read('origin').trim()
|
|
1057
1137
|
if (origin !== '') {
|
|
1058
1138
|
let originHostname = ''
|
|
1139
|
+
let originPort = null
|
|
1059
1140
|
try {
|
|
1060
1141
|
// URL.hostname keeps the brackets on an IPv6 literal; normalizeHostname
|
|
1061
1142
|
// takes them off so it can meet the bracket-free Host parsing on equal
|
|
1062
1143
|
// terms.
|
|
1063
|
-
|
|
1144
|
+
const parsed = new URL(origin)
|
|
1145
|
+
originHostname = normalizeHostname(parsed.hostname.toLowerCase())
|
|
1146
|
+
originPort = parsed.port
|
|
1064
1147
|
} catch {
|
|
1065
1148
|
originHostname = ''
|
|
1066
1149
|
}
|
|
@@ -1072,11 +1155,63 @@ export function requestOriginVerdict(headers, guarded) {
|
|
|
1072
1155
|
if (originHostname === '' || !sameHostname(originHostname, requestHostname)) {
|
|
1073
1156
|
return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
|
|
1074
1157
|
}
|
|
1158
|
+
// On loopback the port IS part of the origin, and a different local port is
|
|
1159
|
+
// a different application: refuse it when both sides state one. Skipped on
|
|
1160
|
+
// a non-loopback bind, where a proxy may legitimately translate the port,
|
|
1161
|
+
// and skipped when the Host states none — an absent port is unknown, not
|
|
1162
|
+
// port 80, and refusing on that guess would break a loopback proxy that
|
|
1163
|
+
// strips it. Default ports compare equal to no port (URL.port omits them).
|
|
1164
|
+
const originPortValue = originPort === '80' || originPort === '443' ? '' : originPort
|
|
1165
|
+
const hostPortValue = requestPortOf(requestHost)
|
|
1166
|
+
if (guarded && originPortValue !== null && originPortValue !== ''
|
|
1167
|
+
&& hostPortValue !== '' && originPortValue !== hostPortValue) {
|
|
1168
|
+
return { ok: false, reason: 'Origin 端口与 Host 端口不一致:' + origin }
|
|
1169
|
+
}
|
|
1075
1170
|
}
|
|
1076
1171
|
|
|
1077
1172
|
return { ok: true, reason: null }
|
|
1078
1173
|
}
|
|
1079
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* The media type of a Content-Type header, lowercased, without parameters.
|
|
1177
|
+
*
|
|
1178
|
+
* `text/plain; note=application/json` has media type `text/plain`; a substring
|
|
1179
|
+
* test would have called it JSON.
|
|
1180
|
+
*
|
|
1181
|
+
* Node joins repeated headers with `, `, so a gateway that forwards its own
|
|
1182
|
+
* `Content-Type` alongside the client's produces
|
|
1183
|
+
* `application/json, text/plain`. The first value is the effective one, and
|
|
1184
|
+
* taking it keeps a duplicated header from being refused as a wrong type.
|
|
1185
|
+
*/
|
|
1186
|
+
function mediaTypeOf(headerValue) {
|
|
1187
|
+
if (typeof headerValue !== 'string') return ''
|
|
1188
|
+
const first = headerValue.indexOf(',')
|
|
1189
|
+
const withoutList = first === -1 ? headerValue : headerValue.slice(0, first)
|
|
1190
|
+
const semicolon = withoutList.indexOf(';')
|
|
1191
|
+
const mediaType = semicolon === -1 ? withoutList : withoutList.slice(0, semicolon)
|
|
1192
|
+
return mediaType.trim().toLowerCase()
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* The port stated by a `Host` header, or '' when none is (which is also what
|
|
1197
|
+
* URL.port reports for a default port, so the two compare equal).
|
|
1198
|
+
*/
|
|
1199
|
+
function requestPortOf(hostHeader) {
|
|
1200
|
+
const value = typeof hostHeader === 'string' ? hostHeader.trim() : ''
|
|
1201
|
+
const closing = value.lastIndexOf(']')
|
|
1202
|
+
const rest = closing === -1 ? value : value.slice(closing + 1)
|
|
1203
|
+
// A bare IPv6 literal (`::1`) carries no brackets and several colons: its
|
|
1204
|
+
// last colon belongs to the address, not to a port. RFC 7230 requires the
|
|
1205
|
+
// brackets, but this tolerant reader must not invent port "1" out of `::1`
|
|
1206
|
+
// and then refuse an Origin that states no port at all.
|
|
1207
|
+
if (closing === -1 && (rest.match(/:/g) || []).length > 1) return ''
|
|
1208
|
+
const index = rest.lastIndexOf(':')
|
|
1209
|
+
const port = index === -1 ? '' : rest.slice(index + 1).trim()
|
|
1210
|
+
// URL.port is '' for a scheme's default port, so `Host: x:80` must compare
|
|
1211
|
+
// equal to `Origin: http://x`.
|
|
1212
|
+
return port === '80' || port === '443' ? '' : port
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1080
1215
|
function formatEntry(entry, tag) {
|
|
1081
1216
|
const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
|
|
1082
1217
|
if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
|
|
@@ -1131,26 +1266,86 @@ const PERSONA_HEADER = [
|
|
|
1131
1266
|
* no timestamps, no counts, identical profiles in produce byte-identical
|
|
1132
1267
|
* text. An empty/disabled set renders to '' so callers can join unconditionally.
|
|
1133
1268
|
*/
|
|
1134
|
-
function buildProfilesBlock(profiles) {
|
|
1269
|
+
function buildProfilesBlock(profiles, budget) {
|
|
1135
1270
|
const active = (Array.isArray(profiles) ? profiles : [])
|
|
1136
1271
|
.filter((profile) => profile !== null && typeof profile === 'object'
|
|
1137
1272
|
&& profile.enabled === true
|
|
1138
1273
|
&& typeof profile.content === 'string' && profile.content !== '')
|
|
1139
1274
|
if (active.length === 0) return { text: '', profiles: [], chars: 0 }
|
|
1275
|
+
/**
|
|
1276
|
+
* The persona section is BOUNDED, and it is bounded by the same number the
|
|
1277
|
+
* panel calls 注入字数上限.
|
|
1278
|
+
*
|
|
1279
|
+
* It used to be unbounded: `content` is sanitised at 4000 chars per profile
|
|
1280
|
+
* but nothing capped how many profiles, or their total, could reach the
|
|
1281
|
+
* prompt. A dozen filled profiles therefore injected ~50k chars before the
|
|
1282
|
+
* entry budget was even consulted — the one number the user was told to tune
|
|
1283
|
+
* did not apply to the largest part of the block. `budget` is what is LEFT
|
|
1284
|
+
* after the persona section in planInjection, so the two together never
|
|
1285
|
+
* exceed the configured ceiling.
|
|
1286
|
+
*/
|
|
1287
|
+
const cap = Math.min(
|
|
1288
|
+
MAX_PROFILE_INJECT_CHARS,
|
|
1289
|
+
Number.isFinite(budget) && budget > 0 ? Math.floor(budget) : MAX_PROFILE_INJECT_CHARS,
|
|
1290
|
+
)
|
|
1291
|
+
const footerFor = (count) => (count === 0
|
|
1292
|
+
? ''
|
|
1293
|
+
: '\n\n(另有 ' + count + ' 个形象因注入字数上限未包含,可在「设置 → 指令记忆」中精简其内容。)')
|
|
1140
1294
|
const blocks = []
|
|
1141
1295
|
const items = []
|
|
1296
|
+
let used = PERSONA_HEADER.length + 1
|
|
1297
|
+
let omitted = 0
|
|
1142
1298
|
for (const profile of active) {
|
|
1143
1299
|
const name = typeof profile.name === 'string' && profile.name !== ''
|
|
1144
1300
|
? profile.name
|
|
1145
1301
|
: singleLine(profile.content, 20)
|
|
1302
|
+
const id = typeof profile.id === 'string' ? profile.id : ''
|
|
1146
1303
|
const block = '### ' + name + '\n' + profile.content
|
|
1147
|
-
blocks.
|
|
1148
|
-
|
|
1304
|
+
const sep = blocks.length === 0 ? 0 : 2
|
|
1305
|
+
// Reserve the omission footer up front: the disclosure must never be the
|
|
1306
|
+
// thing squeezed out by the profiles that caused it. Reserve the WORST
|
|
1307
|
+
// CASE (every profile omitted), not `omitted + 1`: the footer grows with
|
|
1308
|
+
// the omitted count, so reserving one-more-than-now under-reserves by one
|
|
1309
|
+
// character per extra digit and the finished section can exceed `cap`.
|
|
1310
|
+
const reserve = footerFor(active.length).length
|
|
1311
|
+
const room = cap - used - sep - reserve
|
|
1312
|
+
if (block.length <= room) {
|
|
1313
|
+
blocks.push(block)
|
|
1314
|
+
items.push({ id, name, chars: block.length })
|
|
1315
|
+
used += sep + block.length
|
|
1316
|
+
continue
|
|
1317
|
+
}
|
|
1318
|
+
// Too big for the room left: keep a truncated head (never splitting a
|
|
1319
|
+
// surrogate pair) and drop the rest, so the section stays informative
|
|
1320
|
+
// instead of silently vanishing.
|
|
1321
|
+
const note = '\n…(此形象因注入字数上限被截断)'
|
|
1322
|
+
const fit = room - note.length
|
|
1323
|
+
if (fit > 40) {
|
|
1324
|
+
const head = cutText(block, fit)
|
|
1325
|
+
blocks.push(head + note)
|
|
1326
|
+
items.push({ id, name, chars: head.length + note.length, truncated: true })
|
|
1327
|
+
used += sep + head.length + note.length
|
|
1328
|
+
}
|
|
1329
|
+
omitted = active.length - blocks.length
|
|
1330
|
+
break
|
|
1149
1331
|
}
|
|
1150
|
-
const text = PERSONA_HEADER + '\n' + blocks.join('\n\n')
|
|
1332
|
+
const text = PERSONA_HEADER + '\n' + blocks.join('\n\n') + footerFor(Math.max(0, active.length - blocks.length))
|
|
1151
1333
|
return { text, profiles: items, chars: text.length }
|
|
1152
1334
|
}
|
|
1153
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* The disclosure line for entries the budget left out. Shared by the entry
|
|
1338
|
+
* plan and by `planInjection`, because a persona section can consume the whole
|
|
1339
|
+
* budget and leave the entries with nothing: in that case the entry plan's own
|
|
1340
|
+
* text is empty, so its footer would never reach the prompt and the model would
|
|
1341
|
+
* silently lose the user's instructions while the persona block still rendered.
|
|
1342
|
+
*/
|
|
1343
|
+
export function entryOmittedFooter(count) {
|
|
1344
|
+
return count === 0
|
|
1345
|
+
? ''
|
|
1346
|
+
: '\n\n(另有 ' + count + ' 条指令因注入字数上限未包含,可在「设置 → 指令记忆」中提高上限或调低其优先级。)'
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1154
1349
|
/**
|
|
1155
1350
|
* The entry half of the plan: the existing instruction block with its budget
|
|
1156
1351
|
* trimming. Kept as the inner function so planInjection can prepend the
|
|
@@ -1183,10 +1378,7 @@ function planEntries(data, budget) {
|
|
|
1183
1378
|
].join('\n'),
|
|
1184
1379
|
'## 用户长期指令记忆\n',
|
|
1185
1380
|
]
|
|
1186
|
-
const footerFor =
|
|
1187
|
-
count === 0
|
|
1188
|
-
? ''
|
|
1189
|
-
: '\n\n(另有 ' + count + ' 条指令因注入字数上限未包含,可在「设置 → 指令记忆」中提高上限或调低其优先级。)'
|
|
1381
|
+
const footerFor = entryOmittedFooter
|
|
1190
1382
|
|
|
1191
1383
|
const MIN_ROOM = 80
|
|
1192
1384
|
let header = HEADERS[HEADERS.length - 1]
|
|
@@ -1206,8 +1398,8 @@ function planEntries(data, budget) {
|
|
|
1206
1398
|
const consider = (entry, tag) => {
|
|
1207
1399
|
const block = formatEntry(entry, tag)
|
|
1208
1400
|
// Reserve the footer up front so a disclosure can never be squeezed out by
|
|
1209
|
-
// the very entries that caused it.
|
|
1210
|
-
const reserve = footerFor(
|
|
1401
|
+
// the very entries that caused it. Worst case again: every entry omitted.
|
|
1402
|
+
const reserve = footerFor(active.length).length
|
|
1211
1403
|
if (used + block.length + 2 + reserve <= budget) {
|
|
1212
1404
|
used += block.length + 2
|
|
1213
1405
|
blocks.push(block)
|
|
@@ -1220,12 +1412,10 @@ function planEntries(data, budget) {
|
|
|
1220
1412
|
const room = budget - used - 2 - reserve
|
|
1221
1413
|
const note = '\n…(本条因注入字数上限被截断)'
|
|
1222
1414
|
if (room > note.length + 20) {
|
|
1223
|
-
//
|
|
1415
|
+
// cutText backs off one UTF-16 unit when the cut would land between a
|
|
1224
1416
|
// surrogate pair, so the truncation point can never end on the broken
|
|
1225
1417
|
// half of an emoji or CJK ext-B character.
|
|
1226
|
-
|
|
1227
|
-
const last = head.charCodeAt(head.length - 1)
|
|
1228
|
-
if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1)
|
|
1418
|
+
const head = cutText(block, room - note.length)
|
|
1229
1419
|
const body = head + note
|
|
1230
1420
|
blocks.push(body)
|
|
1231
1421
|
used += body.length + 2
|
|
@@ -1320,11 +1510,25 @@ export function planInjection(data, now = Date.now()) {
|
|
|
1320
1510
|
entries: (Array.isArray(data.entries) ? data.entries : []).filter((entry) => inScope(entry, scope)),
|
|
1321
1511
|
profiles: (Array.isArray(data.profiles) ? data.profiles : []).filter((profile) => inScope(profile, scope)),
|
|
1322
1512
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1513
|
+
/**
|
|
1514
|
+
* Persona first, then entries with whatever budget is LEFT. The persona
|
|
1515
|
+
* section used to be prepended outside the budget entirely, so the rendered
|
|
1516
|
+
* block could exceed 注入字数上限 by the size of the whole persona section.
|
|
1517
|
+
* Giving it the first claim and the entries the remainder keeps the total
|
|
1518
|
+
* honest: text.length <= budget for every store shape.
|
|
1519
|
+
*/
|
|
1520
|
+
// Worst-case entry disclosure, plus the 2-char separator the two halves are
|
|
1521
|
+
// joined with: if the persona section took the whole budget, every entry
|
|
1522
|
+
// would be dropped and its footer still has to fit.
|
|
1523
|
+
const JOIN = '\n\n'.length
|
|
1524
|
+
const activeEntryCount = (scoped.entries || []).filter((entry) => entry && entry.enabled === true && entry.content !== '').length
|
|
1525
|
+
const entryReserve = activeEntryCount > 0 ? entryOmittedFooter(activeEntryCount).length + JOIN : 0
|
|
1526
|
+
const persona = buildProfilesBlock(scoped.profiles, Math.max(0, budget - entryReserve))
|
|
1527
|
+
const entryPlan = planEntries(scoped, Math.max(0, budget - persona.chars))
|
|
1325
1528
|
const parts = []
|
|
1326
1529
|
if (persona.text !== '') parts.push(persona.text)
|
|
1327
1530
|
if (entryPlan.text !== '') parts.push(entryPlan.text)
|
|
1531
|
+
else if (parts.length > 0 && entryPlan.omitted.length > 0) parts.push(entryOmittedFooter(entryPlan.omitted.length))
|
|
1328
1532
|
return {
|
|
1329
1533
|
text: parts.join('\n\n'),
|
|
1330
1534
|
budget,
|
|
@@ -1458,6 +1662,28 @@ export function apply(ctx) {
|
|
|
1458
1662
|
return String(err)
|
|
1459
1663
|
}
|
|
1460
1664
|
|
|
1665
|
+
/**
|
|
1666
|
+
* describeError for text that LEAVES the process (HTTP responses).
|
|
1667
|
+
*
|
|
1668
|
+
* A raw fs message names the absolute path it failed on (ENOENT, EACCES,
|
|
1669
|
+
* ENOTDIR), which hands any caller the user's home directory and store
|
|
1670
|
+
* location for free. The full text still reaches the log; the response gets
|
|
1671
|
+
* the same sentence with the home folded to `~` and the store directory to a
|
|
1672
|
+
* stable placeholder.
|
|
1673
|
+
*/
|
|
1674
|
+
const describeErrorSafe = (err) => {
|
|
1675
|
+
let text = describeError(err)
|
|
1676
|
+
const storeDir = dirname(storePath)
|
|
1677
|
+
if (storeDir !== '' && storeDir !== '.') text = text.split(storeDir).join('<记忆目录>')
|
|
1678
|
+
try {
|
|
1679
|
+
const home = homedir()
|
|
1680
|
+
if (typeof home === 'string' && home !== '') text = text.split(home).join('~')
|
|
1681
|
+
} catch {
|
|
1682
|
+
// No home to redact: keep the text as it is.
|
|
1683
|
+
}
|
|
1684
|
+
return text
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1461
1687
|
/* ---------------- diagnostics ---------------- */
|
|
1462
1688
|
|
|
1463
1689
|
/**
|
|
@@ -1612,7 +1838,7 @@ export function apply(ctx) {
|
|
|
1612
1838
|
} catch (err) {
|
|
1613
1839
|
sectionDispose = null
|
|
1614
1840
|
state.sectionRegistered = false
|
|
1615
|
-
state.sectionError = '注入注册失败:' +
|
|
1841
|
+
state.sectionError = '注入注册失败:' + describeErrorSafe(err)
|
|
1616
1842
|
console.error('[instruction-memory] section registration failed:', err)
|
|
1617
1843
|
}
|
|
1618
1844
|
} else if (!wanted) {
|
|
@@ -1720,8 +1946,21 @@ export function apply(ctx) {
|
|
|
1720
1946
|
await readFromDisk()
|
|
1721
1947
|
syncSection()
|
|
1722
1948
|
pushSnapshot()
|
|
1723
|
-
} catch {
|
|
1949
|
+
} catch (err) {
|
|
1724
1950
|
// Transient: the next event, or the panel's 重新载入, catches up.
|
|
1951
|
+
// An EMPTY file is the one exception — a save in progress looks like
|
|
1952
|
+
// that for a moment, but a file that stays empty is data loss waiting
|
|
1953
|
+
// to happen, so route it through the recovery path that knows about
|
|
1954
|
+
// .bak instead of silently serving stale memory forever.
|
|
1955
|
+
if (err !== null && err !== undefined && err.code === EMPTY_STORE_CODE) {
|
|
1956
|
+
try {
|
|
1957
|
+
await readFromDisk()
|
|
1958
|
+
syncSection()
|
|
1959
|
+
pushSnapshot()
|
|
1960
|
+
} catch {
|
|
1961
|
+
// Still transient; the panel's 重新载入 remains available.
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1725
1964
|
}
|
|
1726
1965
|
})
|
|
1727
1966
|
}, WATCH_DEBOUNCE_MS)
|
|
@@ -1916,7 +2155,7 @@ export function apply(ctx) {
|
|
|
1916
2155
|
await unlink(probe).catch(() => {})
|
|
1917
2156
|
return null
|
|
1918
2157
|
} catch (err) {
|
|
1919
|
-
return '目录不可写:' +
|
|
2158
|
+
return '目录不可写:' + describeErrorSafe(err)
|
|
1920
2159
|
}
|
|
1921
2160
|
}
|
|
1922
2161
|
|
|
@@ -1932,10 +2171,24 @@ export function apply(ctx) {
|
|
|
1932
2171
|
})())
|
|
1933
2172
|
|
|
1934
2173
|
/**
|
|
1935
|
-
*
|
|
1936
|
-
*
|
|
2174
|
+
* Identity of the last snapshot's CONTENT, so two writes that produce the
|
|
2175
|
+
* same store (a no-op-ish save, a repeat restore) do not each mint a
|
|
2176
|
+
* "version".
|
|
2177
|
+
*
|
|
2178
|
+
* This used to compare the serialised file byte-for-byte, which never
|
|
2179
|
+
* matched twice: `rev` is bumped on every save, so the "identical" comparison
|
|
2180
|
+
* could not fire and every no-op save still appended a version. The key is
|
|
2181
|
+
* the serialised store with `rev` normalised away — the field that exists
|
|
2182
|
+
* only to differ.
|
|
2183
|
+
*/
|
|
2184
|
+
let lastHistoryKey = null
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Content identity of the store as last PERSISTED (rev normalised away), so a
|
|
2188
|
+
* save that changes nothing is skipped instead of bumping rev and appending a
|
|
2189
|
+
* version-history file.
|
|
1937
2190
|
*/
|
|
1938
|
-
let
|
|
2191
|
+
let lastPersistedKey = null
|
|
1939
2192
|
|
|
1940
2193
|
/**
|
|
1941
2194
|
* Enforce the per-device retention policy: the count and byte ceilings from
|
|
@@ -1958,6 +2211,13 @@ export function apply(ctx) {
|
|
|
1958
2211
|
}
|
|
1959
2212
|
let removed = 0
|
|
1960
2213
|
let kept = 0
|
|
2214
|
+
// Survivors of the per-device plan, kept with their size so a GLOBAL byte
|
|
2215
|
+
// ceiling can be enforced afterwards: the plan bounds each device on its
|
|
2216
|
+
// own, so N devices multiply the budget (ten machines = ten × 32 MB). The
|
|
2217
|
+
// total is capped below by deleting the oldest snapshots first, whichever
|
|
2218
|
+
// device they came from — the fixed-width stamp makes name order
|
|
2219
|
+
// chronological across devices too.
|
|
2220
|
+
const survivors = []
|
|
1961
2221
|
for (const bucket of byDevice.values()) {
|
|
1962
2222
|
// Fixed-width UTC stamp first => lexicographic order is chronological.
|
|
1963
2223
|
bucket.sort()
|
|
@@ -1971,7 +2231,22 @@ export function apply(ctx) {
|
|
|
1971
2231
|
await unlink(join(dir, name)).catch(() => {})
|
|
1972
2232
|
removed += 1
|
|
1973
2233
|
}
|
|
2234
|
+
const sizeOf = new Map(sized.map((item) => [item.name, item.size]))
|
|
1974
2235
|
kept += plan.keep.length
|
|
2236
|
+
// plan.keep holds NAMES (newest first), so re-attach the sizes here: the
|
|
2237
|
+
// global ceiling below sums bytes, not file counts.
|
|
2238
|
+
for (const name of plan.keep) survivors.push({ name, size: sizeOf.get(name) || 0 })
|
|
2239
|
+
}
|
|
2240
|
+
let total = survivors.reduce((sum, item) => sum + item.size, 0)
|
|
2241
|
+
if (total > MAX_HISTORY_BYTES) {
|
|
2242
|
+
survivors.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
2243
|
+
for (const item of survivors) {
|
|
2244
|
+
if (total <= MAX_HISTORY_BYTES) break
|
|
2245
|
+
await unlink(join(dir, item.name)).catch(() => {})
|
|
2246
|
+
total -= item.size
|
|
2247
|
+
removed += 1
|
|
2248
|
+
kept -= 1
|
|
2249
|
+
}
|
|
1975
2250
|
}
|
|
1976
2251
|
return { removed, kept }
|
|
1977
2252
|
}
|
|
@@ -1983,8 +2258,9 @@ export function apply(ctx) {
|
|
|
1983
2258
|
* really was committed; a failure here is disclosed and never fails the save,
|
|
1984
2259
|
* because the store itself is already safely on disk.
|
|
1985
2260
|
*/
|
|
1986
|
-
const writeHistorySnapshot = async (text) => {
|
|
1987
|
-
|
|
2261
|
+
const writeHistorySnapshot = async (text, key) => {
|
|
2262
|
+
const identity = typeof key === 'string' && key !== '' ? key : text
|
|
2263
|
+
if (typeof text !== 'string' || text === '' || identity === lastHistoryKey) return
|
|
1988
2264
|
try {
|
|
1989
2265
|
const dir = historyDirPath()
|
|
1990
2266
|
await mkdir(dir, { recursive: true })
|
|
@@ -1994,19 +2270,19 @@ export function apply(ctx) {
|
|
|
1994
2270
|
// bump the stamp by a millisecond instead of losing one of the two.
|
|
1995
2271
|
for (let attempt = 0; attempt < 5 && !written; attempt += 1) {
|
|
1996
2272
|
try {
|
|
1997
|
-
await writeFile(join(dir, historyFileName(epoch + attempt, state.data.rev || 0, DEVICE)), text, { encoding: 'utf8', flag: 'wx' })
|
|
2273
|
+
await writeFile(join(dir, historyFileName(epoch + attempt, state.data.rev || 0, DEVICE)), text, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
|
|
1998
2274
|
written = true
|
|
1999
2275
|
} catch (err) {
|
|
2000
2276
|
if (err.code !== 'EEXIST') throw err
|
|
2001
2277
|
}
|
|
2002
2278
|
}
|
|
2003
2279
|
if (!written) throw new Error('快照文件名重复')
|
|
2004
|
-
|
|
2280
|
+
lastHistoryKey = identity
|
|
2005
2281
|
const { kept } = await pruneHistory(dir)
|
|
2006
2282
|
state.historyCount = kept
|
|
2007
2283
|
state.historyError = null
|
|
2008
2284
|
} catch (err) {
|
|
2009
|
-
state.historyError = '版本历史快照未能写入(' +
|
|
2285
|
+
state.historyError = '版本历史快照未能写入(' + describeErrorSafe(err) + ');本次保存本身已完成'
|
|
2010
2286
|
}
|
|
2011
2287
|
}
|
|
2012
2288
|
|
|
@@ -2064,31 +2340,92 @@ export function apply(ctx) {
|
|
|
2064
2340
|
await mkdir(dirname(storePath), { recursive: true })
|
|
2065
2341
|
lock = await acquireStoreLock()
|
|
2066
2342
|
if (lock === null) {
|
|
2067
|
-
|
|
2343
|
+
// The lock could not be taken within the wait. That is either a stale
|
|
2344
|
+
// lock left by a crashed process (the file on disk is still exactly
|
|
2345
|
+
// what we loaded, so writing is safe) or a LIVE one, in which case the
|
|
2346
|
+
// other process may have committed since we loaded and writing our
|
|
2347
|
+
// whole-file snapshot would silently drop their change. Only the first
|
|
2348
|
+
// case may proceed: compare the on-disk rev with the one we hold.
|
|
2349
|
+
const diskRev = await readDiskRev()
|
|
2350
|
+
if (diskRev > (state.data.rev || 0)) {
|
|
2351
|
+
state.storageError = '另一个 DSH 进程刚刚保存了记忆(磁盘 rev ' + diskRev
|
|
2352
|
+
+ ',本进程 rev ' + (state.data.rev || 0) + '),本次未写入以免覆盖,请重试'
|
|
2353
|
+
return false
|
|
2354
|
+
}
|
|
2355
|
+
logDebug('store lock held by a stale owner and the disk rev is unchanged; writing anyway')
|
|
2356
|
+
}
|
|
2357
|
+
// The lock is ours, but the DISK may have moved on since this process
|
|
2358
|
+
// loaded it: another DSH window committed while our watcher had not yet
|
|
2359
|
+
// refreshed. Bumping the rev from max(ours, disk) kept the NUMBER
|
|
2360
|
+
// monotonic while the CONTENT of their save was dropped without an error
|
|
2361
|
+
// — "rev 3" looked healthy and the entry was simply gone. Refuse instead,
|
|
2362
|
+
// so the caller retries against the newer store.
|
|
2363
|
+
const diskRevNow = await readDiskRev()
|
|
2364
|
+
if (diskRevNow > (state.data.rev || 0)) {
|
|
2365
|
+
state.storageError = '另一个 DSH 进程刚刚保存了记忆(磁盘 rev ' + diskRevNow
|
|
2366
|
+
+ ',本进程 rev ' + (state.data.rev || 0) + '),本次未写入以免覆盖,请重试'
|
|
2367
|
+
return false
|
|
2368
|
+
}
|
|
2369
|
+
// A save that changes NOTHING must not bump the rev, rewrite the file or
|
|
2370
|
+
// mint a history snapshot. The mutation still counts as committed (the
|
|
2371
|
+
// in-memory state already matches), so callers keep their success path.
|
|
2372
|
+
//
|
|
2373
|
+
// The identity is the serialised store with `rev` normalised away,
|
|
2374
|
+
// because rev is bumped on every save and would make the comparison
|
|
2375
|
+
// always differ.
|
|
2376
|
+
const contentKey = JSON.stringify({ ...persistableData(), rev: 0 })
|
|
2377
|
+
if (state.storeExists && lastPersistedKey !== null && contentKey === lastPersistedKey) {
|
|
2378
|
+
logDebug('save skipped: the store content did not change')
|
|
2379
|
+
return true
|
|
2068
2380
|
}
|
|
2069
2381
|
// The rev is bumped before serialising so the file on disk and the state
|
|
2070
2382
|
// in memory agree on it; a failed write restores the old value.
|
|
2071
|
-
|
|
2383
|
+
//
|
|
2384
|
+
// It is bumped from max(ours, the one on disk) so it stays monotonic
|
|
2385
|
+
// ACROSS processes: two windows that loaded rev 5 would otherwise both
|
|
2386
|
+
// write rev 6, and anything that watches the rev for change (the lite
|
|
2387
|
+
// SSE stream) cannot tell the second save from a repeat of the first.
|
|
2388
|
+
const diskRev = await readDiskRev()
|
|
2389
|
+
previousRev = Math.max(state.data.rev || 0, diskRev)
|
|
2072
2390
|
state.data.rev = previousRev + 1
|
|
2073
2391
|
const text = serialize()
|
|
2074
|
-
|
|
2392
|
+
// mode 0600: the store holds the user's private instructions and (with a
|
|
2393
|
+
// user profile) personal notes, so on a multi-user POSIX host it must not
|
|
2394
|
+
// land world-readable the way a bare writeFile (0666 & ~umask = 0644)
|
|
2395
|
+
// would. Windows ignores the bit and inherits the directory ACL instead.
|
|
2396
|
+
await writeFile(tmpPath, text, { encoding: 'utf8', mode: 0o600 })
|
|
2075
2397
|
// Both rolling copies stand for the PREVIOUS good version, so both are
|
|
2076
2398
|
// taken before the atomic replace, and neither may ever be renamed into
|
|
2077
2399
|
// place (see the long note above).
|
|
2400
|
+
//
|
|
2401
|
+
// The copy is GUARDED against an UNUSABLE primary, not merely an empty
|
|
2402
|
+
// one: the states the backup exists to survive are a 0-byte file, a
|
|
2403
|
+
// whitespace stub, and a truncated or hand-mangled JSON — all of which
|
|
2404
|
+
// parse to nothing, and only the first is zero bytes. Copying any of them
|
|
2405
|
+
// over a good .bak destroys the only remaining copy at the exact moment
|
|
2406
|
+
// the user needs it, so the test is "does this file read as a store?".
|
|
2407
|
+
// An unusable primary therefore keeps the existing backup untouched and
|
|
2408
|
+
// discloses it (the .bak may be stale, and the panel says so).
|
|
2078
2409
|
let hadPrevious = true
|
|
2079
2410
|
try {
|
|
2411
|
+
await readStoreFile(storePath)
|
|
2080
2412
|
await copyFile(storePath, localBackupPath())
|
|
2081
2413
|
state.backupError = null
|
|
2082
2414
|
} catch (err) {
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2415
|
+
if (err !== null && err !== undefined && err.code === 'ENOENT') {
|
|
2416
|
+
// The very first save: there is no previous version to roll, and
|
|
2417
|
+
// writing a .bak here would leave a 0-byte copy that a later recovery
|
|
2418
|
+
// would adopt as an "empty store" — the trap this guard exists for.
|
|
2419
|
+
hadPrevious = false
|
|
2420
|
+
state.backupError = null
|
|
2421
|
+
} else {
|
|
2422
|
+
hadPrevious = false
|
|
2423
|
+
const previous = await stat(localBackupPath()).catch(() => null)
|
|
2424
|
+
state.backupError = previous !== null && previous.size > 0
|
|
2425
|
+
? 'memory.json 当前不可用(' + describeErrorSafe(err) + '),已保留上一版备份 '
|
|
2426
|
+
+ localBackupPath() + ' 不被覆盖'
|
|
2427
|
+
: 'memory.json 当前不可用,且没有可用的上一版备份(' + describeErrorSafe(err) + ')'
|
|
2428
|
+
}
|
|
2092
2429
|
}
|
|
2093
2430
|
// The user-facing copy under a custom backup location (F18). The LOCAL
|
|
2094
2431
|
// copy above stays the one crash recovery reads, so a failure here
|
|
@@ -2099,7 +2436,7 @@ export function apply(ctx) {
|
|
|
2099
2436
|
await copyFile(storePath, customBak)
|
|
2100
2437
|
} catch (err) {
|
|
2101
2438
|
if (state.backupError === null) {
|
|
2102
|
-
state.backupError = '备份位置未能保留上一版(' + customBak + '):' +
|
|
2439
|
+
state.backupError = '备份位置未能保留上一版(' + customBak + '):' + describeErrorSafe(err)
|
|
2103
2440
|
}
|
|
2104
2441
|
}
|
|
2105
2442
|
}
|
|
@@ -2123,7 +2460,8 @@ export function apply(ctx) {
|
|
|
2123
2460
|
// Best-effort side files, each guarded inside itself: a failure in either
|
|
2124
2461
|
// must never be mistaken for "the save failed" — the save already
|
|
2125
2462
|
// committed, and the store is on disk whatever happens next.
|
|
2126
|
-
await writeHistorySnapshot(text)
|
|
2463
|
+
await writeHistorySnapshot(text, contentKey)
|
|
2464
|
+
lastPersistedKey = contentKey
|
|
2127
2465
|
await ensureSchemaFile()
|
|
2128
2466
|
logDebug('store written: ' + storePath + ' bytes=' + text.length
|
|
2129
2467
|
+ ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
|
|
@@ -2133,16 +2471,51 @@ export function apply(ctx) {
|
|
|
2133
2471
|
return true
|
|
2134
2472
|
} catch (err) {
|
|
2135
2473
|
if (previousRev !== null) state.data.rev = previousRev
|
|
2136
|
-
state.storageError = '保存失败:' +
|
|
2474
|
+
state.storageError = '保存失败:' + describeErrorSafe(err)
|
|
2137
2475
|
return false
|
|
2138
2476
|
} finally {
|
|
2139
2477
|
await releaseStoreLock(lock)
|
|
2140
2478
|
}
|
|
2141
2479
|
}
|
|
2142
2480
|
|
|
2481
|
+
/**
|
|
2482
|
+
* Read + parse one store file.
|
|
2483
|
+
*
|
|
2484
|
+
* An EMPTY file is NOT "no file". Returning null for it made the loader treat
|
|
2485
|
+
* a 0-byte memory.json — a truncated write, an editor that saved blank, a
|
|
2486
|
+
* quarantine stub — as a first run, so the plugin materialised an empty store
|
|
2487
|
+
* and the very next save copied that empty file over memory.json.bak,
|
|
2488
|
+
* destroying the last good copy. It now throws a tagged error, which sends it
|
|
2489
|
+
* down the corrupt-file recovery path (the one that knows about .bak).
|
|
2490
|
+
*/
|
|
2143
2491
|
const readStoreFile = async (path) => {
|
|
2144
2492
|
const text = await readFile(path, 'utf8')
|
|
2145
|
-
|
|
2493
|
+
if (text.trim() === '') {
|
|
2494
|
+
const empty = new Error('store file is empty (0 bytes or whitespace only)')
|
|
2495
|
+
empty.code = EMPTY_STORE_CODE
|
|
2496
|
+
throw empty
|
|
2497
|
+
}
|
|
2498
|
+
return JSON.parse(text)
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
/**
|
|
2502
|
+
* The `rev` currently on disk, or 0 when there is no readable store.
|
|
2503
|
+
*
|
|
2504
|
+
* writeToDisk needs this for two questions the in-memory copy cannot answer:
|
|
2505
|
+
* (a) keeping the rev monotonic ACROSS processes — two DSH windows each doing
|
|
2506
|
+
* their own `data.rev + 1` from the same loaded rev mint the SAME number, and
|
|
2507
|
+
* a consumer that detects change by "the rev differs" (the lite SSE stream)
|
|
2508
|
+
* then silently misses the second save; and (b) deciding whether a save made
|
|
2509
|
+
* while another process holds the lock would clobber a newer store.
|
|
2510
|
+
*/
|
|
2511
|
+
const readDiskRev = async () => {
|
|
2512
|
+
try {
|
|
2513
|
+
const parsed = await readStoreFile(storePath)
|
|
2514
|
+
const rev = parsed !== null && typeof parsed === 'object' ? parsed.rev : null
|
|
2515
|
+
return typeof rev === 'number' && Number.isFinite(rev) && rev > 0 ? Math.round(rev) : 0
|
|
2516
|
+
} catch {
|
|
2517
|
+
return 0
|
|
2518
|
+
}
|
|
2146
2519
|
}
|
|
2147
2520
|
|
|
2148
2521
|
/**
|
|
@@ -2205,6 +2578,11 @@ export function apply(ctx) {
|
|
|
2205
2578
|
needsMaterialize = false
|
|
2206
2579
|
}
|
|
2207
2580
|
state.storageError = null
|
|
2581
|
+
// The primary read cleanly, so a "memory.json 当前不可用" note from an
|
|
2582
|
+
// earlier save describes a state that no longer exists. The panel reports
|
|
2583
|
+
// current state, not a log; keeping the stale sentence would leave the
|
|
2584
|
+
// user reading about a broken file they just fixed.
|
|
2585
|
+
state.backupError = null
|
|
2208
2586
|
return false
|
|
2209
2587
|
} catch (err) {
|
|
2210
2588
|
if (err === null || err === undefined || err.code !== 'ENOENT') {
|
|
@@ -2213,14 +2591,23 @@ export function apply(ctx) {
|
|
|
2213
2591
|
// presenting an empty memory, but say so loudly. The corrupt file
|
|
2214
2592
|
// itself stays untouched for hand repair; it gets overwritten on the
|
|
2215
2593
|
// next save (the pre-existing and disclosed behaviour).
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2594
|
+
//
|
|
2595
|
+
// "Corrupt" covers BOTH a file that was read but would not parse and a
|
|
2596
|
+
// file that parsed to nothing at all (0 bytes / whitespace only). The
|
|
2597
|
+
// empty case used to be silently treated as "no file yet", which is the
|
|
2598
|
+
// exact shape that loses data: see readStoreFile.
|
|
2599
|
+
const unreadable = err instanceof SyntaxError || err.code === EMPTY_STORE_CODE
|
|
2600
|
+
if (unreadable) {
|
|
2601
|
+
// The file was READ, so a store file is physically present on disk.
|
|
2602
|
+
// Saying onDisk=no here contradicted the branch's own story ("损坏
|
|
2603
|
+
// 文件已保留") and produced a mounted log line reading
|
|
2604
|
+
// "from=memory.json.bak (main file corrupt) onDisk=no". (Other
|
|
2605
|
+
// non-ENOENT errors stay no: ENOTDIR, say, means nothing exists at
|
|
2606
|
+
// the store path at all.)
|
|
2223
2607
|
state.storeExists = true
|
|
2608
|
+
const reason = err.code === EMPTY_STORE_CODE
|
|
2609
|
+
? 'memory.json 为空(0 字节或只有空白)'
|
|
2610
|
+
: 'memory.json 解析失败(' + describeErrorSafe(err) + ')'
|
|
2224
2611
|
try {
|
|
2225
2612
|
const backup = await readStoreFile(storePath + '.bak')
|
|
2226
2613
|
if (backup !== null) {
|
|
@@ -2232,7 +2619,7 @@ export function apply(ctx) {
|
|
|
2232
2619
|
state.data = loaded.data
|
|
2233
2620
|
const warnings = loadWarnings(loaded)
|
|
2234
2621
|
state.storageWarning = (warnings !== null ? warnings + ';' : '')
|
|
2235
|
-
+
|
|
2622
|
+
+ reason + ',已临时回退到最近备份 memory.json.bak;原文件已保留,下次保存时会覆盖它'
|
|
2236
2623
|
state.loadedFrom = 'memory.json.bak (main file corrupt)'
|
|
2237
2624
|
return false
|
|
2238
2625
|
}
|
|
@@ -2241,9 +2628,11 @@ export function apply(ctx) {
|
|
|
2241
2628
|
}
|
|
2242
2629
|
}
|
|
2243
2630
|
// A file we cannot understand must never be silently overwritten.
|
|
2244
|
-
state.storageError =
|
|
2245
|
-
?
|
|
2246
|
-
|
|
2631
|
+
state.storageError = unreadable
|
|
2632
|
+
? (err.code === EMPTY_STORE_CODE
|
|
2633
|
+
? '配置文件为空(0 字节或只有空白),原文件已保留;你下次保存时会覆盖它'
|
|
2634
|
+
: '配置文件解析失败(' + describeErrorSafe(err) + '),原文件已保留;你下次保存时会覆盖它')
|
|
2635
|
+
: '读取失败:' + describeErrorSafe(err)
|
|
2247
2636
|
return false
|
|
2248
2637
|
}
|
|
2249
2638
|
}
|
|
@@ -2302,7 +2691,7 @@ export function apply(ctx) {
|
|
|
2302
2691
|
}
|
|
2303
2692
|
} catch (err) {
|
|
2304
2693
|
// A missing or unreadable legacy file must never block a fresh store.
|
|
2305
|
-
if (err instanceof SyntaxError) state.storageError = '旧位置文件解析失败,已忽略:' +
|
|
2694
|
+
if (err instanceof SyntaxError) state.storageError = '旧位置文件解析失败,已忽略:' + describeErrorSafe(err)
|
|
2306
2695
|
}
|
|
2307
2696
|
|
|
2308
2697
|
// 3. First run: there is no store anywhere. Deliberately write NOTHING.
|
|
@@ -2376,7 +2765,7 @@ export function apply(ctx) {
|
|
|
2376
2765
|
let bootSettled = false
|
|
2377
2766
|
const boot = readFromDisk()
|
|
2378
2767
|
.catch((err) => {
|
|
2379
|
-
state.storageError = '读取失败:' +
|
|
2768
|
+
state.storageError = '读取失败:' + describeErrorSafe(err)
|
|
2380
2769
|
})
|
|
2381
2770
|
.then(async () => {
|
|
2382
2771
|
bootSettled = true
|
|
@@ -2490,7 +2879,7 @@ export function apply(ctx) {
|
|
|
2490
2879
|
rev: state.data.rev || 0,
|
|
2491
2880
|
},
|
|
2492
2881
|
storage: {
|
|
2493
|
-
path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
|
|
2882
|
+
path: displayPath(state.storageDisplay !== null ? state.storageDisplay : state.storagePath),
|
|
2494
2883
|
error: state.storageError,
|
|
2495
2884
|
warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
|
|
2496
2885
|
savedAt: state.savedAt,
|
|
@@ -2498,19 +2887,22 @@ export function apply(ctx) {
|
|
|
2498
2887
|
/** false until the store file has actually been written (see readFromDisk step 3). */
|
|
2499
2888
|
onDisk: state.storeExists,
|
|
2500
2889
|
/** Backup location (F18): '' means "next to the store file". */
|
|
2501
|
-
backupDir: backupDirOf(),
|
|
2890
|
+
backupDir: displayPath(backupDirOf()),
|
|
2502
2891
|
isDefaultBackupDir: backupDirOf() === '',
|
|
2503
2892
|
/** Where the rolling pre-write copy actually sits (diagnostics). */
|
|
2504
|
-
backupPath: localBackupPath(),
|
|
2893
|
+
backupPath: displayPath(localBackupPath()),
|
|
2505
2894
|
/** Version history (F14). */
|
|
2506
|
-
historyDir: historyDirPath(),
|
|
2895
|
+
historyDir: displayPath(historyDirPath()),
|
|
2507
2896
|
historyCount: state.historyCount,
|
|
2508
2897
|
historyError: state.historyError,
|
|
2509
|
-
schemaFile: join(dirname(storePath), SCHEMA_FILE_NAME),
|
|
2898
|
+
schemaFile: displayPath(join(dirname(storePath), SCHEMA_FILE_NAME)),
|
|
2510
2899
|
},
|
|
2511
2900
|
injection: {
|
|
2512
2901
|
registered: state.sectionRegistered,
|
|
2513
|
-
available
|
|
2902
|
+
// `available` used to be hardcoded true, so a failed section()
|
|
2903
|
+
// registration published "available" next to an `error` string and the
|
|
2904
|
+
// panel had to guess. It now mirrors the real state.
|
|
2905
|
+
available: state.sectionError === null,
|
|
2514
2906
|
error: state.sectionError,
|
|
2515
2907
|
/**
|
|
2516
2908
|
* F7: the pause is decided by the HOST clock (isMuted on the live
|
|
@@ -2825,6 +3217,16 @@ export function apply(ctx) {
|
|
|
2825
3217
|
// is a de-escalation, refusing it would be user-hostile.
|
|
2826
3218
|
let muteTarget = null
|
|
2827
3219
|
let muteClamped = false
|
|
3220
|
+
// `disabledUntil: 0` (or any past instant) is "not paused", not a pause
|
|
3221
|
+
// request. Treating it as one made `{ enabled: true, disabledUntil: 0 }` —
|
|
3222
|
+
// the exact shape a caller sends to say "on, and make sure nothing is
|
|
3223
|
+
// paused" — hit the ambiguity guard below and be refused, even though both
|
|
3224
|
+
// halves agree.
|
|
3225
|
+
let muteClear = false
|
|
3226
|
+
// Whether the caller mentioned a pause at all, for the reply wording: a
|
|
3227
|
+
// past instant and an explicit 0 both mean "resume", which is reported only
|
|
3228
|
+
// when there was something to resume.
|
|
3229
|
+
let muteRequested = false
|
|
2828
3230
|
if (patch !== null && typeof patch === 'object' && 'disabledUntil' in patch) {
|
|
2829
3231
|
const rawMute = patch.disabledUntil
|
|
2830
3232
|
if (typeof rawMute !== 'number' || !Number.isFinite(rawMute) || rawMute < 0) {
|
|
@@ -2835,6 +3237,7 @@ export function apply(ctx) {
|
|
|
2835
3237
|
snapshot: snapshot(),
|
|
2836
3238
|
}
|
|
2837
3239
|
}
|
|
3240
|
+
muteRequested = true
|
|
2838
3241
|
let wanted = Math.round(rawMute)
|
|
2839
3242
|
if (wanted <= Date.now()) {
|
|
2840
3243
|
wanted = 0
|
|
@@ -2842,7 +3245,21 @@ export function apply(ctx) {
|
|
|
2842
3245
|
wanted = Date.now() + MAX_MUTE_MS
|
|
2843
3246
|
muteClamped = true
|
|
2844
3247
|
}
|
|
2845
|
-
muteTarget = wanted
|
|
3248
|
+
if (wanted > 0) muteTarget = wanted
|
|
3249
|
+
else muteClear = true
|
|
3250
|
+
}
|
|
3251
|
+
// A patch carrying BOTH the master switch and a mute instant is ambiguous:
|
|
3252
|
+
// the switch is documented to clear any pause, so the commit below silently
|
|
3253
|
+
// dropped the pause the caller had just asked for and answered "saved".
|
|
3254
|
+
// Refuse the combination instead of guessing which half to honour.
|
|
3255
|
+
if (muteTarget !== null && patch !== null && typeof patch === 'object'
|
|
3256
|
+
&& typeof patch.enabled === 'boolean') {
|
|
3257
|
+
return {
|
|
3258
|
+
ok: false,
|
|
3259
|
+
saved: false,
|
|
3260
|
+
message: '同一请求里不能同时设置总开关与暂停时间:请分开提交(切换总开关会自动清除暂停状态)',
|
|
3261
|
+
snapshot: snapshot(),
|
|
3262
|
+
}
|
|
2846
3263
|
}
|
|
2847
3264
|
const muteWasActive = isMuted(state.data)
|
|
2848
3265
|
const saved = await commit(() => {
|
|
@@ -2857,6 +3274,7 @@ export function apply(ctx) {
|
|
|
2857
3274
|
state.data.budgetChars = clampBudget(patch.budgetChars)
|
|
2858
3275
|
}
|
|
2859
3276
|
if (typeof patch.backupDir === 'string') state.data.backupDir = patch.backupDir.trim()
|
|
3277
|
+
if (muteClear) state.data.disabledUntil = 0
|
|
2860
3278
|
if (muteTarget !== null && typeof patch.enabled !== 'boolean') {
|
|
2861
3279
|
state.data.disabledUntil = muteTarget
|
|
2862
3280
|
}
|
|
@@ -2871,8 +3289,8 @@ export function apply(ctx) {
|
|
|
2871
3289
|
? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
|
|
2872
3290
|
: '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)')
|
|
2873
3291
|
}
|
|
2874
|
-
if (
|
|
2875
|
-
if (muteTarget ===
|
|
3292
|
+
if (muteRequested && !(patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean')) {
|
|
3293
|
+
if (muteTarget === null) {
|
|
2876
3294
|
if (muteWasActive) notes.push('已恢复注入。')
|
|
2877
3295
|
} else {
|
|
2878
3296
|
notes.push('已暂停注入至 ' + new Date(muteTarget).toLocaleString()
|
|
@@ -3166,8 +3584,32 @@ export function apply(ctx) {
|
|
|
3166
3584
|
if (state.data.sets.length >= MAX_SETS) {
|
|
3167
3585
|
return { ok: false, saved: false, message: '最多 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
|
|
3168
3586
|
}
|
|
3169
|
-
|
|
3170
|
-
|
|
3587
|
+
// The derived name must not lose its " 副本" suffix to truncation: a
|
|
3588
|
+
// MAX_SET_NAME-long source name used to be cut AFTER the suffix was
|
|
3589
|
+
// appended, so the copy came back with the source's own name and the
|
|
3590
|
+
// same-name check below refused it with "已存在同名配置集" — a copy that
|
|
3591
|
+
// cannot be made and a message that does not explain why. Reserve the
|
|
3592
|
+
// suffix's room first, then number the copies when that name is taken.
|
|
3593
|
+
const derivedName = () => {
|
|
3594
|
+
const plain = ' 副本'
|
|
3595
|
+
const first = singleLine(source.name, MAX_SET_NAME - plain.length) + plain
|
|
3596
|
+
if (!state.data.sets.some((set) => set.name === first)) return first
|
|
3597
|
+
for (let index = 2; index <= 99; index += 1) {
|
|
3598
|
+
const tag = ' 副本' + index
|
|
3599
|
+
const candidate = singleLine(source.name, MAX_SET_NAME - tag.length) + tag
|
|
3600
|
+
if (!state.data.sets.some((set) => set.name === candidate)) return candidate
|
|
3601
|
+
}
|
|
3602
|
+
return null
|
|
3603
|
+
}
|
|
3604
|
+
const wanted = singleLine(typeof name === 'string' ? name : '', MAX_SET_NAME) || derivedName()
|
|
3605
|
+
if (wanted === null) {
|
|
3606
|
+
return {
|
|
3607
|
+
ok: false,
|
|
3608
|
+
saved: false,
|
|
3609
|
+
message: '无法为新配置集生成不重复的名称(同名「副本」已达 99 个):请手动指定名称',
|
|
3610
|
+
snapshot: snapshot(),
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3171
3613
|
if (state.data.sets.some((set) => set.name === wanted)) {
|
|
3172
3614
|
return { ok: false, saved: false, message: '已存在同名配置集「' + wanted + '」', snapshot: snapshot() }
|
|
3173
3615
|
}
|
|
@@ -3214,7 +3656,7 @@ export function apply(ctx) {
|
|
|
3214
3656
|
} catch (err) {
|
|
3215
3657
|
// A missing directory is not an error: nothing has been written yet.
|
|
3216
3658
|
if (err === null || err === undefined || err.code !== 'ENOENT') {
|
|
3217
|
-
return { ok: false, saved: false, message: '无法读取版本历史目录:' +
|
|
3659
|
+
return { ok: false, saved: false, message: '无法读取版本历史目录:' + describeErrorSafe(err), snapshot: snapshot(), history: { dir, items: [] } }
|
|
3218
3660
|
}
|
|
3219
3661
|
}
|
|
3220
3662
|
const items = []
|
|
@@ -3271,7 +3713,7 @@ export function apply(ctx) {
|
|
|
3271
3713
|
try {
|
|
3272
3714
|
parsed = JSON.parse(await readFile(join(historyDirPath(), name), 'utf8'))
|
|
3273
3715
|
} catch (err) {
|
|
3274
|
-
return { ok: false, saved: false, message: '无法读取该历史快照:' +
|
|
3716
|
+
return { ok: false, saved: false, message: '无法读取该历史快照:' + describeErrorSafe(err), snapshot: snapshot() }
|
|
3275
3717
|
}
|
|
3276
3718
|
const adopted = adoptParsedStore(parsed)
|
|
3277
3719
|
if (adopted.error !== undefined) {
|
|
@@ -3312,7 +3754,7 @@ export function apply(ctx) {
|
|
|
3312
3754
|
try {
|
|
3313
3755
|
await unlink(join(historyDirPath(), name))
|
|
3314
3756
|
} catch (err) {
|
|
3315
|
-
return { ok: false, saved: false, message: '删除历史快照失败:' +
|
|
3757
|
+
return { ok: false, saved: false, message: '删除历史快照失败:' + describeErrorSafe(err), snapshot: snapshot() }
|
|
3316
3758
|
}
|
|
3317
3759
|
await refreshHistoryCount()
|
|
3318
3760
|
return { ok: true, saved: false, message: '已删除该历史快照', snapshot: snapshot() }
|
|
@@ -3360,9 +3802,9 @@ export function apply(ctx) {
|
|
|
3360
3802
|
const target = join(dir, exportFileName(Date.now()))
|
|
3361
3803
|
try {
|
|
3362
3804
|
await mkdir(dir, { recursive: true })
|
|
3363
|
-
await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', 'utf8')
|
|
3805
|
+
await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
3364
3806
|
} catch (err) {
|
|
3365
|
-
return { ok: false, saved: false, message: '写入备份目录失败:' +
|
|
3807
|
+
return { ok: false, saved: false, message: '写入备份目录失败:' + describeErrorSafe(err), snapshot: snapshot() }
|
|
3366
3808
|
}
|
|
3367
3809
|
return { ok: true, saved: false, message: '已写入 ' + target, snapshot: snapshot() }
|
|
3368
3810
|
})
|
|
@@ -3450,7 +3892,7 @@ export function apply(ctx) {
|
|
|
3450
3892
|
try {
|
|
3451
3893
|
parsed = JSON.parse(text)
|
|
3452
3894
|
} catch (err) {
|
|
3453
|
-
return { ok: false, saved: false, message: '源码不是有效的 JSON:' +
|
|
3895
|
+
return { ok: false, saved: false, message: '源码不是有效的 JSON:' + describeErrorSafe(err), snapshot: snapshot() }
|
|
3454
3896
|
}
|
|
3455
3897
|
return commitAdoptedStore(parsed, '已按源码保存:')
|
|
3456
3898
|
})
|
|
@@ -3767,12 +4209,12 @@ export function apply(ctx) {
|
|
|
3767
4209
|
text = await readFile(path, 'utf8')
|
|
3768
4210
|
} catch (err) {
|
|
3769
4211
|
if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
|
|
3770
|
-
throw new Error('无法读取反馈配置文件 ' + path + ':' +
|
|
4212
|
+
throw new Error('无法读取反馈配置文件 ' + path + ':' + describeErrorSafe(err))
|
|
3771
4213
|
}
|
|
3772
4214
|
try {
|
|
3773
4215
|
return JSON.parse(text)
|
|
3774
4216
|
} catch (err) {
|
|
3775
|
-
throw new Error('反馈配置文件不是有效的 JSON(' + path + '):' +
|
|
4217
|
+
throw new Error('反馈配置文件不是有效的 JSON(' + path + '):' + describeErrorSafe(err))
|
|
3776
4218
|
}
|
|
3777
4219
|
}
|
|
3778
4220
|
|
|
@@ -3830,8 +4272,8 @@ export function apply(ctx) {
|
|
|
3830
4272
|
file = await readFeedbackConfigFile(configPath)
|
|
3831
4273
|
} catch (err) {
|
|
3832
4274
|
releaseReservation()
|
|
3833
|
-
logInfo('feedback config unusable: ' +
|
|
3834
|
-
return { ok: false, message:
|
|
4275
|
+
logInfo('feedback config unusable: ' + describeErrorSafe(err))
|
|
4276
|
+
return { ok: false, message: describeErrorSafe(err) }
|
|
3835
4277
|
}
|
|
3836
4278
|
const readiness = feedbackConfigFrom({ env: process.env, file, configPath })
|
|
3837
4279
|
if (!readiness.ok) {
|
|
@@ -3880,7 +4322,7 @@ export function apply(ctx) {
|
|
|
3880
4322
|
// on; it never contains the password (see mailer.js), and the configured
|
|
3881
4323
|
// mailboxes are masked, so logging it is safe and makes reports debuggable.
|
|
3882
4324
|
releaseReservation()
|
|
3883
|
-
const reason = maskAddresses(
|
|
4325
|
+
const reason = maskAddresses(describeErrorSafe(err))
|
|
3884
4326
|
logInfo('feedback send failed: ' + reason)
|
|
3885
4327
|
return { ok: false, message: '反馈发送失败:' + reason + '(详细原因已写入宿主日志)' }
|
|
3886
4328
|
}
|
|
@@ -4032,6 +4474,18 @@ export function apply(ctx) {
|
|
|
4032
4474
|
res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
|
|
4033
4475
|
return
|
|
4034
4476
|
}
|
|
4477
|
+
// Every subscriber holds a Map entry, a `req.on('close')` listener and a
|
|
4478
|
+
// live socket until it disconnects. A tab that is duplicated, a client that
|
|
4479
|
+
// never closes, or a script that opens streams in a loop would otherwise
|
|
4480
|
+
// grow that set without bound — the host process leaks memory and file
|
|
4481
|
+
// handles for as long as it runs. Refuse politely past the ceiling instead
|
|
4482
|
+
// of growing forever; the panel retries on its own schedule.
|
|
4483
|
+
if (sseStreams.size >= MAX_SSE_STREAMS) {
|
|
4484
|
+
logInfo('event stream refused: ' + sseStreams.size + ' streams already open')
|
|
4485
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
4486
|
+
res.end(JSON.stringify({ ok: false, message: '事件流连接数已达上限(' + MAX_SSE_STREAMS + '),请稍后重试' }))
|
|
4487
|
+
return
|
|
4488
|
+
}
|
|
4035
4489
|
res.writeHead(200, {
|
|
4036
4490
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
4037
4491
|
'cache-control': 'no-store',
|
|
@@ -4102,6 +4556,23 @@ export function apply(ctx) {
|
|
|
4102
4556
|
res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
|
|
4103
4557
|
return
|
|
4104
4558
|
}
|
|
4559
|
+
// A request carrying TWO Host headers has no unambiguous origin: Node
|
|
4560
|
+
// exposes only the first in req.headers, while the proxy in front (or the
|
|
4561
|
+
// attacker's own client) may consider a different one authoritative. There
|
|
4562
|
+
// is no legitimate client of this route that sends two, so refuse rather
|
|
4563
|
+
// than guess which one the guard should believe.
|
|
4564
|
+
if (Array.isArray(req.rawHeaders)) {
|
|
4565
|
+
let hostCount = 0
|
|
4566
|
+
for (let index = 0; index < req.rawHeaders.length; index += 2) {
|
|
4567
|
+
if (String(req.rawHeaders[index]).toLowerCase() === 'host') hostCount += 1
|
|
4568
|
+
}
|
|
4569
|
+
if (hostCount > 1) {
|
|
4570
|
+
logInfo('route rejected a request with ' + hostCount + ' Host headers')
|
|
4571
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
4572
|
+
res.end(JSON.stringify({ ok: false, message: '请求带有多个 Host 头,已拒绝' }))
|
|
4573
|
+
return
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4105
4576
|
// Origin/Host guard, before anything else is read. See
|
|
4106
4577
|
// requestOriginVerdict() for why the content-type rule below is not enough
|
|
4107
4578
|
// on its own.
|
|
@@ -4116,10 +4587,15 @@ export function apply(ctx) {
|
|
|
4116
4587
|
// the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
|
|
4117
4588
|
// body) into a hard rule: a cross-site or malformed post now gets a
|
|
4118
4589
|
// readable 415 instead of a confusing parse failure.
|
|
4590
|
+
//
|
|
4591
|
+
// The match is on the MEDIA TYPE alone, exactly. A substring test let
|
|
4592
|
+
// `text/plain; note=application/json` through — and `text/plain` is a CORS
|
|
4593
|
+
// "simple" content type, so that one spelling bypasses the preflight that
|
|
4594
|
+
// the whole rule exists to rely on.
|
|
4119
4595
|
const contentType = String(
|
|
4120
4596
|
(req.headers !== null && typeof req.headers === 'object') ? (req.headers['content-type'] || '') : '',
|
|
4121
4597
|
)
|
|
4122
|
-
if (contentType
|
|
4598
|
+
if (mediaTypeOf(contentType) !== 'application/json') {
|
|
4123
4599
|
res.writeHead(415, { 'content-type': 'application/json; charset=utf-8' })
|
|
4124
4600
|
res.end(JSON.stringify({ ok: false, message: 'content-type 必须是 application/json' }))
|
|
4125
4601
|
return
|
|
@@ -4130,10 +4606,24 @@ export function apply(ctx) {
|
|
|
4130
4606
|
// the whole host process down over one dead HTTP request.
|
|
4131
4607
|
req.on('error', () => {})
|
|
4132
4608
|
res.on('error', () => {})
|
|
4133
|
-
|
|
4609
|
+
// A declared length far over the ceiling is refused before a single chunk
|
|
4610
|
+
// is buffered. The ceiling is counted in CHARACTERS, so a UTF-8 byte length
|
|
4611
|
+
// above 4× it cannot fit; buffering first and checking later is exactly the
|
|
4612
|
+
// memory cost this guard exists to avoid (~6 MB per concurrent request).
|
|
4613
|
+
const declaredLength = Number.parseInt(String(
|
|
4614
|
+
(req.headers !== null && typeof req.headers === 'object' ? req.headers['content-length'] : '') || '',
|
|
4615
|
+
), 10)
|
|
4616
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_CHARS * 4) {
|
|
4617
|
+
res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', connection: 'close' })
|
|
4618
|
+
res.end(JSON.stringify({ ok: false, message: '请求体超过 ' + MAX_BODY_CHARS + ' 字符上限' }))
|
|
4619
|
+
// Drain the rest so the client sees the 413 rather than a reset socket.
|
|
4620
|
+
req.resume()
|
|
4621
|
+
return
|
|
4622
|
+
}
|
|
4134
4623
|
// Over the ceiling we stop accumulating but keep draining, so the
|
|
4135
4624
|
// response can go out as a clean 413 instead of a destroyed socket the
|
|
4136
4625
|
// Client can only report as a generic network failure.
|
|
4626
|
+
let body = ''
|
|
4137
4627
|
let overflow = false
|
|
4138
4628
|
req.setEncoding('utf8')
|
|
4139
4629
|
req.on('data', (chunk) => {
|
|
@@ -4156,7 +4646,7 @@ export function apply(ctx) {
|
|
|
4156
4646
|
const payload = body === '' ? {} : JSON.parse(body)
|
|
4157
4647
|
result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
|
|
4158
4648
|
} catch (err) {
|
|
4159
|
-
result = { ok: false, saved: false, message:
|
|
4649
|
+
result = { ok: false, saved: false, message: describeErrorSafe(err), snapshot: snapshot() }
|
|
4160
4650
|
}
|
|
4161
4651
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
4162
4652
|
res.end(JSON.stringify(result))
|
|
@@ -4190,6 +4680,20 @@ export function apply(ctx) {
|
|
|
4190
4680
|
// F12: the settings page can only warn about a non-loopback bind if the
|
|
4191
4681
|
// snapshot carries what the route actually saw at registration time.
|
|
4192
4682
|
state.boundHost = boundHost
|
|
4683
|
+
// This callback runs again whenever the service is published anew (host
|
|
4684
|
+
// restart, fiber remount). Registering on top of the previous registration
|
|
4685
|
+
// would LEAK it: the old handler stays mounted and keeps answering from the
|
|
4686
|
+
// state it closed over, while `routeDispose` only remembers the newest one.
|
|
4687
|
+
// Drop the previous registration first — newest wins.
|
|
4688
|
+
if (routeDispose !== null) {
|
|
4689
|
+
const previous = routeDispose
|
|
4690
|
+
routeDispose = null
|
|
4691
|
+
try {
|
|
4692
|
+
previous()
|
|
4693
|
+
} catch (err) {
|
|
4694
|
+
console.error('[instruction-memory] previous route dispose failed:', err)
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4193
4697
|
try {
|
|
4194
4698
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
4195
4699
|
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|