dsh-workspace-plus 0.6.1 → 0.7.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.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * dsh-workspace-plus / sessionlog — outbound credential redaction.
3
+ *
4
+ * A session log is not just a conversation: it embeds every tool call the model
5
+ * made and every result that came back. In practice that means `npm config set
6
+ * …:_authToken=…`, `git` remotes with embedded credentials, `Authorization`
7
+ * headers echoed by curl, private key bodies read by an editor, and so on. Those
8
+ * values are ordinary strings in `event.data`, and nothing upstream removes them:
9
+ * DSH's own session-telemetry seam ships NO redaction rules by default, so an
10
+ * unmodified record leaves the machine exactly as captured.
11
+ *
12
+ * This module is applied to every event on its way out, before either upload
13
+ * channel sees it, and it cannot be switched off: a session log upload without
14
+ * redaction is a credential leak with a progress bar. Redaction never touches
15
+ * the canonical log — it operates on copies, so what the user can still read in
16
+ * their own session is unaffected.
17
+ *
18
+ * The patterns are deliberately shape-based (prefixes and key names that only
19
+ * exist for credentials) rather than entropy-based, so ordinary prose and code
20
+ * survive intact and the result is predictable.
21
+ *
22
+ * @module dsh-workspace-plus/sessionlog/redact
23
+ */
24
+
25
+ /** What a scrubbed value is replaced with. Stable so receivers can count redactions. */
26
+ export const REDACTED = '[REDACTED]'
27
+
28
+ /**
29
+ * Credential shapes, ordered most-specific first. Each `replace` keeps whatever
30
+ * context is useful (`_authToken=` stays, only the value goes) so a redacted log
31
+ * is still readable as a record of what happened.
32
+ */
33
+ export const CREDENTIAL_PATTERNS = Object.freeze([
34
+ // npm: the modern granular/automation token and the legacy `_authToken` line.
35
+ { name: 'npm-token', re: /npm_[A-Za-z0-9]{36,}/g, replace: `npm_${REDACTED}` },
36
+ {
37
+ name: 'npm-authtoken',
38
+ // The value may be bare, quoted, or JSON-escaped inside a nested string
39
+ // (a tool result that itself contains JSON is common), so the alternatives
40
+ // admit optional backslash-escaped quotes.
41
+ re: /(_authToken\s*[=:]\s*)(?:\\?"(?:[^"\\]|\\.)*\\?"|\\?'(?:[^'\\]|\\.)*\\?'|[^\s"'\\]+)/g,
42
+ replace: `$1${REDACTED}`,
43
+ },
44
+ // GitHub
45
+ { name: 'github-pat', re: /github_pat_[A-Za-z0-9_]{20,}/g, replace: `github_pat_${REDACTED}` },
46
+ { name: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}/g, replace: `gh*_${REDACTED}` },
47
+ // Model providers
48
+ { name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g, replace: `sk-ant-${REDACTED}` },
49
+ { name: 'openai-key', re: /\bsk-[A-Za-z0-9]{32,}/g, replace: `sk-${REDACTED}` },
50
+ // Bearer / basic auth in headers and command lines
51
+ { name: 'bearer', re: /([Bb]earer\s+)[A-Za-z0-9._~+/-]{20,}=*/g, replace: `$1${REDACTED}` },
52
+ { name: 'url-credentials', re: /(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/"'@:]+:[^\s/"'@]+@/gi, replace: `$1${REDACTED}@` },
53
+ // Other common ones that turn up in real logs
54
+ { name: 'aws-access-key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: `AKIA${REDACTED}` },
55
+ { name: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g, replace: `xox*-${REDACTED}` },
56
+ { name: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g, replace: `AIza${REDACTED}` },
57
+ // Private key bodies: everything between the markers goes.
58
+ {
59
+ name: 'private-key',
60
+ re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
61
+ replace: `-----BEGIN PRIVATE KEY-----${REDACTED}-----END PRIVATE KEY-----`,
62
+ },
63
+ ])
64
+
65
+ /**
66
+ * Scrub every credential shape out of one string.
67
+ *
68
+ * @param text - any string that may leave the machine.
69
+ * @returns the same text with credential shapes replaced.
70
+ */
71
+ export function redactText(text) {
72
+ if (typeof text !== 'string' || text === '') return text
73
+ let out = text
74
+ for (const pattern of CREDENTIAL_PATTERNS) {
75
+ out = out.replace(pattern.re, pattern.replace)
76
+ }
77
+ return out
78
+ }
79
+
80
+ /**
81
+ * An object key that means "the value is a credential". Deliberately anchored so
82
+ * ordinary measurements survive: `max_tokens`, `maxTokens` and `tokenCount` are
83
+ * data worth keeping, while `GITHUB_TOKEN`, `client_secret` and `apiKey` are not.
84
+ */
85
+ export const SECRET_KEY_PATTERN =
86
+ /^(?:.*[_-])?(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key|credentials?|authorization|auth)$/i
87
+
88
+ /**
89
+ * Scrub every string reachable from one JSON-ish value, on a copy.
90
+ *
91
+ * A depth bound keeps a pathological payload (a deeply nested tool result) from
92
+ * turning redaction into a stack overflow; past the bound the subtree is
93
+ * replaced by a single marker, which is the fail-closed direction.
94
+ *
95
+ * @param value - the value to copy and scrub.
96
+ * @param depth - remaining recursion allowance.
97
+ * @returns a scrubbed copy; the input is never mutated.
98
+ */
99
+ export function redactValue(value, depth = 32) {
100
+ if (typeof value === 'string') return redactText(value)
101
+ if (value === null || typeof value !== 'object') return value
102
+ if (depth <= 0) return REDACTED
103
+ if (Array.isArray(value)) return value.map((child) => redactValue(child, depth - 1))
104
+ if (value instanceof Uint8Array) return value
105
+ const out = {}
106
+ for (const [key, child] of Object.entries(value)) {
107
+ // A secret-named key keeps its name — the log still says what was there — and loses its value.
108
+ out[key] = SECRET_KEY_PATTERN.test(key) ? REDACTED : redactValue(child, depth - 1)
109
+ }
110
+ return out
111
+ }
112
+
113
+ /**
114
+ * Copy one canonical event with its payload scrubbed. The envelope (`seq`,
115
+ * `time`, `type`, surface metadata) is structural and passes through unchanged.
116
+ *
117
+ * @param event - a canonical session event.
118
+ * @returns a redacted copy.
119
+ */
120
+ export function redactEvent(event) {
121
+ if (event === null || typeof event !== 'object') return event
122
+ const copy = { ...event }
123
+ if (event.data !== undefined) copy.data = redactValue(event.data)
124
+ return copy
125
+ }
126
+
127
+ /**
128
+ * Whether any pattern is at least present in a record, for the diagnostics the
129
+ * settings page shows. Reports which pattern families matched, never the values.
130
+ *
131
+ * @param value - the value that was (or will be) sent.
132
+ * @returns the sorted names of the credential shapes found.
133
+ */
134
+ export function detectCredentialShapes(value) {
135
+ const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''
136
+ const found = new Set()
137
+ for (const pattern of CREDENTIAL_PATTERNS) {
138
+ pattern.re.lastIndex = 0
139
+ if (pattern.re.test(text)) found.add(pattern.name)
140
+ }
141
+ return [...found]
142
+ }
@@ -0,0 +1,436 @@
1
+ /**
2
+ * dsh-workspace-plus / sessionlog — pure settings layer.
3
+ *
4
+ * Everything in this file is pure or filesystem-only, so tools/verify.mjs can
5
+ * exercise it without a running Harness and without DSH's own services. The
6
+ * two-layer shape is the same one `lib/notice/settings.js` uses:
7
+ *
8
+ * 1. **bundle config** — the keys a deployment writes under the loader row's
9
+ * `config:` in `cordis.patch.yml`. They are deployment defaults and
10
+ * survive upgrades.
11
+ * 2. **override store** — `$DSH_HOME/sessionlog/settings.json` holds only the
12
+ * keys the user changed on the 会话日志 settings page. "Restore default"
13
+ * deletes the key instead of writing the default value.
14
+ *
15
+ * `enabled` defaults to `false`: this sub-feature ships OFF. Uploading a
16
+ * session log hands the machine's conversation content to a remote collector,
17
+ * so turning it on has to be an explicit act by whoever runs this host.
18
+ *
19
+ * @module dsh-workspace-plus/sessionlog/settings
20
+ */
21
+
22
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
23
+ import { randomUUID } from 'node:crypto'
24
+ import { homedir } from 'node:os'
25
+ import { dirname, join, resolve } from 'node:path'
26
+
27
+ /**
28
+ * Shipping collection endpoint. The same DeepSeek-operated collector the base
29
+ * profile's own OpenTelemetry session backend uses, so a deployment that has
30
+ * already pointed `DSH_TELEMETRY_OTLP_URL` at an isolated collector gets the
31
+ * same destination here for free.
32
+ */
33
+ export const DEFAULT_ENDPOINT = 'https://dsh-otel-collector.deepseeksvc.com/v1/logs'
34
+
35
+ /** Environment variable that overrides {@link DEFAULT_ENDPOINT}. */
36
+ export const ENDPOINT_ENV = 'DSH_TELEMETRY_OTLP_URL'
37
+
38
+ /**
39
+ * Process-level telemetry opt-out. DSH's base profile patches its own
40
+ * session-telemetry row `disabled` when this is set, and treats ANY non-empty
41
+ * value — including `0` and `false` — as "off".
42
+ */
43
+ export const TELEMETRY_DISABLED_ENV = 'DSH_TELEMETRY_DISABLED'
44
+
45
+ /** Selective-telemetry mode variable; `DISABLED` is the value that means "off". */
46
+ export const TELEMETRY_MODE_ENV = 'DSH_TELEMETRY_MODE'
47
+
48
+ /**
49
+ * Whether the process has opted out of telemetry entirely.
50
+ *
51
+ * A deployment that exports `DSH_TELEMETRY_DISABLED` (or sets
52
+ * `DSH_TELEMETRY_MODE=DISABLED`) has said "nothing leaves this machine", and
53
+ * that has to outrank a setting this plugin's own page can flip. Honouring it
54
+ * is what keeps the plugin from being a way around the host's opt-out.
55
+ *
56
+ * @param env - environment snapshot (injectable for tests).
57
+ * @returns the reason to stay silent, or `null` when the process has not opted out.
58
+ */
59
+ export function telemetryKillSwitch(env = process.env) {
60
+ const disabled = env[TELEMETRY_DISABLED_ENV]
61
+ if (typeof disabled === 'string' && disabled !== '') {
62
+ return `${TELEMETRY_DISABLED_ENV} 已设置,进程级关闭遥测`
63
+ }
64
+ const mode = typeof env[TELEMETRY_MODE_ENV] === 'string' ? env[TELEMETRY_MODE_ENV].trim().toUpperCase() : ''
65
+ if (mode === 'DISABLED') return `${TELEMETRY_MODE_ENV}=DISABLED,进程级关闭遥测`
66
+ return null
67
+ }
68
+
69
+ /**
70
+ * Whether uploading may run at all: the switch is on AND the process has not
71
+ * opted out of telemetry.
72
+ *
73
+ * @param settings - effective settings.
74
+ * @param env - environment snapshot (injectable for tests).
75
+ * @returns the reason uploading is suppressed, or `null` when it may run.
76
+ */
77
+ export function uploadBlockedBy(settings, env = process.env) {
78
+ if (settings?.enabled !== true) return '开关未开启'
79
+ return telemetryKillSwitch(env)
80
+ }
81
+
82
+ /** Store format version. */
83
+ export const STORE_VERSION = 1
84
+
85
+ /**
86
+ * Defaults for every setting. The single source of truth: `normalizeSettings`
87
+ * derives from it, the settings page renders it, and "restore default" means
88
+ * "drop the override so this value applies again".
89
+ *
90
+ * `requestField` is the second, provider-coupled half of the feature: when the
91
+ * official DeepSeek adapter performs the request and no other plugin already
92
+ * owns the `dsh_session_log` field (in a shipped profile the built-in
93
+ * `@deepseek-ai/dsh-session-log-deepseek` does), this sub-feature contributes
94
+ * the field in the shipped v1 wire format.
95
+ *
96
+ * It ships OFF for a reason that is not obvious from its name. DSH performs no
97
+ * official-endpoint check before attaching that field — the only condition is
98
+ * "the request came from the DeepSeek adapter" — and the adapter's `baseURL` is
99
+ * freely configurable. A host whose `deepseek-official` route points at a
100
+ * third-party relay would therefore send conversation content to that relay,
101
+ * while this plugin's switch claims to upload to DeepSeek. Making it an
102
+ * explicit opt-in keeps the parity fallback available without that surprise.
103
+ */
104
+ export const DEFAULT_SETTINGS = Object.freeze({
105
+ enabled: false,
106
+ endpoint: '',
107
+ requestField: false,
108
+ includeHistory: true,
109
+ maxRequestBytes: 4000000,
110
+ shutdownTimeoutMillis: 3000,
111
+ })
112
+
113
+ /** Every key the override store is allowed to carry. */
114
+ export const SETTING_KEYS = Object.freeze(Object.keys(DEFAULT_SETTINGS))
115
+
116
+ /** Upper bound for `maxRequestBytes`, mirroring DSH's own session-log ceiling. */
117
+ export const MAX_REQUEST_BYTES_CEILING = 4000000
118
+
119
+ /** Upper bound for `shutdownTimeoutMillis`. */
120
+ export const MAX_SHUTDOWN_TIMEOUT_MILLIS = 60000
121
+
122
+ /** @returns a fresh defaults object (never a shared reference). */
123
+ export function defaultSettings() {
124
+ return { ...DEFAULT_SETTINGS }
125
+ }
126
+
127
+ /**
128
+ * Coerce anything into `enabled`/`requestField`/`includeHistory` booleans.
129
+ * YAML is the reason this exists: a hand-edited `config:` block can deliver
130
+ * `'false'`, `'no'`, `'off'` or `0`, and treating the string `'false'` as truthy
131
+ * would silently switch uploading ON.
132
+ *
133
+ * @param value - the raw value.
134
+ * @param fallback - the value to use when nothing usable was supplied.
135
+ * @returns a boolean.
136
+ */
137
+ export function coerceBoolean(value, fallback) {
138
+ if (typeof value === 'boolean') return value
139
+ if (typeof value === 'number') return value !== 0
140
+ if (typeof value === 'string') {
141
+ const text = value.trim().toLowerCase()
142
+ if (['true', '1', 'yes', 'on'].includes(text)) return true
143
+ if (['false', '0', 'no', 'off'].includes(text)) return false
144
+ }
145
+ return fallback
146
+ }
147
+
148
+ /**
149
+ * Clamp a byte/time count into `[0, max]`.
150
+ *
151
+ * An absent or blank value is the fallback, not zero: `Number('')` is `0`, so a
152
+ * naive coercion would turn every omitted key into a hard zero — a zero-length
153
+ * upload budget silently disables the whole feature.
154
+ *
155
+ * @param value - the raw value.
156
+ * @param fallback - the value to use when nothing usable was supplied.
157
+ * @param max - inclusive upper bound.
158
+ * @returns a non-negative integer.
159
+ */
160
+ export function coerceCount(value, fallback, max = Number.MAX_SAFE_INTEGER) {
161
+ if (value === null || value === undefined) return fallback
162
+ if (typeof value === 'string' && value.trim() === '') return fallback
163
+ const number = typeof value === 'number' ? value : Number(String(value).trim())
164
+ if (!Number.isFinite(number) || number < 0) return fallback
165
+ return Math.min(Math.floor(number), max)
166
+ }
167
+
168
+ /**
169
+ * Coerce the endpoint. Only an absolute `http(s)` URL is accepted; anything
170
+ * else falls back to `''`, which means "use the environment variable, else the
171
+ * shipping collector". A bad endpoint must never be silently used as a request
172
+ * target, and it must never silently disable uploading either — hence the
173
+ * separate `warning` channel on {@link normalizeEndpoint}.
174
+ *
175
+ * @param value - the raw value.
176
+ * @returns `{ value, warning }`, where `value` is `''` or a normalized URL.
177
+ */
178
+ export function normalizeEndpoint(value) {
179
+ if (value === null || value === undefined) return { value: '', warning: null }
180
+ const text = String(value).trim()
181
+ if (text === '') return { value: '', warning: null }
182
+ let parsed
183
+ try {
184
+ parsed = new URL(text)
185
+ } catch {
186
+ return { value: '', warning: `endpoint 不是合法 URL,已回落默认值:${text}` }
187
+ }
188
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
189
+ return { value: '', warning: `endpoint 必须是 http(s),已回落默认值:${text}` }
190
+ }
191
+ return { value: parsed.toString(), warning: null }
192
+ }
193
+
194
+ /**
195
+ * Fill in every default and drop every unusable value.
196
+ *
197
+ * @param input - the raw settings object, or anything else.
198
+ * @returns a complete settings object.
199
+ */
200
+ export function normalizeSettings(input) {
201
+ const raw = input !== null && typeof input === 'object' && !Array.isArray(input) ? input : {}
202
+ const endpoint = normalizeEndpoint(raw.endpoint)
203
+ return {
204
+ enabled: coerceBoolean(raw.enabled, DEFAULT_SETTINGS.enabled),
205
+ endpoint: endpoint.value,
206
+ requestField: coerceBoolean(raw.requestField, DEFAULT_SETTINGS.requestField),
207
+ includeHistory: coerceBoolean(raw.includeHistory, DEFAULT_SETTINGS.includeHistory),
208
+ maxRequestBytes: coerceCount(raw.maxRequestBytes, DEFAULT_SETTINGS.maxRequestBytes, MAX_REQUEST_BYTES_CEILING),
209
+ shutdownTimeoutMillis: coerceCount(raw.shutdownTimeoutMillis, DEFAULT_SETTINGS.shutdownTimeoutMillis, MAX_SHUTDOWN_TIMEOUT_MILLIS),
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Merge a base (bundle config) with user overrides. Overrides win key by key;
215
+ * an absent key keeps the base value.
216
+ *
217
+ * @param base - the bundle-config defaults.
218
+ * @param overrides - what the settings page persisted.
219
+ * @returns the effective settings.
220
+ */
221
+ export function composeSettings(base, overrides) {
222
+ const normalizedBase = normalizeSettings(base)
223
+ const patch = {}
224
+ for (const key of SETTING_KEYS) {
225
+ if (overrides !== null && typeof overrides === 'object' && Object.prototype.hasOwnProperty.call(overrides, key)) {
226
+ patch[key] = overrides[key]
227
+ }
228
+ }
229
+ return normalizeSettings({ ...normalizedBase, ...patch })
230
+ }
231
+
232
+ /**
233
+ * Keep only the keys this version understands. A newer build's store must not
234
+ * push values into an older build, and a hand-edited file must not smuggle
235
+ * arbitrary keys into the effective settings.
236
+ *
237
+ * @param patch - the requested override keys.
238
+ * @returns a normalized override object carrying only known keys.
239
+ */
240
+ export function mergeOverrides(patch) {
241
+ const raw = patch !== null && typeof patch === 'object' && !Array.isArray(patch) ? patch : {}
242
+ const overrides = {}
243
+ for (const key of SETTING_KEYS) {
244
+ if (Object.prototype.hasOwnProperty.call(raw, key)) overrides[key] = raw[key]
245
+ }
246
+ return overrides
247
+ }
248
+
249
+ /**
250
+ * Drop one override so the base value applies again.
251
+ *
252
+ * @param overrides - the current overrides.
253
+ * @param key - the key to drop.
254
+ * @returns a fresh overrides object.
255
+ */
256
+ export function clearOverride(overrides, key) {
257
+ const next = { ...(overrides !== null && typeof overrides === 'object' ? overrides : {}) }
258
+ delete next[key]
259
+ return next
260
+ }
261
+
262
+ /**
263
+ * Locate the Harness home the same way the rest of the plugin and the rest of
264
+ * the Harness do: `DSH_HOME` when set and non-blank, else `~/.dsh`.
265
+ *
266
+ * @param env - environment snapshot (injectable for tests).
267
+ * @returns the absolute Harness home directory.
268
+ */
269
+ export function dshHome(env = process.env) {
270
+ const fromEnv = env.DSH_HOME
271
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim())
272
+ return join(homedir(), '.dsh')
273
+ }
274
+
275
+ /**
276
+ * Absolute path of this sub-feature's override store.
277
+ *
278
+ * @param env - environment snapshot (injectable for tests).
279
+ * @returns the settings file path.
280
+ */
281
+ export function storePath(env = process.env) {
282
+ return join(dshHome(env), 'sessionlog', 'settings.json')
283
+ }
284
+
285
+ /** Filename of the Harness-wide anonymous user id, shared with DSH's telemetry. */
286
+ export const ANONYMOUS_USER_ID_FILE_NAME = '.anonymous-user-id'
287
+
288
+ /** Anonymous user id files are bare UUID lines; anything else is corruption. */
289
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
290
+
291
+ /**
292
+ * Read a valid persisted anonymous user id.
293
+ *
294
+ * @param file - the id file path.
295
+ * @returns the id, or `undefined` when absent or corrupt.
296
+ */
297
+ function readAnonymousUserId(file) {
298
+ try {
299
+ const text = readFileSync(file, 'utf8').trim()
300
+ return UUID_PATTERN.test(text) ? text : undefined
301
+ } catch {
302
+ return undefined
303
+ }
304
+ }
305
+
306
+ /**
307
+ * The harness home's anonymous user id, created on first use.
308
+ *
309
+ * This deliberately mirrors `@deepseek-ai/dsh-anonymous-user-id` byte for byte
310
+ * — same file, same bare-UUID + newline format, same `wx` first-writer-wins
311
+ * create — because the id is a resource attribute the collector already uses to
312
+ * group one machine's uploads. Minting a different identifier here would split
313
+ * the same host into two identities. Persistence stays best-effort: a read-only
314
+ * home still yields a usable per-run id so uploading is never blocked by it.
315
+ *
316
+ * @param env - environment snapshot (injectable for tests).
317
+ * @param makeId - id generator (injectable for tests).
318
+ * @returns the id, or `''` when it could not be produced at all.
319
+ */
320
+ export function anonymousUserId(env = process.env, makeId = randomUUID) {
321
+ const file = join(dshHome(env), ANONYMOUS_USER_ID_FILE_NAME)
322
+ const existing = readAnonymousUserId(file)
323
+ if (existing !== undefined) return existing
324
+ const created = makeId()
325
+ if (!UUID_PATTERN.test(created)) return ''
326
+ try {
327
+ mkdirSync(dirname(file), { recursive: true })
328
+ writeFileSync(file, `${created}\n`, { encoding: 'utf8', flag: 'wx' })
329
+ return created
330
+ } catch {
331
+ return readAnonymousUserId(file) ?? created
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Report the effective collector endpoint: the settings override first, then
337
+ * `DSH_TELEMETRY_OTLP_URL`, then the shipping collector.
338
+ *
339
+ * @param settings - effective settings (normalized or raw).
340
+ * @param env - environment snapshot (injectable for tests).
341
+ * @returns the endpoint URL.
342
+ */
343
+ export function resolveEndpoint(settings, env = process.env) {
344
+ const explicit = normalizeEndpoint(settings?.endpoint).value
345
+ if (explicit !== '') return explicit
346
+ const fromEnv = typeof env[ENDPOINT_ENV] === 'string' ? env[ENDPOINT_ENV].trim() : ''
347
+ if (fromEnv !== '') return normalizeEndpoint(fromEnv).value || DEFAULT_ENDPOINT
348
+ return DEFAULT_ENDPOINT
349
+ }
350
+
351
+ /**
352
+ * Normalize the accepted-watermark map used by the `dsh_session_log` request
353
+ * field: `{ <sessionId>: { formatVersion, throughSeq } }`.
354
+ *
355
+ * The watermark lives here rather than in a canonical session event on purpose.
356
+ * The shipped package records acceptance as its own event type, but that event
357
+ * type is baked into the Session format migrations, and a third-party plugin
358
+ * must not add a new type to the canonical log. Keeping the cursor in this
359
+ * plugin's own store gets the same "do not resend the accepted range" property
360
+ * without touching the session format at all.
361
+ *
362
+ * @param raw - the parsed `accepted` value, or anything else.
363
+ * @returns a clean watermark map.
364
+ */
365
+ export function normalizeAccepted(raw) {
366
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return {}
367
+ const accepted = {}
368
+ for (const [sessionId, entry] of Object.entries(raw)) {
369
+ if (sessionId === '' || entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
370
+ const throughSeq = Number(entry.throughSeq)
371
+ const formatVersion = Number(entry.formatVersion)
372
+ if (!Number.isSafeInteger(throughSeq) || throughSeq < 0) continue
373
+ if (!Number.isSafeInteger(formatVersion) || formatVersion < 0) continue
374
+ accepted[sessionId] = { formatVersion, throughSeq }
375
+ }
376
+ return accepted
377
+ }
378
+
379
+ /**
380
+ * Read the store, tolerating a missing, unreadable, or corrupt file. A corrupt
381
+ * store degrades to "no overrides, no watermarks" with a warning; it must never
382
+ * stop the Harness from booting, and it must never be guessed at (a corrupt
383
+ * file cannot turn uploading on, and a corrupt watermark only causes a resend).
384
+ *
385
+ * @param file - the store path.
386
+ * @returns `{ overrides, accepted, warning }`.
387
+ */
388
+ export function readStore(file) {
389
+ let parsed
390
+ try {
391
+ parsed = JSON.parse(readFileSync(file, 'utf8'))
392
+ } catch {
393
+ return { overrides: {}, accepted: {}, warning: null }
394
+ }
395
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
396
+ return { overrides: {}, accepted: {}, warning: `${file} 不是对象;已按默认值处理` }
397
+ }
398
+ const warnings = []
399
+ let overrides = {}
400
+ if (parsed.overrides !== undefined) {
401
+ if (parsed.overrides !== null && typeof parsed.overrides === 'object' && !Array.isArray(parsed.overrides)) {
402
+ overrides = mergeOverrides(parsed.overrides)
403
+ } else {
404
+ warnings.push('overrides 不是对象')
405
+ }
406
+ }
407
+ return {
408
+ overrides,
409
+ accepted: normalizeAccepted(parsed.accepted),
410
+ warning: warnings.length === 0 ? null : `${file} 的 ${warnings.join('、')};已按默认值处理`,
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Persist the store atomically (temp file + rename) and never throw.
416
+ *
417
+ * @param file - the store path.
418
+ * @param store - `{ overrides, accepted }` to persist.
419
+ * @returns `{ ok, message }`.
420
+ */
421
+ export function writeStore(file, store) {
422
+ try {
423
+ mkdirSync(dirname(file), { recursive: true })
424
+ const temporary = `${file}.tmp`
425
+ const document = {
426
+ version: STORE_VERSION,
427
+ overrides: mergeOverrides(store?.overrides),
428
+ accepted: normalizeAccepted(store?.accepted),
429
+ }
430
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8')
431
+ renameSync(temporary, file)
432
+ return { ok: true, message: `saved ${file}` }
433
+ } catch (error) {
434
+ return { ok: false, message: String((error && error.message) || error) }
435
+ }
436
+ }