dsh-deepseek-style-theme 1.43.1
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/LICENSE +21 -0
- package/README.md +248 -0
- package/THIRD-PARTY-NOTICES.md +95 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +3775 -0
- package/lib/index.js +824 -0
- package/package.json +59 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
// Host side of the DeepSeek-style theme plugin.
|
|
2
|
+
// Registers a Package-private RPC channel the browser half uses to open a
|
|
3
|
+
// workspace's directory in the OS file manager and bring the window to the
|
|
4
|
+
// foreground. The built-in host.openPath (Invoke-Item) leaves the Explorer
|
|
5
|
+
// window in the background when spawned from this windowless service process,
|
|
6
|
+
// so this plugin owns the whole "open workspace" gesture instead. The same
|
|
7
|
+
// channel carries the delivered-file gestures the browser half's card menu
|
|
8
|
+
// needs: open with the default application, and reveal in the file manager.
|
|
9
|
+
// It also owns the DSTT settings section and, once per activation, aligns the
|
|
10
|
+
// official DeepSeek route's advisory model catalog with the ids the endpoint
|
|
11
|
+
// actually advertises (see the catalog-sync block below).
|
|
12
|
+
//
|
|
13
|
+
// Wire contract: every return is an RpcResult<T> per rpc.schema.js. Error
|
|
14
|
+
// codes must come from the closed enum (bad-request / internal) — an
|
|
15
|
+
// off-enum code would make the connection layer's serverResponseSchema.parse
|
|
16
|
+
// reject the frame and surface as a carrier failure instead of a clean error.
|
|
17
|
+
//
|
|
18
|
+
// The channel is fenced exactly like the core's own /api route: a loopback
|
|
19
|
+
// peer, an authority this server answers on, agreement between Host and any
|
|
20
|
+
// provenance headers, a JSON body, and local absolute paths only — see
|
|
21
|
+
// isTrustedBridgeRequest. Nothing here may widen that fence without widening
|
|
22
|
+
// the core's.
|
|
23
|
+
import { execFile } from "node:child_process";
|
|
24
|
+
import { dirname, isAbsolute } from "node:path";
|
|
25
|
+
|
|
26
|
+
// Schemastery is this bundle's only runtime dependency, and it is loaded
|
|
27
|
+
// defensively. `settings.register()` genuinely needs a real schema — the
|
|
28
|
+
// settings service calls it as a function (`resolve`) and serializes it through
|
|
29
|
+
// `schema.toJSON()` plus a `redactSecrets` walk inside `describe()` — so a
|
|
30
|
+
// hand-rolled stand-in cannot work. What it must not do is take the whole
|
|
31
|
+
// plugin tree down when the package is unresolvable: with an empty profile
|
|
32
|
+
// (`autoInstallPeers: false`) or a `link:` install whose real path sits outside
|
|
33
|
+
// the profile, a bare `import` here fails the loader entry and the GUI refuses
|
|
34
|
+
// to start at all. The guarded dynamic import converts that into one lost
|
|
35
|
+
// preference.
|
|
36
|
+
let z = null;
|
|
37
|
+
try {
|
|
38
|
+
const schemastery = await import("@deepseek-ai/schemastery");
|
|
39
|
+
const schema = schemastery.default ?? null;
|
|
40
|
+
if (schema !== null && typeof schema.object === "function") z = schema;
|
|
41
|
+
} catch {
|
|
42
|
+
// Reported once, below, where the remedy is actionable.
|
|
43
|
+
}
|
|
44
|
+
if (z === null) {
|
|
45
|
+
console.warn(
|
|
46
|
+
"[deepseek-style-theme] @deepseek-ai/schemastery could not be loaded, so the DSTT mode will not persist (the theme itself still works). Install it beside the plugin: dsh plugin --profile web add @deepseek-ai/schemastery@^3.18.2"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CHANNEL = "/dshome-open-workspace";
|
|
51
|
+
/** Hard cap per opener process: a hung COM call must not hang the RPC forever. */
|
|
52
|
+
const OPEN_TIMEOUT_MS = 10000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Durable DSTT (DeepSeekStyleTheme) preference section, registered into the
|
|
56
|
+
* host settings document exactly like the product's own namespaces
|
|
57
|
+
* (ui-theme/locale/ui-conversation): the client half binds this namespace
|
|
58
|
+
* through `settingsScope` and reads/writes the preference fields.
|
|
59
|
+
* A single `mode` enum drives the whole color story:
|
|
60
|
+
* - peakvalley-redblue → vivid red (鲜红) at peak hours, blue at valley;
|
|
61
|
+
* - peakvalley-redgreen → vivid red (鲜红) at peak hours, green at valley;
|
|
62
|
+
* - always-green → always green, no peak distinction;
|
|
63
|
+
* - always-blue → always blue, no peak distinction.
|
|
64
|
+
*/
|
|
65
|
+
// `settingsNamespace` from @deepseek-ai/dsh-settings was deleted in dsh
|
|
66
|
+
// 0.1.2-alpha.1 (the settings service itself is unchanged - register /
|
|
67
|
+
// describe / mutate below still take the namespace string), so this is now
|
|
68
|
+
// a plain literal. Keep it in sync with the client half's DSTT_NS.
|
|
69
|
+
const DSTT_SETTINGS_NS = "deepseek-style-theme";
|
|
70
|
+
const DSTT_MODES = ["peakvalley-redblue", "peakvalley-redgreen", "always-green", "always-blue"];
|
|
71
|
+
const DSTT_MODE_DEFAULT = "peakvalley-redblue";
|
|
72
|
+
// 1.37.x persisted `auto` / `blue` / `green`. The schema still accepts them —
|
|
73
|
+
// otherwise register() validates the stored document and throws, killing DSTT
|
|
74
|
+
// persistence on upgrade — and apply() migrates them to the closest four-mode
|
|
75
|
+
// value right after registration.
|
|
76
|
+
const DSTT_LEGACY_MODES = { auto: "peakvalley-redblue", blue: "always-blue", green: "always-green" };
|
|
77
|
+
/**
|
|
78
|
+
* Catalog-sync policies, most automatic first:
|
|
79
|
+
* - auto → align the catalog with the endpoint, but only while every
|
|
80
|
+
* advertised id is one this plugin can describe (see syncDeepseekCatalog);
|
|
81
|
+
* - add → append-only: adopt newly advertised ids, remove nothing ever;
|
|
82
|
+
* - off → probe and report drift, write nothing.
|
|
83
|
+
*/
|
|
84
|
+
const CATALOG_SYNC_POLICIES = ["auto", "add", "off"];
|
|
85
|
+
const CATALOG_SYNC_DEFAULT = "auto";
|
|
86
|
+
// Glass recipes. `liquid` is the Apple-style lens (edge refraction, a flowing
|
|
87
|
+
// edge highlight, high translucency); `frosted` is the white frosted pane with
|
|
88
|
+
// the brand-tinted shifting border this theme shipped before, kept byte for byte
|
|
89
|
+
// because that is the look the user picked. The client mirrors the choice onto
|
|
90
|
+
// <html data-dshome-glass> so one stylesheet can gate both recipes.
|
|
91
|
+
const GLASS_STYLES = ["liquid", "frosted"];
|
|
92
|
+
const GLASS_STYLE_DEFAULT = "liquid";
|
|
93
|
+
// `null` when Schemastery failed to load: registration is then skipped and the
|
|
94
|
+
// DSTT mode simply stops persisting instead of failing the plugin tree.
|
|
95
|
+
const DsttSettingsSchema = z === null ? null : z.object({
|
|
96
|
+
mode: z.union([
|
|
97
|
+
z.const("peakvalley-redblue"),
|
|
98
|
+
z.const("peakvalley-redgreen"),
|
|
99
|
+
z.const("always-green"),
|
|
100
|
+
z.const("always-blue"),
|
|
101
|
+
z.const("auto"),
|
|
102
|
+
z.const("blue"),
|
|
103
|
+
z.const("green")
|
|
104
|
+
]).default(DSTT_MODE_DEFAULT),
|
|
105
|
+
catalogSync: z.union([
|
|
106
|
+
z.const("auto"),
|
|
107
|
+
z.const("add"),
|
|
108
|
+
z.const("off")
|
|
109
|
+
]).default(CATALOG_SYNC_DEFAULT),
|
|
110
|
+
// The fluid background's pointer brush writes a velocity wake into the flow
|
|
111
|
+
// field wherever the cursor goes. It is both the most expensive part of the
|
|
112
|
+
// simulation and the most intrusive visually, so it ships OFF; this setting
|
|
113
|
+
// exists only to turn it back on.
|
|
114
|
+
fluidBrush: z.boolean().default(false),
|
|
115
|
+
glassStyle: z.union([
|
|
116
|
+
z.const("liquid"),
|
|
117
|
+
z.const("frosted")
|
|
118
|
+
]).default(GLASS_STYLE_DEFAULT)
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/** Map a legacy 1.37.x mode id onto a four-mode id, or null when nothing to do. */
|
|
122
|
+
function migrateLegacyMode(mode) {
|
|
123
|
+
return typeof mode === "string" && Object.prototype.hasOwnProperty.call(DSTT_LEGACY_MODES, mode)
|
|
124
|
+
? DSTT_LEGACY_MODES[mode]
|
|
125
|
+
: null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** PowerShell single-quoted literal (doubles embedded quotes). */
|
|
129
|
+
function powershellLiteral(path) {
|
|
130
|
+
return `'${path.replace(/'/g, "''")}'`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Open one path with Shell.Application, then focus the matching Explorer
|
|
135
|
+
* window. The open step is strict: any COM failure exits the script non-zero
|
|
136
|
+
* so the caller reports a real failure instead of a false success. Focusing
|
|
137
|
+
* stays best-effort — a directory that opened but could not be focused is
|
|
138
|
+
* still a successful open.
|
|
139
|
+
*/
|
|
140
|
+
function openExplorerForeground(path) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const script = [
|
|
143
|
+
"$ErrorActionPreference = 'Stop'",
|
|
144
|
+
`$p = ${powershellLiteral(path)}`,
|
|
145
|
+
"$sh = New-Object -ComObject Shell.Application",
|
|
146
|
+
"try { $sh.Open($p) } catch { exit 1 }",
|
|
147
|
+
"Start-Sleep -Milliseconds 500",
|
|
148
|
+
"$f = $false",
|
|
149
|
+
"foreach ($w in @($sh.Windows())) {",
|
|
150
|
+
" try { if ($w.Document.Folder.Self.Path -ieq $p) { $null = $w.Focus(); $f = $true; break } } catch {}",
|
|
151
|
+
"}",
|
|
152
|
+
"if (-not $f) { $null = (New-Object -ComObject WScript.Shell).AppActivate((Split-Path $p -Leaf)) }"
|
|
153
|
+
].join("; ");
|
|
154
|
+
execFile("powershell.exe", ["-NoProfile", "-Command", script], { windowsHide: true, timeout: OPEN_TIMEOUT_MS }, (error) => {
|
|
155
|
+
resolve(error === null || error === undefined);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run a simple command and resolve with whether it exited cleanly.
|
|
162
|
+
* `lenient` counts a numeric exit status as success: some platform tools
|
|
163
|
+
* (notably `explorer.exe`, which returns 1 after a successful `/select`) report
|
|
164
|
+
* failure while having done the work, so only a spawn failure is a failure.
|
|
165
|
+
*/
|
|
166
|
+
function runOpener(command, args, lenient = false) {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
execFile(command, args, { windowsHide: true, timeout: OPEN_TIMEOUT_MS }, (error) => {
|
|
169
|
+
if (error === null || error === undefined) {
|
|
170
|
+
resolve(true);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
resolve(lenient && typeof error.code === "number");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Open a directory in the platform's default file manager. */
|
|
179
|
+
function openPathGeneric(path) {
|
|
180
|
+
switch (process.platform) {
|
|
181
|
+
case "win32":
|
|
182
|
+
return openExplorerForeground(path);
|
|
183
|
+
case "darwin":
|
|
184
|
+
return runOpener("open", [path]);
|
|
185
|
+
case "linux":
|
|
186
|
+
return runOpener("xdg-open", [path]);
|
|
187
|
+
default:
|
|
188
|
+
return Promise.resolve(false);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Open one file with the OS default application. Deliberately not
|
|
194
|
+
* {@link openPathGeneric}: on Windows `Shell.Application.Open` on a file is the
|
|
195
|
+
* shell's own gesture, while `Start-Process` hands the path to the registered
|
|
196
|
+
* handler, which is what "用默认应用打开" means.
|
|
197
|
+
*/
|
|
198
|
+
function openFileGeneric(path) {
|
|
199
|
+
switch (process.platform) {
|
|
200
|
+
case "win32":
|
|
201
|
+
// `-FilePath`, not `-LiteralPath`: Start-Process has no -LiteralPath
|
|
202
|
+
// parameter (Windows PowerShell 5.1 rejects it with
|
|
203
|
+
// NamedParameterNotFound), so the literal quoting comes from
|
|
204
|
+
// powershellLiteral alone. Start-Process resolves through
|
|
205
|
+
// ShellExecute, which is what invokes the registered default handler.
|
|
206
|
+
return runOpener("powershell.exe", ["-NoProfile", "-Command", `Start-Process -FilePath ${powershellLiteral(path)}`]);
|
|
207
|
+
case "darwin":
|
|
208
|
+
return runOpener("open", [path]);
|
|
209
|
+
case "linux":
|
|
210
|
+
return runOpener("xdg-open", [path]);
|
|
211
|
+
default:
|
|
212
|
+
return Promise.resolve(false);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Reveal one file in the platform's file manager, selected where supported. */
|
|
217
|
+
function revealFileGeneric(path) {
|
|
218
|
+
switch (process.platform) {
|
|
219
|
+
case "win32":
|
|
220
|
+
return runOpener("explorer.exe", ["/select," + path], true);
|
|
221
|
+
case "darwin":
|
|
222
|
+
return runOpener("open", ["-R", path]);
|
|
223
|
+
case "linux":
|
|
224
|
+
return runOpener("xdg-open", [dirname(path)]);
|
|
225
|
+
default:
|
|
226
|
+
return Promise.resolve(false);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Uniform RpcResult failure branch. */
|
|
231
|
+
function failure(code, message, details) {
|
|
232
|
+
return { ok: false, error: { code, message, details } };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Advisory model-catalog sync for the official DeepSeek route.
|
|
237
|
+
*
|
|
238
|
+
* `dsh-llm-deepseek` deliberately never probes its gateway — `listModels()`
|
|
239
|
+
* returns the declared catalog and the README states the defaults are published
|
|
240
|
+
* "without probing gateway availability" — so the composer's model selector can
|
|
241
|
+
* drift from what the endpoint actually serves, and nothing in the harness would
|
|
242
|
+
* ever notice. On activation this plugin therefore asks the endpoint which ids
|
|
243
|
+
* it advertises (the same call the Models page makes: GET {baseURL}/models) and
|
|
244
|
+
* aligns `llm-deepseek.models` with the answer.
|
|
245
|
+
*
|
|
246
|
+
* Two properties keep that from being a liability:
|
|
247
|
+
*
|
|
248
|
+
* 1. It never invents capability metadata. The endpoint reports ids only, so
|
|
249
|
+
* it cannot supply `inputModalities`, `systemPromptUpdate` or a context
|
|
250
|
+
* window. Entries that already exist are preserved verbatim and new ids are
|
|
251
|
+
* filled from KNOWN_CATALOG_ENTRIES; an id this plugin cannot describe is
|
|
252
|
+
* reported rather than guessed, because a guessed entry silently downgrades
|
|
253
|
+
* a vision model to text-only.
|
|
254
|
+
* 2. It only rewrites the list wholesale while every advertised id is
|
|
255
|
+
* describable. An official route advertises a handful of DeepSeek ids; an
|
|
256
|
+
* aggregating gateway in front of `baseURL` advertises its entire
|
|
257
|
+
* catalogue, and "the endpoint is authoritative" would then replace a
|
|
258
|
+
* curated two-entry catalog with hundreds of unrelated rows. A
|
|
259
|
+
* foreign-looking answer degrades to append-only plus a warning.
|
|
260
|
+
*
|
|
261
|
+
* Every failure mode (no settings, no credential, offline gateway, renamed
|
|
262
|
+
* service, concurrent settings edit) is a no-op for the theme — a skin must
|
|
263
|
+
* never be able to break the model catalog — and it only writes on real drift.
|
|
264
|
+
* `catalogSync: "off"` probes and reports without ever writing.
|
|
265
|
+
*/
|
|
266
|
+
const LLM_DEEPSEEK_NS = "llm-deepseek";
|
|
267
|
+
/** Public endpoint; a deployment may point elsewhere through `$DEEPSEEK_BASE_URL`. */
|
|
268
|
+
const PUBLIC_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
|
269
|
+
const DEEPSEEK_BASE_URL_ENV = "DEEPSEEK_BASE_URL";
|
|
270
|
+
const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
271
|
+
const CATALOG_SYNC_TIMEOUT_MS = 5000;
|
|
272
|
+
/** Retry offsets (ms) while a boot-order prerequisite is still missing. */
|
|
273
|
+
const CATALOG_SYNC_RETRIES = [0, 2000, 6000, 15000];
|
|
274
|
+
/**
|
|
275
|
+
* Outcomes worth retrying: activation order is not ours to choose, so the
|
|
276
|
+
* provider may not have registered `llm-deepseek` yet and the credentials
|
|
277
|
+
* service may not be reachable at the instant this plugin activates. A failed
|
|
278
|
+
* or answered probe (in-sync / updated / endpoint-unreachable) is final.
|
|
279
|
+
*/
|
|
280
|
+
const CATALOG_SYNC_RETRYABLE = ["provider-absent", "no-credentials"];
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Capability metadata for ids this plugin knows by name. Only consulted for ids
|
|
284
|
+
* the endpoint advertises that the stored catalog does not describe yet; an
|
|
285
|
+
* existing entry is never overwritten, so a user's own edits survive.
|
|
286
|
+
*/
|
|
287
|
+
const KNOWN_CATALOG_ENTRIES = {
|
|
288
|
+
"deepseek-flash": {
|
|
289
|
+
name: "DeepSeek-V41-Flash",
|
|
290
|
+
contextWindow: 1000000,
|
|
291
|
+
inputModalities: ["text", "image"],
|
|
292
|
+
imagePixelBudget: 640000,
|
|
293
|
+
imageMaxBytes: 1048576,
|
|
294
|
+
systemPromptUpdate: "in-history"
|
|
295
|
+
},
|
|
296
|
+
"deepseek-v4-pro": {
|
|
297
|
+
name: "DeepSeek-V4-Pro",
|
|
298
|
+
contextWindow: 1000000,
|
|
299
|
+
inputModalities: ["text"]
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** One registered namespace's descriptor (resolved value plus revision), or null. */
|
|
304
|
+
function settingsDescriptor(ctx, ns) {
|
|
305
|
+
const settings = ctx.get("settings");
|
|
306
|
+
if (settings === undefined || settings === null || typeof settings.describe !== "function") return null;
|
|
307
|
+
let descriptors;
|
|
308
|
+
try {
|
|
309
|
+
descriptors = settings.describe({ redactSecrets: true });
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
if (!Array.isArray(descriptors)) return null;
|
|
314
|
+
const descriptor = descriptors.find((candidate) => String(candidate.ns) === ns);
|
|
315
|
+
if (descriptor === undefined || descriptor === null) return null;
|
|
316
|
+
const value = descriptor.value;
|
|
317
|
+
if (value === null || typeof value !== "object") return null;
|
|
318
|
+
return { value, revision: descriptor.revision };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The resolved `llm-deepseek` settings section, or null while it is absent. */
|
|
322
|
+
function llmDeepseekSection(ctx) {
|
|
323
|
+
return settingsDescriptor(ctx, LLM_DEEPSEEK_NS);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The configured catalog-sync policy, falling back to the schema default. */
|
|
327
|
+
function catalogSyncPolicy(ctx) {
|
|
328
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
329
|
+
const policy = descriptor === null ? undefined : descriptor.value.catalogSync;
|
|
330
|
+
return CATALOG_SYNC_POLICIES.includes(policy) ? policy : CATALOG_SYNC_DEFAULT;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The endpoint the official adapter itself would dial, resolved in that
|
|
335
|
+
* adapter's own order (`dsh-llm-deepseek` resolves `config.baseURL ??
|
|
336
|
+
* launchEnvironment.get("DEEPSEEK_BASE_URL") ?? "https://api.deepseek.com"`).
|
|
337
|
+
* Falling straight back to the public host would post the resolved credential to
|
|
338
|
+
* api.deepseek.com on a deployment whose gateway lives behind that variable —
|
|
339
|
+
* and that credential may be a gateway token, not a DeepSeek key. The service's
|
|
340
|
+
* own fallback is a raw `process.env` snapshot, so consulting `process.env` only
|
|
341
|
+
* while the service is absent matches the adapter exactly instead of widening
|
|
342
|
+
* trust.
|
|
343
|
+
*/
|
|
344
|
+
function resolveBaseURL(ctx, section) {
|
|
345
|
+
if (typeof section.baseURL === "string" && section.baseURL !== "") return section.baseURL;
|
|
346
|
+
const environment = ctx.get("launchEnvironment");
|
|
347
|
+
if (environment === undefined || environment === null || typeof environment.get !== "function") {
|
|
348
|
+
const ambient = process.env[DEEPSEEK_BASE_URL_ENV];
|
|
349
|
+
return typeof ambient === "string" && ambient !== "" ? ambient : PUBLIC_DEEPSEEK_BASE_URL;
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const hit = environment.get(DEEPSEEK_BASE_URL_ENV);
|
|
353
|
+
const value = hit === undefined || hit === null ? undefined : hit.value;
|
|
354
|
+
if (typeof value === "string" && value !== "") return value;
|
|
355
|
+
} catch (error) {
|
|
356
|
+
// Fall through to the public endpoint, exactly like the adapter does.
|
|
357
|
+
}
|
|
358
|
+
return PUBLIC_DEEPSEEK_BASE_URL;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolve the provider API key the same way `dsh-llm-deepseek` does: through the
|
|
363
|
+
* credentials service under the configured `apiKeyEnv`, falling back to the
|
|
364
|
+
* ambient environment of the launching process.
|
|
365
|
+
*/
|
|
366
|
+
async function resolveProviderKey(ctx, apiKeyEnv) {
|
|
367
|
+
const ref = typeof apiKeyEnv === "string" && apiKeyEnv !== "" ? apiKeyEnv : DEFAULT_API_KEY_ENV;
|
|
368
|
+
const credentials = ctx.get("credentials");
|
|
369
|
+
if (credentials !== undefined && credentials !== null && typeof credentials.resolve === "function") {
|
|
370
|
+
try {
|
|
371
|
+
const hit = await credentials.resolve(ref);
|
|
372
|
+
if (hit !== undefined && hit !== null && typeof hit.value === "string" && hit.value !== "") return hit.value;
|
|
373
|
+
} catch (error) {
|
|
374
|
+
// Fall through to the ambient environment.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const ambient = typeof process !== "undefined" && process.env !== undefined ? process.env[ref] : undefined;
|
|
378
|
+
return typeof ambient === "string" && ambient !== "" ? ambient : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Ask the endpoint which model ids it advertises, in endpoint order. */
|
|
382
|
+
async function fetchEndpointModelIds(baseURL, key) {
|
|
383
|
+
if (typeof fetch !== "function") return null;
|
|
384
|
+
const url = String(baseURL).replace(/\/+$/, "") + "/models";
|
|
385
|
+
const signal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
|
|
386
|
+
? AbortSignal.timeout(CATALOG_SYNC_TIMEOUT_MS)
|
|
387
|
+
: undefined;
|
|
388
|
+
const response = await fetch(url, {
|
|
389
|
+
headers: { authorization: "Bearer " + key, accept: "application/json" },
|
|
390
|
+
...(signal === undefined ? {} : { signal })
|
|
391
|
+
});
|
|
392
|
+
if (!response.ok) throw new Error("endpoint answered " + String(response.status));
|
|
393
|
+
const body = await response.json();
|
|
394
|
+
const rows = body !== null && typeof body === "object" && Array.isArray(body.data) ? body.data : [];
|
|
395
|
+
const ids = [];
|
|
396
|
+
for (const row of rows) {
|
|
397
|
+
const id = row !== null && typeof row === "object" && typeof row.id === "string" ? row.id.trim() : "";
|
|
398
|
+
if (id !== "" && !ids.includes(id)) ids.push(id);
|
|
399
|
+
}
|
|
400
|
+
return ids;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** One catalog entry for an id the stored catalog does not describe yet. */
|
|
404
|
+
function catalogEntryFor(id) {
|
|
405
|
+
const known = Object.prototype.hasOwnProperty.call(KNOWN_CATALOG_ENTRIES, id) ? KNOWN_CATALOG_ENTRIES[id] : undefined;
|
|
406
|
+
return known === undefined ? { id, name: id, inputModalities: ["text"] } : { id, ...known };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Whether this plugin can describe an id without inventing capability metadata. */
|
|
410
|
+
function isDescribable(id) {
|
|
411
|
+
return Object.prototype.hasOwnProperty.call(KNOWN_CATALOG_ENTRIES, id);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The ids of a catalog array, in order, ignoring malformed entries. */
|
|
415
|
+
function catalogIds(entries) {
|
|
416
|
+
return entries
|
|
417
|
+
.map((entry) => (entry !== null && typeof entry === "object" && typeof entry.id === "string" ? entry.id : ""))
|
|
418
|
+
.filter((id) => id !== "");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Align the stored catalog with the endpoint's id list, preserving surviving
|
|
423
|
+
* entries verbatim. Returns one of: provider-absent | no-credentials |
|
|
424
|
+
* endpoint-unreachable | in-sync | updated | conflict.
|
|
425
|
+
*/
|
|
426
|
+
async function syncDeepseekCatalog(ctx) {
|
|
427
|
+
const section = llmDeepseekSection(ctx);
|
|
428
|
+
if (section === null) return "provider-absent";
|
|
429
|
+
const settings = ctx.get("settings");
|
|
430
|
+
if (settings === undefined || settings === null || typeof settings.mutate !== "function") return "provider-absent";
|
|
431
|
+
const policy = catalogSyncPolicy(ctx);
|
|
432
|
+
const baseURL = resolveBaseURL(ctx, section.value);
|
|
433
|
+
const key = await resolveProviderKey(ctx, section.value.apiKeyEnv);
|
|
434
|
+
if (key === null) return "no-credentials";
|
|
435
|
+
let ids;
|
|
436
|
+
try {
|
|
437
|
+
ids = await fetchEndpointModelIds(baseURL, key);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
return "endpoint-unreachable";
|
|
440
|
+
}
|
|
441
|
+
if (ids === null || ids.length === 0) return "endpoint-unreachable";
|
|
442
|
+
const current = Array.isArray(section.value.models) ? section.value.models : [];
|
|
443
|
+
const byId = new Map();
|
|
444
|
+
for (const entry of current) {
|
|
445
|
+
if (entry !== null && typeof entry === "object" && typeof entry.id === "string") byId.set(entry.id, entry);
|
|
446
|
+
}
|
|
447
|
+
const currentIds = catalogIds(current);
|
|
448
|
+
// Advertised ids this plugin knows nothing about: reported, never guessed.
|
|
449
|
+
const undescribed = ids.filter((id) => !byId.has(id) && !isDescribable(id));
|
|
450
|
+
|
|
451
|
+
if (policy === "off") {
|
|
452
|
+
const missing = ids.filter((id) => !byId.has(id));
|
|
453
|
+
const stale = currentIds.filter((id) => !ids.includes(id));
|
|
454
|
+
if (missing.length === 0 && stale.length === 0) return "in-sync";
|
|
455
|
+
console.warn(
|
|
456
|
+
`[deepseek-style-theme] model catalog drift at ${baseURL} (catalogSync is "off", nothing written) — endpoint adds: ${missing.join(", ") || "none"}; endpoint no longer lists: ${stale.join(", ") || "none"}`
|
|
457
|
+
);
|
|
458
|
+
return "in-sync";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// A wholesale rewrite is only safe while every advertised id is one this
|
|
462
|
+
// plugin can describe; otherwise the answer looks like a gateway catalogue
|
|
463
|
+
// rather than the official route, and aligning to it would replace a curated
|
|
464
|
+
// catalog with unrelated rows.
|
|
465
|
+
const rewrites = policy === "auto" && undescribed.length === 0;
|
|
466
|
+
let models;
|
|
467
|
+
if (rewrites) {
|
|
468
|
+
if (currentIds.length === ids.length && currentIds.every((id, index) => id === ids[index])) return "in-sync";
|
|
469
|
+
models = ids.map((id) => (byId.has(id) ? byId.get(id) : catalogEntryFor(id)));
|
|
470
|
+
} else {
|
|
471
|
+
const added = ids.filter((id) => !byId.has(id) && isDescribable(id));
|
|
472
|
+
if (added.length === 0) {
|
|
473
|
+
if (undescribed.length > 0) {
|
|
474
|
+
console.warn(
|
|
475
|
+
`[deepseek-style-theme] ${baseURL} advertises ${undescribed.length} model id(s) this plugin cannot describe (${undescribed.slice(0, 5).join(", ")}); leaving llm-deepseek.models untouched rather than guessing their capabilities.`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return "in-sync";
|
|
479
|
+
}
|
|
480
|
+
models = [...current, ...added.map((id) => catalogEntryFor(id))];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const removed = currentIds.filter((id) => !models.some((entry) => entry.id === id));
|
|
484
|
+
try {
|
|
485
|
+
// Pinned to the revision this decision was read from: a settings edit the
|
|
486
|
+
// user made in the meantime wins, and this activation simply does not sync.
|
|
487
|
+
await settings.mutate(LLM_DEEPSEEK_NS, [{ op: "set", path: ["models"], value: models }], section.revision);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
return "conflict";
|
|
490
|
+
}
|
|
491
|
+
if (removed.length > 0) {
|
|
492
|
+
console.warn(
|
|
493
|
+
`[deepseek-style-theme] model catalog synced from ${baseURL}; removed id(s) the endpoint does not list: ${removed.join(", ")}`
|
|
494
|
+
);
|
|
495
|
+
} else {
|
|
496
|
+
console.info(`[deepseek-style-theme] model catalog synced from ${baseURL}`);
|
|
497
|
+
}
|
|
498
|
+
return "updated";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Run the catalog sync once per activation, retrying briefly while a boot-order
|
|
503
|
+
* prerequisite is still missing (the provider namespace or the credentials
|
|
504
|
+
* service). Disposal cancels any pending retry.
|
|
505
|
+
*/
|
|
506
|
+
function startCatalogSync(ctx) {
|
|
507
|
+
return ctx.effect(() => {
|
|
508
|
+
let cancelled = false;
|
|
509
|
+
let timer = null;
|
|
510
|
+
const attempt = (step) => {
|
|
511
|
+
if (cancelled) return;
|
|
512
|
+
syncDeepseekCatalog(ctx).then((outcome) => {
|
|
513
|
+
if (cancelled) return;
|
|
514
|
+
// "updated" already logged exactly what it changed where it changed it.
|
|
515
|
+
if (outcome === "updated") return;
|
|
516
|
+
if (CATALOG_SYNC_RETRYABLE.includes(outcome) && step + 1 < CATALOG_SYNC_RETRIES.length) {
|
|
517
|
+
timer = setTimeout(() => attempt(step + 1), CATALOG_SYNC_RETRIES[step + 1]);
|
|
518
|
+
}
|
|
519
|
+
}).catch(() => {});
|
|
520
|
+
};
|
|
521
|
+
attempt(0);
|
|
522
|
+
return () => {
|
|
523
|
+
cancelled = true;
|
|
524
|
+
if (timer !== null) clearTimeout(timer);
|
|
525
|
+
};
|
|
526
|
+
}, "deepseek-style-theme: model catalog sync");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* DSTT private-channel endpoints. The settings domain's RPC surface only
|
|
531
|
+
* serves namespaces in the core's hard-coded allowlist (`settings-not-exposed`
|
|
532
|
+
* otherwise), so the browser half reads/writes the DSTT preference through
|
|
533
|
+
* this plugin's own loopback channel, and the host half drives the settings
|
|
534
|
+
* service directly — the same shape as the explorer-open RPC above.
|
|
535
|
+
*/
|
|
536
|
+
const DSTT_GET = "dstt.mode.get";
|
|
537
|
+
const DSTT_SET = "dstt.mode.set";
|
|
538
|
+
/** Delivered-file gestures for the card menu patched by the browser half. */
|
|
539
|
+
const FILE_OPEN = "dshome/file.open";
|
|
540
|
+
const FILE_REVEAL = "dshome/file.reveal";
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Validate `payload.path` as a local absolute path, or return null. Every path
|
|
544
|
+
* endpoint shares this: a relative path, an embedded NUL, a UNC share or a
|
|
545
|
+
* device namespace is refused before it reaches any opener.
|
|
546
|
+
*/
|
|
547
|
+
function localPathOf(payload) {
|
|
548
|
+
const path = payload === null || payload === undefined || typeof payload !== "object" ? undefined : payload.path;
|
|
549
|
+
if (typeof path !== "string" || path.trim() === "" || path.indexOf("\0") !== -1 || !isAbsolute(path) || isNetworkPath(path)) return null;
|
|
550
|
+
return path;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Read the durable DSTT mode. Reports the schema default whenever the stored
|
|
555
|
+
* value is not one of the four modes, so the reply always honors the RPC
|
|
556
|
+
* contract — a legacy 1.37.x value is migrated right after registration, and
|
|
557
|
+
* the client rejects any off-enum answer anyway.
|
|
558
|
+
*/
|
|
559
|
+
function dsttRead(ctx) {
|
|
560
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
561
|
+
if (descriptor === null) return failure("internal", "settings service unavailable", {});
|
|
562
|
+
const stored = descriptor.value.mode;
|
|
563
|
+
return {
|
|
564
|
+
ok: true,
|
|
565
|
+
value: {
|
|
566
|
+
mode: DSTT_MODES.includes(stored) ? stored : DSTT_MODE_DEFAULT,
|
|
567
|
+
fluidBrush: descriptor.value.fluidBrush === true,
|
|
568
|
+
glassStyle: dsttGlassValue(ctx)
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Write the durable DSTT preferences through the host settings service directly.
|
|
575
|
+
* `fluidBrush` and `glassStyle` are both optional: when they carry a valid value
|
|
576
|
+
* the same write also sets them, so the settings panel can persist a toggle
|
|
577
|
+
* without a second round trip. `glassStyle` is deliberately NOT mandatory the
|
|
578
|
+
* way `mode` is — an older client that only knows about the mode keeps working.
|
|
579
|
+
*/
|
|
580
|
+
async function dsttWrite(ctx, mode, fluidBrush, glassStyle) {
|
|
581
|
+
if (!DSTT_MODES.includes(mode)) {
|
|
582
|
+
return failure("bad-request", `mode must be one of ${DSTT_MODES.join("/")}`, { issues: [] });
|
|
583
|
+
}
|
|
584
|
+
const wantsGlass = GLASS_STYLES.includes(glassStyle);
|
|
585
|
+
const ops = [{ op: "set", path: ["mode"], value: mode }];
|
|
586
|
+
if (typeof fluidBrush === "boolean") ops.push({ op: "set", path: ["fluidBrush"], value: fluidBrush });
|
|
587
|
+
if (wantsGlass) ops.push({ op: "set", path: ["glassStyle"], value: glassStyle });
|
|
588
|
+
const settings = ctx.get("settings");
|
|
589
|
+
if (settings === undefined || settings === null || typeof settings.mutate !== "function") {
|
|
590
|
+
return failure("internal", "settings service unavailable", {});
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
await settings.mutate(DSTT_SETTINGS_NS, ops);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
return failure("internal", error instanceof Error ? error.message : "settings write failed", {});
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
ok: true,
|
|
599
|
+
value: {
|
|
600
|
+
mode,
|
|
601
|
+
fluidBrush: typeof fluidBrush === "boolean" ? fluidBrush === true : dsttBrushValue(ctx),
|
|
602
|
+
glassStyle: wantsGlass ? glassStyle : dsttGlassValue(ctx)
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** The stored fluid-brush preference, defaulting to off. */
|
|
608
|
+
function dsttBrushValue(ctx) {
|
|
609
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
610
|
+
return descriptor === null ? false : descriptor.value.fluidBrush === true;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The stored glass recipe. A missing or unrecognized value reads back as the
|
|
615
|
+
* default rather than as `undefined`, so the client always gets one of the two
|
|
616
|
+
* ids it can gate its stylesheet on.
|
|
617
|
+
*/
|
|
618
|
+
function dsttGlassValue(ctx) {
|
|
619
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
620
|
+
const stored = descriptor === null ? undefined : descriptor.value.glassStyle;
|
|
621
|
+
return GLASS_STYLES.includes(stored) ? stored : GLASS_STYLE_DEFAULT;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function apply(ctx) {
|
|
625
|
+
// Both registrations below are best-effort on purpose: dsh ships breaking
|
|
626
|
+
// changes to the settings / connection APIs between releases, and a throw
|
|
627
|
+
// here fails this bundle's loader entry — with it the whole plugin tree,
|
|
628
|
+
// which is far worse than a theme that merely loses its persistence.
|
|
629
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
630
|
+
if (DsttSettingsSchema === null) {
|
|
631
|
+
console.warn("[deepseek-style-theme] DSTT settings not registered (Schemastery is missing, see above): the mode will not persist");
|
|
632
|
+
} else {
|
|
633
|
+
try {
|
|
634
|
+
settingsCtx.settings.register(DSTT_SETTINGS_NS, DsttSettingsSchema);
|
|
635
|
+
const descriptor = settingsDescriptor(settingsCtx, DSTT_SETTINGS_NS);
|
|
636
|
+
const stored = descriptor === null ? undefined : descriptor.value.mode;
|
|
637
|
+
const migrated = migrateLegacyMode(stored);
|
|
638
|
+
if (migrated !== null) {
|
|
639
|
+
settingsCtx.settings.mutate(DSTT_SETTINGS_NS, [{ op: "set", path: ["mode"], value: migrated }])
|
|
640
|
+
.then(() => console.warn(`[deepseek-style-theme] migrated DSTT mode "${stored}" -> "${migrated}"`))
|
|
641
|
+
.catch(() => {});
|
|
642
|
+
}
|
|
643
|
+
} catch (error) {
|
|
644
|
+
console.warn("[deepseek-style-theme] settings.register failed (DSTT mode will not persist):", error);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Activation-time, best-effort, silent unless it really changed something.
|
|
648
|
+
try {
|
|
649
|
+
startCatalogSync(settingsCtx);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
console.warn("[deepseek-style-theme] catalog sync unavailable:", error);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// The private bridge is a plain webServer prefix route. dsh 0.1.5's
|
|
656
|
+
// `connection.rpc.handle()` registers its route through the *provider's* ctx,
|
|
657
|
+
// so it trips cordis' guard — `cannot get property "webServer" without
|
|
658
|
+
// inject` — no matter what this plugin declares in its own inject list.
|
|
659
|
+
// ctx.webServer is the same mechanism the shipped plugin console uses, and
|
|
660
|
+
// the browser half reaches it with a same-origin fetch.
|
|
661
|
+
const webServer = ctx.webServer;
|
|
662
|
+
if (webServer === undefined || webServer === null || typeof webServer.register !== "function") {
|
|
663
|
+
console.warn("[deepseek-style-theme] webServer unavailable: 打开工作区与模式持久化将不可用");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const handler = async (endpoint, payload) => {
|
|
667
|
+
try {
|
|
668
|
+
if (endpoint === DSTT_GET) return dsttRead(ctx);
|
|
669
|
+
if (endpoint === DSTT_SET) {
|
|
670
|
+
const payloadObject = payload === null || payload === undefined || typeof payload !== "object" ? {} : payload;
|
|
671
|
+
return await dsttWrite(ctx, payloadObject.mode, payloadObject.fluidBrush, payloadObject.glassStyle);
|
|
672
|
+
}
|
|
673
|
+
if (endpoint === FILE_OPEN || endpoint === FILE_REVEAL) {
|
|
674
|
+
const target = localPathOf(payload);
|
|
675
|
+
if (target === null) {
|
|
676
|
+
return failure("bad-request", "payload.path must be a non-empty absolute local path", { issues: [] });
|
|
677
|
+
}
|
|
678
|
+
const done = endpoint === FILE_OPEN ? await openFileGeneric(target) : await revealFileGeneric(target);
|
|
679
|
+
if (!done) {
|
|
680
|
+
return failure("internal", endpoint === FILE_OPEN
|
|
681
|
+
? "the system failed to open the file with its default application"
|
|
682
|
+
: "the system file manager failed to reveal the file", {});
|
|
683
|
+
}
|
|
684
|
+
return { ok: true, value: { opened: true } };
|
|
685
|
+
}
|
|
686
|
+
if (endpoint !== "dshome/explorer.open") {
|
|
687
|
+
return failure("bad-request", `unknown endpoint "${endpoint}"`, { issues: [] });
|
|
688
|
+
}
|
|
689
|
+
const path = localPathOf(payload);
|
|
690
|
+
if (path === null) {
|
|
691
|
+
return failure("bad-request", "payload.path must be a non-empty absolute local path", { issues: [] });
|
|
692
|
+
}
|
|
693
|
+
const opened = await openPathGeneric(path);
|
|
694
|
+
if (!opened) {
|
|
695
|
+
return failure("internal", "the system file manager failed to open the path", {});
|
|
696
|
+
}
|
|
697
|
+
return { ok: true, value: { opened: true } };
|
|
698
|
+
} catch (error) {
|
|
699
|
+
const message = error instanceof Error ? error.message : "unexpected failure in the rpc handler";
|
|
700
|
+
return failure("internal", message, {});
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
ctx.effect(() => webServer.register({
|
|
704
|
+
kind: "prefix",
|
|
705
|
+
path: CHANNEL,
|
|
706
|
+
handler: async (req, res) => {
|
|
707
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
708
|
+
sendJson(res, 403, failure("bad-request", "untrusted request origin: loopback and same-origin authorities only", {}));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (!isJsonRequest(req)) {
|
|
712
|
+
sendJson(res, 400, failure("bad-request", "content-type must be application/json", {}));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const body = await readJsonBody(req);
|
|
717
|
+
const endpoint = body === null || typeof body !== "object" ? undefined : body.endpoint;
|
|
718
|
+
const payload = body === null || typeof body !== "object" ? undefined : body.payload;
|
|
719
|
+
sendJson(res, 200, await handler(endpoint, payload));
|
|
720
|
+
} catch (error) {
|
|
721
|
+
sendJson(res, 200, failure("internal", error instanceof Error ? error.message : "bridge failure", {}));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}), "deepseek-style-theme: private bridge route");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** One request header, or undefined when absent (Node lowercases header names). */
|
|
728
|
+
function headerValue(headers, name) {
|
|
729
|
+
const value = headers === undefined || headers === null ? undefined : headers[name];
|
|
730
|
+
return typeof value === "string" ? value : undefined;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Whether a TCP peer address is loopback. */
|
|
734
|
+
function isLoopbackAddress(address) {
|
|
735
|
+
const value = typeof address === "string" ? address : "";
|
|
736
|
+
return value === "127.0.0.1" || value === "::1" || value.startsWith("::ffff:127.") || value.startsWith("127.");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Whether one authority's hostname names this machine's own loopback interface. */
|
|
740
|
+
function isLoopbackHostname(hostname) {
|
|
741
|
+
const value = hostname.replace(/^\[/u, "").replace(/\]$/u, "").toLowerCase();
|
|
742
|
+
return value === "localhost" || value === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(value);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Fence for the private bridge route, mirroring the core's own
|
|
747
|
+
* `isTrustedApiRequest` (dsh-client-connection) instead of trusting the TCP peer
|
|
748
|
+
* alone. The peer address is necessary but not sufficient: a cross-site
|
|
749
|
+
* `fetch()` with a simple content type never triggers a preflight, so its side
|
|
750
|
+
* effects run even though the page cannot read the response, and a rebinding
|
|
751
|
+
* hostname resolves to 127.0.0.1 while still arriving with the attacker's name
|
|
752
|
+
* in `Host`.
|
|
753
|
+
*
|
|
754
|
+
* So the request must also carry a loopback authority, and any browser
|
|
755
|
+
* provenance headers must agree with it. A caller sending no `Origin` (curl, the
|
|
756
|
+
* CLI) is allowed, exactly as the core allows it. Loopback-only is also the
|
|
757
|
+
* contract the route has always had, so this narrows nothing that worked before.
|
|
758
|
+
*/
|
|
759
|
+
function isTrustedBridgeRequest(req) {
|
|
760
|
+
const socket = req.socket === undefined || req.socket === null ? null : req.socket;
|
|
761
|
+
if (!isLoopbackAddress(socket === null ? "" : socket.remoteAddress)) return false;
|
|
762
|
+
const host = headerValue(req.headers, "host");
|
|
763
|
+
if (host === undefined) return false;
|
|
764
|
+
let hostUrl;
|
|
765
|
+
try {
|
|
766
|
+
hostUrl = new URL("http://" + host);
|
|
767
|
+
} catch (error) {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
771
|
+
if (headerValue(req.headers, "sec-fetch-site") === "cross-site") return false;
|
|
772
|
+
const origin = headerValue(req.headers, "origin");
|
|
773
|
+
if (origin === undefined) return true;
|
|
774
|
+
try {
|
|
775
|
+
return new URL(origin).host === hostUrl.host;
|
|
776
|
+
} catch (error) {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Whether a request body is JSON. Requiring it costs this plugin nothing (its own
|
|
783
|
+
* client always sends it) and makes every cross-site attempt a non-simple
|
|
784
|
+
* request, so the browser must preflight a route that never answers a preflight.
|
|
785
|
+
*/
|
|
786
|
+
function isJsonRequest(req) {
|
|
787
|
+
const value = headerValue(req.headers, "content-type");
|
|
788
|
+
return value !== undefined && value.split(";")[0].trim().toLowerCase() === "application/json";
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Whether a path addresses a network share or a device namespace. `isAbsolute`
|
|
793
|
+
* is true for `\\host\share` on Windows, and opening such a path makes Windows
|
|
794
|
+
* authenticate to that host — an NTLM hash leak reachable from a cross-site
|
|
795
|
+
* request. The theme only ever opens local workspace directories, so the whole
|
|
796
|
+
* `\\` and `//` namespace is refused.
|
|
797
|
+
*/
|
|
798
|
+
function isNetworkPath(path) {
|
|
799
|
+
return path.startsWith("\\\\") || path.startsWith("//");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Read one JSON request body from the Node request stream (bounded). */
|
|
803
|
+
async function readJsonBody(req) {
|
|
804
|
+
let raw = "";
|
|
805
|
+
for await (const chunk of req) {
|
|
806
|
+
raw += chunk;
|
|
807
|
+
if (raw.length > 65536) throw new Error("request body too large");
|
|
808
|
+
}
|
|
809
|
+
return raw === "" ? null : JSON.parse(raw);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Write one JSON response. */
|
|
813
|
+
function sendJson(res, status, value) {
|
|
814
|
+
const payload = JSON.stringify(value);
|
|
815
|
+
res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
|
|
816
|
+
res.end(payload);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// The bridge rides `webServer` (declared hard: it is core to any web realm).
|
|
820
|
+
// `connection` is no longer injected — dsh 0.1.5's rpc carrier cannot host this
|
|
821
|
+
// plugin's channel without tripping the guard described in apply().
|
|
822
|
+
const inject = ["webServer"];
|
|
823
|
+
|
|
824
|
+
export { apply, inject };
|