@dsh-plugin/dsh-loader 1.0.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 +226 -0
- package/README.zh-CN.md +202 -0
- package/bin/dshloader.mjs +42 -0
- package/cordis.patch.yml +8 -0
- package/package.json +59 -0
- package/src/adapters/dsh-1-x.js +236 -0
- package/src/adapters/index.js +59 -0
- package/src/api.js +56 -0
- package/src/client.js +330 -0
- package/src/index.js +105 -0
- package/src/registry.js +242 -0
- package/src/services/services.js +28 -0
- package/src/services/settings.js +174 -0
- package/src/services/web.js +71 -0
- package/src/setup.mjs +125 -0
- package/src/stable/agent.d.ts +1 -0
- package/src/stable/agent.js +2 -0
- package/src/stable/llm.d.ts +1 -0
- package/src/stable/llm.js +2 -0
- package/src/stable/runtime.d.ts +1 -0
- package/src/stable/runtime.js +5 -0
- package/src/stable/schema-form.d.ts +1 -0
- package/src/stable/schema-form.js +2 -0
- package/src/stable/settings.d.ts +1 -0
- package/src/stable/settings.js +2 -0
- package/src/stable/tools.d.ts +1 -0
- package/src/stable/tools.js +5 -0
- package/src/stable/ui-primitives.d.ts +4 -0
- package/src/stable/ui-primitives.js +6 -0
- package/src/stable/ui-settings.d.ts +6 -0
- package/src/stable/ui-settings.js +4 -0
- package/src/stable/ui-slots.d.ts +1 -0
- package/src/stable/ui-slots.js +2 -0
- package/src/stable/web-react.d.ts +1 -0
- package/src/stable/web-react.js +2 -0
- package/src/version.js +5 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// dshloader host bundle entry (design.md §7.3 / §4.4).
|
|
2
|
+
//
|
|
3
|
+
// Exports the cordis function-plugin shape (`name`, `inject`, `apply`) and
|
|
4
|
+
// wires the adapter registry → version detection → stable API onto
|
|
5
|
+
// `ctx.dshLoader`. Service aliases / bridge routes register through
|
|
6
|
+
// ctx.reflect.provide / ctx.effect so cordis auto-recycles them on fiber
|
|
7
|
+
// unload — no manual dispose is required for v1's covered capabilities.
|
|
8
|
+
import { LOADER_VERSION, LOG_PREFIX } from './version.js';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
import { AdapterRegistry, detectDshVersion, UnsupportedDshVersionError } from './registry.js';
|
|
12
|
+
import { registerHostAdapters } from './adapters/index.js';
|
|
13
|
+
import { createHostAPI } from './api.js';
|
|
14
|
+
|
|
15
|
+
export const name = '@dsh-plugin/dsh-loader';
|
|
16
|
+
// dshloader itself depends on `webServer` so its fiber only activates once
|
|
17
|
+
// the real web server is provided — the alias then points `httpServer` at it.
|
|
18
|
+
export const inject = ['webServer'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read the profile-level dshloader config (exposeAllNamespaces etc.).
|
|
22
|
+
* Sources, in priority order:
|
|
23
|
+
* 1. process.env.DSHLOADER_EXPOSE_ALL_SETTINGS=1
|
|
24
|
+
* 2. profile package.json `dsh.dshloader.exposeAllNamespaces`
|
|
25
|
+
* 3. profile package.json `dshLoader.settings.exposeAllNamespaces`
|
|
26
|
+
*/
|
|
27
|
+
export function readLoaderConfig({ profileDir } = {}) {
|
|
28
|
+
const envOn = process.env.DSHLOADER_EXPOSE_ALL_SETTINGS === '1' || process.env.DSHLOADER_EXPOSE_ALL_SETTINGS === 'true';
|
|
29
|
+
let pkgOn = false;
|
|
30
|
+
try {
|
|
31
|
+
// Best-effort: read the profile manifest if reachable via cwd.
|
|
32
|
+
const dir = profileDir ?? join(process.env.DSH_HOME ?? '', 'profiles', 'web');
|
|
33
|
+
const manifest = JSON.parse(readFileSync(join(resolve(dir), 'package.json'), 'utf8'));
|
|
34
|
+
pkgOn = Boolean(
|
|
35
|
+
manifest.dsh?.dshloader?.exposeAllNamespaces ??
|
|
36
|
+
manifest.dshLoader?.settings?.exposeAllNamespaces,
|
|
37
|
+
);
|
|
38
|
+
} catch {
|
|
39
|
+
/* ignore */
|
|
40
|
+
}
|
|
41
|
+
return { exposeAllNamespaces: envOn || pkgOn };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the registry + select an adapter for the current dsh version, without
|
|
46
|
+
* touching the cordis context. Exported for tests and the `info` CLI command.
|
|
47
|
+
*/
|
|
48
|
+
export function selectAdapter({ dshVersion, registry } = {}) {
|
|
49
|
+
const reg = registry ?? registerHostAdapters(new AdapterRegistry());
|
|
50
|
+
const version = dshVersion ?? detectDshVersion();
|
|
51
|
+
if (version === undefined) {
|
|
52
|
+
throw new UnsupportedDshVersionError(
|
|
53
|
+
`${LOG_PREFIX} could not detect dsh version; set DSHLOADER_DSH_VERSION or install @deepseek-ai/dsh`,
|
|
54
|
+
{ kind: 'too-new' },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const { factory, mode } = reg.select(version);
|
|
58
|
+
return { registry: reg, factory, mode, dshVersion: version };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply the selected adapter onto a cordis context and expose the stable API.
|
|
63
|
+
* @param {object} ctx cordis context
|
|
64
|
+
* @param {{ dshVersion?: string, config?: object, registry?: AdapterRegistry }} [opts]
|
|
65
|
+
* @returns {Promise<{ api: object, factory: object, mode: string, dshVersion: string }>}
|
|
66
|
+
*/
|
|
67
|
+
export async function applyAdapter(ctx, opts = {}) {
|
|
68
|
+
const config = opts.config ?? readLoaderConfig();
|
|
69
|
+
const selection = selectAdapter({ dshVersion: opts.dshVersion, registry: opts.registry });
|
|
70
|
+
const { factory, mode, dshVersion } = selection;
|
|
71
|
+
|
|
72
|
+
const adapter = factory.create(ctx, config);
|
|
73
|
+
await adapter.apply?.();
|
|
74
|
+
|
|
75
|
+
const api = createHostAPI({
|
|
76
|
+
ctx,
|
|
77
|
+
dshVersion,
|
|
78
|
+
factory,
|
|
79
|
+
adapter,
|
|
80
|
+
exposeAllNamespaces: config.exposeAllNamespaces,
|
|
81
|
+
hostPackageAliases: config.hostPackageAliases,
|
|
82
|
+
});
|
|
83
|
+
ctx.reflect.provide('dshLoader', api);
|
|
84
|
+
|
|
85
|
+
console.log(`${LOG_PREFIX} loaded adapter ${factory.name} for dsh ${dshVersion} (mode: ${mode})`);
|
|
86
|
+
console.log(`${LOG_PREFIX} registered stable API: settings, web, services`);
|
|
87
|
+
if (config.exposeAllNamespaces) {
|
|
88
|
+
console.warn(
|
|
89
|
+
`${LOG_PREFIX} exposeAllNamespaces enabled: bypassing official settings whitelist`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { api, factory, mode, dshVersion };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** cordis function-plugin entry point. */
|
|
97
|
+
export async function apply(ctx) {
|
|
98
|
+
if (process.env.DSHLOADER_DISABLE === '1' || process.env.DSHLOADER_DISABLE === 'true') {
|
|
99
|
+
console.log(`${LOG_PREFIX} disabled by env, skipping`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await applyAdapter(ctx);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { LOADER_VERSION };
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter registry + dsh version detection (design.md §3.1 / §3.2).
|
|
3
|
+
*
|
|
4
|
+
* Selection rules (mirrors design.md §3.1, five rules):
|
|
5
|
+
* 1. exact match — adapter.supports === version
|
|
6
|
+
* 2. range match — semver.satisfies(version, supports); when several
|
|
7
|
+
* ranges cover the version, pick the narrowest; ties
|
|
8
|
+
* broken by last-registered-wins.
|
|
9
|
+
* 3. nearest-low fallback — no exact/range hit, but some adapters only cover
|
|
10
|
+
* versions below the real one: pick the one whose
|
|
11
|
+
* upper bound is closest, mark mode 'fallback', warn.
|
|
12
|
+
* 4. version too old — real version is below every adapter's lower bound:
|
|
13
|
+
* throw UnsupportedDshVersionError with a "too old"
|
|
14
|
+
* message and the lowest supported version.
|
|
15
|
+
* 5. version too new / empty registry — throw UnsupportedDshVersionError with
|
|
16
|
+
* an "upgrade dshloader" message.
|
|
17
|
+
*
|
|
18
|
+
* Version detection priority (design.md §3.2):
|
|
19
|
+
* 1. DSHLOADER_DSH_VERSION env var (highest; for tests/CI override)
|
|
20
|
+
* 2. node_modules/@deepseek-ai/dsh/package.json#version
|
|
21
|
+
* 3. ctx.runtime?.version — reserved for the future, NOT consulted in v1.
|
|
22
|
+
* child_process `dsh --version` is deliberately NOT used (design.md §3.2).
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { join, resolve, dirname } from 'node:path';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import semver from 'semver';
|
|
28
|
+
import { LOG_PREFIX } from './version.js';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
|
|
32
|
+
export class UnsupportedDshVersionError extends Error {
|
|
33
|
+
constructor(message, { kind, version, minSupported } = {}) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'UnsupportedDshVersionError';
|
|
36
|
+
this.kind = kind; // 'too-old' | 'too-new'
|
|
37
|
+
this.version = version;
|
|
38
|
+
this.minSupported = minSupported;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class InvalidVersionError extends Error {
|
|
43
|
+
constructor(message, { version } = {}) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'InvalidVersionError';
|
|
46
|
+
this.version = version;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the installed dsh version.
|
|
52
|
+
*
|
|
53
|
+
* @param {{ profileDir?: string, dshPkgPath?: string, env?: NodeJS.ProcessEnv }} [opts]
|
|
54
|
+
* @returns {string|undefined} semver version string, or undefined when unreachable
|
|
55
|
+
*/
|
|
56
|
+
export function detectDshVersion(opts = {}) {
|
|
57
|
+
const env = opts.env ?? process.env;
|
|
58
|
+
const envVal = env.DSHLOADER_DSH_VERSION;
|
|
59
|
+
if (typeof envVal === 'string' && envVal.trim() !== '') {
|
|
60
|
+
return envVal.trim();
|
|
61
|
+
}
|
|
62
|
+
const candidates = [];
|
|
63
|
+
if (opts.dshPkgPath) candidates.push(opts.dshPkgPath);
|
|
64
|
+
if (opts.profileDir) {
|
|
65
|
+
candidates.push(join(opts.profileDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'));
|
|
66
|
+
}
|
|
67
|
+
// Resolve from the loader's own location (profile node_modules sits beside it).
|
|
68
|
+
try {
|
|
69
|
+
candidates.push(require.resolve('@deepseek-ai/dsh/package.json'));
|
|
70
|
+
} catch { /* not installed — skip */ }
|
|
71
|
+
// Global node_modules — dsh is typically installed globally (the runtime
|
|
72
|
+
// that loads profiles, not a profile dependency). Derive from the Node.js
|
|
73
|
+
// executable: /opt/homebrew/bin/node → /opt/homebrew/lib/node_modules.
|
|
74
|
+
const globalRoot = resolve(dirname(process.execPath), '..', 'lib', 'node_modules');
|
|
75
|
+
candidates.push(join(globalRoot, '@deepseek-ai', 'dsh', 'package.json'));
|
|
76
|
+
// Walk up from cwd as a last resort.
|
|
77
|
+
let dir = process.cwd();
|
|
78
|
+
for (let i = 0; i < 8; i += 1) {
|
|
79
|
+
candidates.push(join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'));
|
|
80
|
+
const parent = resolve(dir, '..');
|
|
81
|
+
if (parent === dir) break;
|
|
82
|
+
dir = parent;
|
|
83
|
+
}
|
|
84
|
+
for (const path of candidates) {
|
|
85
|
+
try {
|
|
86
|
+
const pkg = JSON.parse(readFileSync(path, 'utf8'));
|
|
87
|
+
if (typeof pkg.version === 'string' && pkg.version.trim() !== '') {
|
|
88
|
+
return pkg.version.trim();
|
|
89
|
+
}
|
|
90
|
+
} catch { /* try next */ }
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a semver range into { lower, upper } bounds. Each bound is
|
|
97
|
+
* { v: SemVer, inc: boolean } or null when unbounded on that side.
|
|
98
|
+
*/
|
|
99
|
+
function rangeBounds(range) {
|
|
100
|
+
const r = new semver.Range(range);
|
|
101
|
+
let lower = null;
|
|
102
|
+
let upper = null;
|
|
103
|
+
for (const group of r.set) {
|
|
104
|
+
for (const c of group) {
|
|
105
|
+
const op = c.operator;
|
|
106
|
+
const v = c.semver; // parsed SemVer (c.value is the raw comparator string)
|
|
107
|
+
if (op === '' || op === '=') {
|
|
108
|
+
lower = { v, inc: true };
|
|
109
|
+
upper = { v, inc: true };
|
|
110
|
+
} else if (op === '>') {
|
|
111
|
+
lower = { v, inc: false };
|
|
112
|
+
} else if (op === '>=') {
|
|
113
|
+
lower = { v, inc: true };
|
|
114
|
+
} else if (op === '<') {
|
|
115
|
+
upper = { v, inc: false };
|
|
116
|
+
} else if (op === '<=') {
|
|
117
|
+
upper = { v, inc: true };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { lower, upper };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True when every version the range covers is strictly below `version`. */
|
|
125
|
+
function isLowerCandidate(range, version) {
|
|
126
|
+
const { upper } = rangeBounds(range);
|
|
127
|
+
if (!upper) return false; // unbounded above → can cover version, not a lower candidate
|
|
128
|
+
return upper.inc ? semver.gt(version, upper.v) : semver.gte(version, upper.v);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Lowest version that satisfies the range (semver.minVersion). */
|
|
132
|
+
function rangeMinVersion(range) {
|
|
133
|
+
const min = semver.minVersion(range);
|
|
134
|
+
return min ? min.version : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class AdapterRegistry {
|
|
138
|
+
constructor() {
|
|
139
|
+
/** @type {Array<{supports: string, name: string, create: Function}>} */
|
|
140
|
+
this.adapters = [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
register(factory) {
|
|
144
|
+
if (!factory || typeof factory.supports !== 'string' || typeof factory.create !== 'function') {
|
|
145
|
+
throw new TypeError('AdapterFactory must expose { supports: string, create: function }');
|
|
146
|
+
}
|
|
147
|
+
this.adapters.push(factory);
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} version real dsh version
|
|
153
|
+
* @returns {{ factory: object, mode: 'exact'|'range'|'fallback' }}
|
|
154
|
+
*/
|
|
155
|
+
select(version) {
|
|
156
|
+
if (!semver.valid(version)) {
|
|
157
|
+
throw new InvalidVersionError(
|
|
158
|
+
`${LOG_PREFIX} cannot parse dsh version "${version}" as semver`,
|
|
159
|
+
{ version },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (this.adapters.length === 0) {
|
|
163
|
+
throw new UnsupportedDshVersionError(
|
|
164
|
+
`${LOG_PREFIX} no adapter registered for dsh ${version}; please upgrade @dsh-plugin/dsh-loader`,
|
|
165
|
+
{ kind: 'too-new', version },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Rule 1 + 2: exact and range matches.
|
|
170
|
+
const matches = [];
|
|
171
|
+
for (const factory of this.adapters) {
|
|
172
|
+
if (factory.supports === version) {
|
|
173
|
+
matches.push({ factory, mode: 'exact' });
|
|
174
|
+
} else if (semver.satisfies(version, factory.supports)) {
|
|
175
|
+
matches.push({ factory, mode: 'range' });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (matches.length > 0) {
|
|
179
|
+
// Narrowest range wins; tie → last registered.
|
|
180
|
+
let best = matches[matches.length - 1];
|
|
181
|
+
for (let i = matches.length - 1; i >= 0; i -= 1) {
|
|
182
|
+
const candidate = matches[i];
|
|
183
|
+
const narrowest = matches.every((other, j) => {
|
|
184
|
+
if (i === j) return true;
|
|
185
|
+
try {
|
|
186
|
+
return semver.subset(candidate.factory.supports, other.factory.supports);
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
if (narrowest) {
|
|
192
|
+
best = candidate;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Exact match always beats a range match on the same version.
|
|
197
|
+
const exact = matches.find((m) => m.mode === 'exact');
|
|
198
|
+
if (exact) best = exact;
|
|
199
|
+
return { factory: best.factory, mode: best.mode };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Rule 3: nearest-low fallback.
|
|
203
|
+
const lowers = this.adapters
|
|
204
|
+
.filter((f) => isLowerCandidate(f.supports, version))
|
|
205
|
+
.map((f) => {
|
|
206
|
+
const { upper } = rangeBounds(f.supports);
|
|
207
|
+
return { factory: f, upper };
|
|
208
|
+
});
|
|
209
|
+
if (lowers.length > 0) {
|
|
210
|
+
lowers.sort((a, b) => {
|
|
211
|
+
const cmp = semver.compare(b.upper.v, a.upper.v);
|
|
212
|
+
if (cmp !== 0) return cmp;
|
|
213
|
+
// exclusive upper is "higher" than inclusive at the same version
|
|
214
|
+
return (b.upper.inc ? 0 : 1) - (a.upper.inc ? 0 : 1);
|
|
215
|
+
});
|
|
216
|
+
const chosen = lowers[0];
|
|
217
|
+
console.warn(
|
|
218
|
+
`${LOG_PREFIX} no exact adapter for dsh ${version}; falling back to "${chosen.factory.name}" (supports ${chosen.factory.supports})`,
|
|
219
|
+
);
|
|
220
|
+
return { factory: chosen.factory, mode: 'fallback' };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Rule 4 vs 5: too old vs too new.
|
|
224
|
+
const minVersions = this.adapters
|
|
225
|
+
.map((f) => rangeMinVersion(f.supports))
|
|
226
|
+
.filter(Boolean)
|
|
227
|
+
.map((v) => semver.parse(v));
|
|
228
|
+
if (minVersions.length > 0) {
|
|
229
|
+
const lowest = minVersions.reduce((acc, v) => (semver.lt(v, acc) ? v : acc));
|
|
230
|
+
if (semver.lt(version, lowest)) {
|
|
231
|
+
throw new UnsupportedDshVersionError(
|
|
232
|
+
`${LOG_PREFIX} current dsh version ${version} is too old; dshloader minimum supported is ${lowest.version}. Please upgrade dsh or use an older dshloader release.`,
|
|
233
|
+
{ kind: 'too-old', version, minSupported: lowest.version },
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
throw new UnsupportedDshVersionError(
|
|
238
|
+
`${LOG_PREFIX} no adapter covers dsh ${version}; please upgrade @dsh-plugin/dsh-loader`,
|
|
239
|
+
{ kind: 'too-new', version },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Services stable API (design.md §3.3 / §4.1 `ctx.dshLoader.services`).
|
|
2
|
+
//
|
|
3
|
+
// Thin read/alias helpers over the cordis service registry. `alias()` is a
|
|
4
|
+
// low-level escape hatch for plugin authors who need a one-hop alias beyond
|
|
5
|
+
// what the selected adapter already provides; it never overwrites an existing
|
|
6
|
+
// service (mirrors the adapter safety rule in design.md §5.1).
|
|
7
|
+
|
|
8
|
+
import { LOG_PREFIX } from '../version.js';
|
|
9
|
+
|
|
10
|
+
export function createServicesAPI({ ctx }) {
|
|
11
|
+
return {
|
|
12
|
+
get(name) {
|
|
13
|
+
return ctx.get(name);
|
|
14
|
+
},
|
|
15
|
+
alias(from, to) {
|
|
16
|
+
if (ctx.get(from) !== undefined) {
|
|
17
|
+
console.warn(`${LOG_PREFIX} services.alias: "${from}" already exists, skip alias`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const target = ctx.get(to);
|
|
21
|
+
if (target === undefined) {
|
|
22
|
+
console.warn(`${LOG_PREFIX} services.alias: target "${to}" unavailable, cannot alias "${from}"`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
ctx.reflect.provide(from, target);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Settings stable API (design.md §3.3.1 / §4.1 / §4.2).
|
|
2
|
+
//
|
|
3
|
+
// Two concerns are deliberately separated:
|
|
4
|
+
// 1. naming/shape differences — handled by proxying to ctx.get('settings').
|
|
5
|
+
// 2. access scope (security) — `exposeAllNamespaces` controls whether
|
|
6
|
+
// `describe` returns only the official browser whitelist namespaces
|
|
7
|
+
// (default, matches official behavior) or every registered namespace.
|
|
8
|
+
//
|
|
9
|
+
// Host-side writes always proxy through to the real settings service: host
|
|
10
|
+
// plugin code is already trusted (it runs in the dsh Node process and could
|
|
11
|
+
// call ctx.get('settings').update directly). The whitelist is a browser-side
|
|
12
|
+
// default-deny boundary owned by dsh-host-apiproxy; the browser path is
|
|
13
|
+
// covered separately by the client fetch interceptor (src/client.js).
|
|
14
|
+
import { LOG_PREFIX } from '../version.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Official browser-writable settings namespaces (dsh-host-apiproxy
|
|
18
|
+
* WEB_SETTINGS_NAMESPACES), as documented in dsh-upstream-fixes/README.md.
|
|
19
|
+
* Product namespaces and dynamic model-provider namespaces are not enumerable
|
|
20
|
+
* without dsh internals; callers may extend this set via `extraWhitelist`.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_WEB_SETTINGS_NAMESPACES = new Set([
|
|
23
|
+
'agent-loop',
|
|
24
|
+
'shell',
|
|
25
|
+
'locale',
|
|
26
|
+
'permission',
|
|
27
|
+
'ui-conversation',
|
|
28
|
+
'ui-theme',
|
|
29
|
+
'web-search-deepseek',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Shape a raw settings descriptor into the official NamespaceView wire shape
|
|
34
|
+
* (mirrors dsh-upstream-fixes/lib/index.js `namespaceView`).
|
|
35
|
+
*/
|
|
36
|
+
export function toNamespaceView(descriptor) {
|
|
37
|
+
const view = {
|
|
38
|
+
ns: String(descriptor.ns),
|
|
39
|
+
schema: descriptor.schema,
|
|
40
|
+
value: descriptor.value,
|
|
41
|
+
...(descriptor.base === undefined ? {} : { base: descriptor.base }),
|
|
42
|
+
...(descriptor.user === undefined ? {} : { user: descriptor.user }),
|
|
43
|
+
applies: descriptor.applies,
|
|
44
|
+
secrets: (descriptor.secrets ?? []).map((s) => ({ path: [...s.path], set: s.set })),
|
|
45
|
+
revision: descriptor.revision,
|
|
46
|
+
};
|
|
47
|
+
return view;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Map a thrown settings error into a SettingsResult, preserving the official
|
|
52
|
+
* `settings-conflict` / `settings-rejected` classification.
|
|
53
|
+
*/
|
|
54
|
+
export function settingsErrorToResult(error, ns, method) {
|
|
55
|
+
const isObject = error !== null && typeof error === 'object';
|
|
56
|
+
const isConflict = isObject && ('expected' in error || 'actual' in error);
|
|
57
|
+
if (isConflict) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
code: 'settings-conflict',
|
|
61
|
+
message: error.message ?? String(error),
|
|
62
|
+
details: {
|
|
63
|
+
ns,
|
|
64
|
+
...(error.expected === undefined ? {} : { expected: error.expected }),
|
|
65
|
+
...(error.actual === undefined ? {} : { actual: error.actual }),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
code: 'settings-rejected',
|
|
72
|
+
message: error instanceof Error ? error.message : String(error),
|
|
73
|
+
details: { ns, method },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the `ctx.dshLoader.settings` stable API.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ ctx: object, exposeAllNamespaces: boolean, whitelist?: Set<string> }} opts
|
|
81
|
+
*/
|
|
82
|
+
export function createSettingsAPI({ ctx, exposeAllNamespaces, whitelist }) {
|
|
83
|
+
const allowed = whitelist ?? DEFAULT_WEB_SETTINGS_NAMESPACES;
|
|
84
|
+
|
|
85
|
+
function getSettings() {
|
|
86
|
+
return ctx.get('settings');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function filterNamespaces(views) {
|
|
90
|
+
if (exposeAllNamespaces) return views;
|
|
91
|
+
return views.filter((v) => allowed.has(String(v.ns)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const api = {
|
|
95
|
+
exposeAllNamespaces: Boolean(exposeAllNamespaces),
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Register a settings namespace. Proxies to the real settings service's
|
|
99
|
+
* `register(ns, schema, options)` and returns the owner scope
|
|
100
|
+
* ({ get, watch }) — the same shape the official service returns.
|
|
101
|
+
*
|
|
102
|
+
* Unlike describe/update/replace/mutate, register is NOT filtered by the
|
|
103
|
+
* whitelist: host plugin code is trusted and registering a namespace is
|
|
104
|
+
* a composition-time act, not a browser-facing read/write.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} ns - unique namespace (lowercase kebab-case)
|
|
107
|
+
* @param {object} schema - schemastery schema for this namespace
|
|
108
|
+
* @param {{ base?: object, applies?: 'live'|'restart', validate?: (value:any)=>void }} [options]
|
|
109
|
+
* @returns {{ get: () => any, watch: (cb: (value:any)=>void) => () => void } | undefined}
|
|
110
|
+
*/
|
|
111
|
+
register(ns, schema, options) {
|
|
112
|
+
const settings = getSettings();
|
|
113
|
+
if (settings === undefined || typeof settings.register !== 'function') {
|
|
114
|
+
console.warn(`${LOG_PREFIX}:settings.register settings service unavailable`);
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
return settings.register(ns, schema, options);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
describe(options = {}) {
|
|
121
|
+
const settings = getSettings();
|
|
122
|
+
if (settings === undefined || typeof settings.describe !== 'function') {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const redactSecrets = options.redactSecrets !== false;
|
|
126
|
+
const descriptors = settings.describe({ redactSecrets });
|
|
127
|
+
const views = (descriptors ?? []).map(toNamespaceView);
|
|
128
|
+
return filterNamespaces(views);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async _write(method, ns, section, expectedRevision) {
|
|
132
|
+
const settings = getSettings();
|
|
133
|
+
if (settings === undefined) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
code: 'internal',
|
|
137
|
+
message: `${LOG_PREFIX}:settings.${method} settings service unavailable`,
|
|
138
|
+
details: { ns },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
if (method === 'update') await settings.update(ns, section, expectedRevision);
|
|
143
|
+
else if (method === 'replace') await settings.replace(ns, section, expectedRevision);
|
|
144
|
+
else await settings.mutate(ns, section, expectedRevision);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return settingsErrorToResult(error, ns, method);
|
|
147
|
+
}
|
|
148
|
+
const descriptor = settings
|
|
149
|
+
.describe({ redactSecrets: true })
|
|
150
|
+
.find((d) => String(d.ns) === String(ns));
|
|
151
|
+
if (descriptor === undefined) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
code: 'internal',
|
|
155
|
+
message: `${LOG_PREFIX}:settings.${method} namespace disposed after write`,
|
|
156
|
+
details: { ns },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, value: toNamespaceView(descriptor) };
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
update(ns, section, expectedRevision) {
|
|
163
|
+
return api._write('update', ns, section, expectedRevision);
|
|
164
|
+
},
|
|
165
|
+
replace(ns, section, expectedRevision) {
|
|
166
|
+
return api._write('replace', ns, section, expectedRevision);
|
|
167
|
+
},
|
|
168
|
+
mutate(ns, ops, expectedRevision) {
|
|
169
|
+
return api._write('mutate', ns, ops, expectedRevision);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return api;
|
|
174
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Web server stable API (design.md §3.3.2 / §4.1).
|
|
2
|
+
//
|
|
3
|
+
// Routes every registration through the real `webServer` (or `httpServer`
|
|
4
|
+
// alias) `register({ kind, ... })` call shape used by dsh 1.x, mirroring
|
|
5
|
+
// dsh-upstream-fixes/lib/index.js `registerRoutes`. Each method returns a
|
|
6
|
+
// dispose function that removes the registration when the underlying
|
|
7
|
+
// service supports it.
|
|
8
|
+
import { LOG_PREFIX } from '../version.js';
|
|
9
|
+
|
|
10
|
+
export class DshLoaderWebError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'DshLoaderWebError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Resolve the active web server service (webServer preferred, httpServer alias next). */
|
|
18
|
+
function resolveWebServer(ctx) {
|
|
19
|
+
return ctx.get('webServer') ?? ctx.get('httpServer');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the `ctx.dshLoader.web` stable API.
|
|
24
|
+
* @param {{ ctx: object }} opts
|
|
25
|
+
*/
|
|
26
|
+
export function createWebAPI({ ctx }) {
|
|
27
|
+
function server() {
|
|
28
|
+
const web = resolveWebServer(ctx);
|
|
29
|
+
if (web === undefined || typeof web.register !== 'function') {
|
|
30
|
+
throw new DshLoaderWebError(
|
|
31
|
+
`${LOG_PREFIX}:web webServer service unavailable`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return web;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
register(prefix, handler) {
|
|
39
|
+
const web = server();
|
|
40
|
+
return web.register({ kind: 'prefix', path: prefix, handler }) ?? (() => {});
|
|
41
|
+
},
|
|
42
|
+
get(path, handler) {
|
|
43
|
+
const web = server();
|
|
44
|
+
return web.register({ kind: 'route', method: 'GET', path, handler }) ?? (() => {});
|
|
45
|
+
},
|
|
46
|
+
post(path, handler) {
|
|
47
|
+
const web = server();
|
|
48
|
+
return web.register({ kind: 'route', method: 'POST', path, handler }) ?? (() => {});
|
|
49
|
+
},
|
|
50
|
+
use(middleware) {
|
|
51
|
+
const web = server();
|
|
52
|
+
return web.register({ kind: 'middleware', handler: middleware }) ?? (() => {});
|
|
53
|
+
},
|
|
54
|
+
/**
|
|
55
|
+
* Register a WebSocket upgrade route for an exact pathname.
|
|
56
|
+
* Proxies to `webServer.registerUpgrade({ path, handler })`.
|
|
57
|
+
*
|
|
58
|
+
* @param {{ path: string, handler: (req: any, socket: any, head: Buffer) => void }} route
|
|
59
|
+
* @returns {() => void} dispose function that removes the upgrade route
|
|
60
|
+
*/
|
|
61
|
+
registerUpgrade(route) {
|
|
62
|
+
const web = server();
|
|
63
|
+
if (typeof web.registerUpgrade !== 'function') {
|
|
64
|
+
throw new DshLoaderWebError(
|
|
65
|
+
`${LOG_PREFIX}:web.registerUpgrade webServer does not support upgrade routes (registerUpgrade missing)`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return web.registerUpgrade(route) ?? (() => {});
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|