dsh-workspace-plus 0.6.0 → 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.
- package/README.md +107 -572
- package/cordis.patch.yml +33 -1
- package/lib/client.js +448 -11
- package/lib/index.js +63 -6
- package/lib/notice/click-host.cs +515 -0
- package/lib/notice/index.js +114 -20
- package/lib/notice/launcher.js +455 -0
- package/lib/notice/win32.js +26 -2
- package/lib/sessionlog/icon.svg +19 -0
- package/lib/sessionlog/index.js +636 -0
- package/lib/sessionlog/locale/en.json +6 -0
- package/lib/sessionlog/locale/zh.json +6 -0
- package/lib/sessionlog/package.json +6 -0
- package/lib/sessionlog/redact.js +142 -0
- package/lib/sessionlog/settings.js +436 -0
- package/lib/sessionlog/wire.js +235 -0
- package/package.json +11 -3
|
@@ -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
|
+
}
|