dsh-agent-plugins-market 0.6.2 → 0.7.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/README.md +117 -111
- package/README.zh.md +112 -98
- package/client/client.js +28533 -1511
- package/client/client.js.map +1 -1
- package/lib/application/catalog-context.js +188 -0
- package/lib/application/catalog.js +229 -1109
- package/lib/application/details.js +19 -8
- package/lib/application/install-store.js +95 -0
- package/lib/application/lsp-service.js +97 -0
- package/lib/application/mcp-service.js +263 -0
- package/lib/application/panel-resources.js +43 -14
- package/lib/application/ports.js +21 -0
- package/lib/application/project-agent-roles.js +8 -2
- package/lib/application/snapshot-cache.js +160 -0
- package/lib/application/source-store.js +521 -0
- package/lib/catalog/archive.js +35 -30
- package/lib/catalog/component-files.js +14 -6
- package/lib/catalog/fs-probes.js +43 -0
- package/lib/catalog/manifests.js +53 -92
- package/lib/catalog/native-project.js +2 -2
- package/lib/catalog/paths.js +74 -55
- package/lib/catalog/plugin-variables.js +38 -0
- package/lib/catalog/project-config.js +2 -1
- package/lib/catalog/project-hooks.js +23 -2
- package/lib/catalog/scan-resolvers.js +29 -45
- package/lib/catalog/skills-parse.js +61 -10
- package/lib/catalog/source-catalog.js +10 -4
- package/lib/catalog/suite-components.js +7 -7
- package/lib/catalog/suite-scanner.js +3 -1
- package/lib/catalog/surfaces.js +5 -26
- package/lib/catalog/validate.js +7 -11
- package/lib/contracts/market.js +2 -0
- package/lib/contracts/settings.js +63 -0
- package/lib/index.js +115 -217
- package/lib/model/layouts.js +10 -1
- package/lib/model/types.js +26 -3
- package/lib/routes.js +57 -15
- package/lib/runtime/agent-role-names.js +70 -0
- package/lib/runtime/agent-role-router.js +142 -106
- package/lib/runtime/commands-mounts.js +55 -11
- package/lib/runtime/deadline.js +54 -0
- package/lib/runtime/dynamic-context.js +140 -0
- package/lib/runtime/feedback-tool.js +101 -46
- package/lib/runtime/hooks-mounts.js +9 -11
- package/lib/runtime/host-locale.js +49 -24
- package/lib/runtime/lsp-direct-config.js +19 -19
- package/lib/runtime/lsp-mounts.js +182 -70
- package/lib/runtime/lsp-server-state.js +4 -4
- package/lib/runtime/lsp-status.js +1 -1
- package/lib/runtime/mcp-auth-record.js +42 -0
- package/lib/runtime/mcp-backend.js +14 -14
- package/lib/runtime/mcp-client/config.js +8 -1
- package/lib/runtime/mcp-client/connection.js +38 -43
- package/lib/runtime/mcp-client/host-contract.js +10 -0
- package/lib/runtime/mcp-client/host-seams.js +8 -88
- package/lib/runtime/mcp-client/oauth.js +5 -7
- package/lib/runtime/mcp-client/plugin-identity.js +28 -0
- package/lib/runtime/mcp-client/projection.js +202 -0
- package/lib/runtime/mcp-client/tools.js +4 -189
- package/lib/runtime/mcp-config.js +36 -14
- package/lib/runtime/mcp-direct-config.js +33 -16
- package/lib/runtime/mcp-mounts.js +35 -75
- package/lib/runtime/mcp-overrides.js +5 -5
- package/lib/runtime/mcp-redaction.js +3 -2
- package/lib/runtime/mcp-status.js +7 -8
- package/lib/runtime/mount-lifecycle.js +73 -0
- package/lib/runtime/profile-seam.js +321 -0
- package/lib/runtime/project-runtime.js +34 -17
- package/lib/runtime/reconcile-scheduler.js +97 -0
- package/lib/runtime/reconciler.js +6 -1
- package/lib/runtime/regions.js +0 -19
- package/lib/runtime/server-config.js +6 -4
- package/lib/runtime/settings-namespace.js +144 -0
- package/lib/runtime/skills-provider.js +38 -14
- package/lib/runtime/source-auto-update.js +66 -0
- package/lib/{model/state.js → runtime/state-store.js} +9 -11
- package/lib/runtime/storage-migration.js +35 -18
- package/lib/runtime/subagent-catalog.js +5 -4
- package/lib/runtime/timer-seat.js +80 -0
- package/lib/runtime/tool-registry-observer.js +20 -20
- package/lib/runtime/user-commands.js +7 -3
- package/lib/runtime/user-panels.js +125 -46
- package/lib/runtime/user-store.js +124 -69
- package/lib/types/application/catalog-context.d.ts +124 -0
- package/lib/types/application/catalog.d.ts +108 -304
- package/lib/types/application/install-store.d.ts +21 -0
- package/lib/types/application/lsp-service.d.ts +36 -0
- package/lib/types/application/mcp-service.d.ts +77 -0
- package/lib/types/application/panel-resources.d.ts +8 -0
- package/lib/types/application/ports.d.ts +72 -0
- package/lib/types/application/queries.d.ts +15 -26
- package/lib/types/application/snapshot-cache.d.ts +51 -0
- package/lib/types/application/source-store.d.ts +100 -0
- package/lib/types/catalog/archive.d.ts +3 -11
- package/lib/types/catalog/component-files.d.ts +4 -0
- package/lib/types/catalog/fs-probes.d.ts +10 -0
- package/lib/types/catalog/manifests.d.ts +17 -16
- package/lib/types/catalog/native-project.d.ts +2 -2
- package/lib/types/catalog/paths.d.ts +53 -15
- package/lib/types/catalog/plugin-variables.d.ts +16 -0
- package/lib/types/catalog/scan-pipeline.d.ts +3 -3
- package/lib/types/catalog/scan-resolvers.d.ts +3 -9
- package/lib/types/catalog/skills-parse.d.ts +13 -7
- package/lib/types/catalog/source-catalog.d.ts +10 -5
- package/lib/types/catalog/suite-scanner.d.ts +8 -6
- package/lib/types/catalog/validate.d.ts +0 -2
- package/lib/types/contracts/lsp-status.d.ts +35 -0
- package/lib/types/contracts/market.d.ts +13 -2
- package/lib/types/contracts/mcp-status.d.ts +4 -0
- package/lib/types/contracts/settings.d.ts +59 -0
- package/lib/types/model/layouts.d.ts +10 -0
- package/lib/types/model/types.d.ts +29 -5
- package/lib/types/runtime/agent-role-names.d.ts +6 -0
- package/lib/types/runtime/agent-role-router.d.ts +59 -52
- package/lib/types/runtime/commands-mounts.d.ts +15 -7
- package/lib/types/runtime/deadline.d.ts +17 -0
- package/lib/types/runtime/dynamic-context.d.ts +81 -0
- package/lib/types/runtime/feedback-tool.d.ts +33 -11
- package/lib/types/runtime/hooks-mounts.d.ts +1 -1
- package/lib/types/runtime/host-locale.d.ts +20 -2
- package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
- package/lib/types/runtime/lsp-mounts.d.ts +69 -19
- package/lib/types/runtime/lsp-status.d.ts +2 -1
- package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
- package/lib/types/runtime/mcp-backend.d.ts +10 -14
- package/lib/types/runtime/mcp-client/config.d.ts +8 -2
- package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
- package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
- package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
- package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
- package/lib/types/runtime/mcp-config.d.ts +25 -4
- package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
- package/lib/types/runtime/mcp-mounts.d.ts +10 -15
- package/lib/types/runtime/mcp-overrides.d.ts +1 -1
- package/lib/types/runtime/mcp-status.d.ts +1 -1
- package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
- package/lib/types/runtime/profile-seam.d.ts +64 -0
- package/lib/types/runtime/project-runtime.d.ts +10 -3
- package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
- package/lib/types/runtime/reconciler.d.ts +2 -0
- package/lib/types/runtime/regions.d.ts +1 -4
- package/lib/types/runtime/settings-namespace.d.ts +76 -0
- package/lib/types/runtime/skills-provider.d.ts +11 -2
- package/lib/types/runtime/source-auto-update.d.ts +32 -0
- package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
- package/lib/types/runtime/storage-migration.d.ts +5 -3
- package/lib/types/runtime/subagent-catalog.d.ts +2 -0
- package/lib/types/runtime/timer-seat.d.ts +51 -0
- package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
- package/lib/types/runtime/user-panels.d.ts +69 -19
- package/lib/types/runtime/user-store.d.ts +67 -20
- package/package.json +68 -24
- package/schemas/README.md +1 -1
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
* to the DeepSeek function-name constraints. The raw name is only ever sent on
|
|
10
10
|
* the wire (`tools/call`); the public name is never parsed to recover it.
|
|
11
11
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* upstream bridge registers, so the model-facing behavior is identical.
|
|
12
|
+
* The registration surface this module targets is mirrored by
|
|
13
|
+
* `./host-contract.js`; MCP results are mapped into host content blocks by
|
|
14
|
+
* `./projection.js`.
|
|
16
15
|
*
|
|
17
16
|
* @module runtime/mcp-client/tools
|
|
18
17
|
*/
|
|
@@ -21,7 +20,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
21
20
|
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
22
21
|
import { z } from 'zod';
|
|
23
22
|
import { assertSupportedJsonSchema } from './json-schema-subset.js';
|
|
24
|
-
import {
|
|
23
|
+
import { containsImage, extractText, prepareImageProjection } from './projection.js';
|
|
25
24
|
/**
|
|
26
25
|
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
|
|
27
26
|
* constant, not configuration.
|
|
@@ -31,10 +30,6 @@ const MAX_PUBLIC_NAME_LENGTH = 64;
|
|
|
31
30
|
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
|
|
32
31
|
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
|
|
33
32
|
const HASH_LENGTH = 12;
|
|
34
|
-
/** Raster formats supported by the durable attachment vocabulary. */
|
|
35
|
-
const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
|
|
36
|
-
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
|
|
37
|
-
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
38
33
|
/** Raw result record: the bridge owns JSON-value validation after transport. */
|
|
39
34
|
const RawCallToolResultSchema = z.record(z.string(), z.unknown());
|
|
40
35
|
/** List without mutating the SDK's per-page output-validator cache. */
|
|
@@ -253,183 +248,3 @@ function createExecutor(client, host, rawName, taskRequired, opts, projections)
|
|
|
253
248
|
return value;
|
|
254
249
|
};
|
|
255
250
|
}
|
|
256
|
-
/** Whether an untrusted MCP content array contains a declared image block. */
|
|
257
|
-
function containsImage(content) {
|
|
258
|
-
return content.some(value => isRecord(value) && value.type === 'image');
|
|
259
|
-
}
|
|
260
|
-
/** Narrow one JSON value to a string-keyed object. */
|
|
261
|
-
function isRecord(value) {
|
|
262
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
263
|
-
}
|
|
264
|
-
/** Narrow a declared MIME string to the durable image vocabulary. */
|
|
265
|
-
function isImageMediaType(value) {
|
|
266
|
-
return IMAGE_MEDIA_TYPES.includes(value);
|
|
267
|
-
}
|
|
268
|
-
/** Decode one untrusted MCP image block without accepting base64 aliases. */
|
|
269
|
-
function decodeImage(block) {
|
|
270
|
-
if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) {
|
|
271
|
-
throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF');
|
|
272
|
-
}
|
|
273
|
-
if (block.data === undefined || !CANONICAL_BASE64.test(block.data)) {
|
|
274
|
-
throw new Error('the image data is not canonical base64');
|
|
275
|
-
}
|
|
276
|
-
const data = Buffer.from(block.data, 'base64');
|
|
277
|
-
if (data.toString('base64') !== block.data) {
|
|
278
|
-
throw new Error('the image data is not canonical base64');
|
|
279
|
-
}
|
|
280
|
-
return { data, mediaType: block.mimeType };
|
|
281
|
-
}
|
|
282
|
-
/**
|
|
283
|
-
* Resolve the active model route and durable store for an image-bearing result.
|
|
284
|
-
* @param host - bridge host with optional services.
|
|
285
|
-
* @param exec - exact tool execution whose agent supplies the latest route.
|
|
286
|
-
* @returns the attachment store after exact positive image-capability proof.
|
|
287
|
-
*/
|
|
288
|
-
async function resolveImageAdmission(host, exec) {
|
|
289
|
-
const attachments = host.getService?.('attachments');
|
|
290
|
-
if (attachments === undefined || typeof attachments.saveImages !== 'function') {
|
|
291
|
-
throw new Error('no attachment store is mounted');
|
|
292
|
-
}
|
|
293
|
-
const routed = exec.agent?.session?.requestHeader?.()?.config;
|
|
294
|
-
const provider = routed?.provider ?? exec.agent?.options?.provider;
|
|
295
|
-
const model = routed?.model ?? exec.agent?.options?.model;
|
|
296
|
-
const llm = host.getService?.('llm');
|
|
297
|
-
if (provider === undefined || model === undefined || llm === undefined) {
|
|
298
|
-
throw new Error('the current model route could not be resolved');
|
|
299
|
-
}
|
|
300
|
-
let info;
|
|
301
|
-
try {
|
|
302
|
-
info = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
303
|
-
}
|
|
304
|
-
catch {
|
|
305
|
-
throw new Error('the current model route could not be verified');
|
|
306
|
-
}
|
|
307
|
-
if (info?.inputModalities === undefined || !info.inputModalities.includes('image')) {
|
|
308
|
-
throw new Error(`model "${model}" does not declare image input`);
|
|
309
|
-
}
|
|
310
|
-
if (exec.signal.aborted)
|
|
311
|
-
throw new Error('the tool call was canceled before image storage');
|
|
312
|
-
return attachments;
|
|
313
|
-
}
|
|
314
|
-
/** Stable diagnostic text for an image block that was not admitted. */
|
|
315
|
-
function imageDiagnostic(block, reason) {
|
|
316
|
-
return `[image unavailable: ${block.mimeType ?? 'unknown media type'}; ${reason}; raw image data remains available to programmatic callers]`;
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* Decode, preflight, and durably save one MCP result's ordered image batch.
|
|
320
|
-
* Any refusal projects every image as text while retaining the canonical raw
|
|
321
|
-
* value for programmatic callers.
|
|
322
|
-
*/
|
|
323
|
-
async function prepareImageProjection(host, exec, content, toolName) {
|
|
324
|
-
const decoded = [];
|
|
325
|
-
const validationErrors = new Map();
|
|
326
|
-
const imageIndexes = [];
|
|
327
|
-
for (const [index, value] of content.entries()) {
|
|
328
|
-
if (!isRecord(value) || value.type !== 'image')
|
|
329
|
-
continue;
|
|
330
|
-
imageIndexes.push(index);
|
|
331
|
-
try {
|
|
332
|
-
decoded.push(decodeImage(value));
|
|
333
|
-
}
|
|
334
|
-
catch (error) {
|
|
335
|
-
// decodeImage owns every throw above and always produces Error.
|
|
336
|
-
validationErrors.set(index, error.message);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
if (validationErrors.size > 0) {
|
|
340
|
-
return projectContent(content, toolName, (block, index) => ({
|
|
341
|
-
type: 'text',
|
|
342
|
-
text: imageDiagnostic(block, validationErrors.get(index) ?? 'another image in the same result was invalid')
|
|
343
|
-
}));
|
|
344
|
-
}
|
|
345
|
-
let attachments;
|
|
346
|
-
try {
|
|
347
|
-
attachments = await resolveImageAdmission(host, exec);
|
|
348
|
-
}
|
|
349
|
-
catch (error) {
|
|
350
|
-
// resolveImageAdmission contains provider failures and throws Error only.
|
|
351
|
-
const reason = error.message;
|
|
352
|
-
return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }));
|
|
353
|
-
}
|
|
354
|
-
try {
|
|
355
|
-
const refs = await attachments.saveImages(decoded);
|
|
356
|
-
const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset]]));
|
|
357
|
-
return projectContent(content, toolName, (_block, index) => ({
|
|
358
|
-
type: 'image',
|
|
359
|
-
attachment: byIndex.get(index)
|
|
360
|
-
}));
|
|
361
|
-
}
|
|
362
|
-
catch (error) {
|
|
363
|
-
const reason = isImageAdmissionError(error) ? `image admission rejected the result: ${error.message}` : 'durable image storage rejected the result';
|
|
364
|
-
return projectContent(content, toolName, block => ({
|
|
365
|
-
type: 'text',
|
|
366
|
-
text: imageDiagnostic(block, reason)
|
|
367
|
-
}));
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
/**
|
|
371
|
-
* Extract text from an MCP content array into a single string.
|
|
372
|
-
* - text blocks: join with '\n'
|
|
373
|
-
* - image/audio/resource blocks: replaced with a placeholder
|
|
374
|
-
*
|
|
375
|
-
* Defensive: fields that the MCP spec declares required (mimeType, text) are
|
|
376
|
-
* guarded with fallbacks because this is a network trust boundary.
|
|
377
|
-
*/
|
|
378
|
-
function extractText(mcpContent, toolName) {
|
|
379
|
-
const content = projectContent(mcpContent, toolName);
|
|
380
|
-
// The default image projector below also returns text, so this local call
|
|
381
|
-
// cannot produce a core image block.
|
|
382
|
-
return content.map(block => block.text).join('\n');
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Project ordered MCP blocks into the core content vocabulary.
|
|
386
|
-
* Text-like runs are newline-coalesced; admitted images split those runs at
|
|
387
|
-
* their original position.
|
|
388
|
-
*/
|
|
389
|
-
function projectContent(mcpContent, toolName, image = block => ({
|
|
390
|
-
type: 'text',
|
|
391
|
-
text: imageDiagnostic(block, 'this result was not admitted to durable model context')
|
|
392
|
-
})) {
|
|
393
|
-
const projected = [];
|
|
394
|
-
const text = [];
|
|
395
|
-
const flushText = () => {
|
|
396
|
-
if (text.length === 0)
|
|
397
|
-
return;
|
|
398
|
-
projected.push({ type: 'text', text: text.splice(0).join('\n') });
|
|
399
|
-
};
|
|
400
|
-
for (const [index, value] of mcpContent.entries()) {
|
|
401
|
-
if (!isRecord(value)) {
|
|
402
|
-
text.push('[unsupported MCP content block: expected an object]');
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
|
-
const block = value;
|
|
406
|
-
switch (block.type) {
|
|
407
|
-
case 'text':
|
|
408
|
-
if (block.text !== undefined)
|
|
409
|
-
text.push(block.text);
|
|
410
|
-
break;
|
|
411
|
-
case 'image':
|
|
412
|
-
flushText();
|
|
413
|
-
projected.push(image(block, index));
|
|
414
|
-
break;
|
|
415
|
-
case 'resource_link':
|
|
416
|
-
if (block.name === undefined || block.uri === undefined) {
|
|
417
|
-
text.push('[resource link unavailable: the MCP block is missing its name or URI]');
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
text.push(`Resource link: ${block.name} (${block.uri})`);
|
|
421
|
-
}
|
|
422
|
-
break;
|
|
423
|
-
case 'audio':
|
|
424
|
-
text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`);
|
|
425
|
-
break;
|
|
426
|
-
case 'resource':
|
|
427
|
-
text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]');
|
|
428
|
-
break;
|
|
429
|
-
default:
|
|
430
|
-
text.push(`[unsupported MCP content type: ${block.type}]`);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
flushText();
|
|
434
|
-
return projected.length > 0 ? projected : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }];
|
|
435
|
-
}
|
|
@@ -13,13 +13,29 @@
|
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
14
|
import { DEFAULT_TOOL_CALL_TIMEOUT_MS } from './mcp-client/config.js';
|
|
15
15
|
import { resolveCwd } from '../catalog/validate.js';
|
|
16
|
-
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
16
|
+
import { qualifiedSuiteId, suiteDataDir } from '../catalog/paths.js';
|
|
17
17
|
import { applyOverride } from './mcp-overrides.js';
|
|
18
18
|
import { PLUGIN_ROOT_VARIABLES, PLUGIN_DATA_VARIABLES } from '../model/layouts.js';
|
|
19
19
|
/** The max length the bridge accepts for a serverName. */
|
|
20
20
|
const SERVER_NAME_MAX = 32;
|
|
21
21
|
const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
22
22
|
const BUILTIN_PLACEHOLDERS = new Set([...PLUGIN_ROOT_VARIABLES, ...PLUGIN_DATA_VARIABLES]);
|
|
23
|
+
/**
|
|
24
|
+
* The effective view of one suite's `mcp.json` servers: the user's per-server
|
|
25
|
+
* overrides applied, every row flagged with whether it is used at all, and each
|
|
26
|
+
* row's credential references read off the effective shape. Mounting, status
|
|
27
|
+
* and detail rendering read this one projection, so a server cannot be listed
|
|
28
|
+
* as enabled while mounting something else.
|
|
29
|
+
*/
|
|
30
|
+
export function effectiveMcpServers(suite, overrides = {}) {
|
|
31
|
+
const rows = [];
|
|
32
|
+
for (const [serverKey, source] of Object.entries(suite.mcp?.servers ?? {})) {
|
|
33
|
+
const override = overrides[serverKey];
|
|
34
|
+
const server = applyOverride(source, override);
|
|
35
|
+
rows.push({ serverKey, server, override, enabled: override?.enabled !== false, credentialRefs: credentialRefsInServer(server) });
|
|
36
|
+
}
|
|
37
|
+
return rows;
|
|
38
|
+
}
|
|
23
39
|
/**
|
|
24
40
|
* Build one mount request per supported mcp.json server, resolving every
|
|
25
41
|
* `${NAME}` through the credential resolver before a child process or HTTP
|
|
@@ -29,31 +45,34 @@ const BUILTIN_PLACEHOLDERS = new Set([...PLUGIN_ROOT_VARIABLES, ...PLUGIN_DATA_V
|
|
|
29
45
|
* @param overrides user-owned per-server overrides (url/headers/env/args
|
|
30
46
|
* replacement plus enable/disable); applied before mount. Disabled servers
|
|
31
47
|
* are omitted entirely.
|
|
32
|
-
* @returns mount requests
|
|
33
|
-
*
|
|
48
|
+
* @returns mount requests, per-server failures, and the credential references
|
|
49
|
+
* the effective servers need. serverName collisions are reported by the
|
|
50
|
+
* mount registry, not here.
|
|
34
51
|
*/
|
|
35
52
|
export async function toMcpMounts(suite, pluginDataRoot, overrides = {}, resolver = { resolve: async () => undefined }) {
|
|
36
53
|
if (suite.mcp === undefined)
|
|
37
|
-
return { mounts: [], failures: [] };
|
|
54
|
+
return { mounts: [], failures: [], credentialRefs: [] };
|
|
38
55
|
const mounts = [];
|
|
39
56
|
const failures = [];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
57
|
+
const credentialRefs = new Set();
|
|
58
|
+
for (const row of effectiveMcpServers(suite, overrides)) {
|
|
59
|
+
if (!row.enabled)
|
|
43
60
|
continue;
|
|
44
|
-
const
|
|
61
|
+
for (const ref of row.credentialRefs)
|
|
62
|
+
credentialRefs.add(ref);
|
|
45
63
|
try {
|
|
46
|
-
const
|
|
64
|
+
const target = suite.mcp.root === undefined ? suite : { ...suite, root: suite.mcp.root };
|
|
65
|
+
const result = await toResolvedMount(target, row.serverKey, row.server, pluginDataRoot, resolver);
|
|
47
66
|
if (result.failure !== undefined)
|
|
48
67
|
failures.push(result.failure);
|
|
49
68
|
if (result.request !== undefined)
|
|
50
69
|
mounts.push(result.request);
|
|
51
70
|
}
|
|
52
71
|
catch {
|
|
53
|
-
failures.push({ serverKey, code: 'credential-error', credentialRefs:
|
|
72
|
+
failures.push({ serverKey: row.serverKey, code: 'credential-error', credentialRefs: row.credentialRefs, reason: 'credential lookup failed' });
|
|
54
73
|
}
|
|
55
74
|
}
|
|
56
|
-
return { mounts, failures };
|
|
75
|
+
return { mounts, failures, credentialRefs: [...credentialRefs] };
|
|
57
76
|
}
|
|
58
77
|
/** Find external credential references used by one MCP server definition. */
|
|
59
78
|
export function credentialRefsInServer(server) {
|
|
@@ -79,7 +98,7 @@ export function credentialRefsInServer(server) {
|
|
|
79
98
|
async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolver) {
|
|
80
99
|
if (server.type === 'sse') {
|
|
81
100
|
// The market bridge supports the legacy HTTP+SSE transport natively.
|
|
82
|
-
const expand = expander(suite,
|
|
101
|
+
const expand = expander(suite, suiteDataDir(pluginDataRoot, suite.sourceId, suite.id), resolver);
|
|
83
102
|
const url = await expand.one(server.url);
|
|
84
103
|
const headers = await expand.map(server.headers ?? {});
|
|
85
104
|
const missing = unique([...url.missing, ...headers.missing]);
|
|
@@ -96,7 +115,7 @@ async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolve
|
|
|
96
115
|
};
|
|
97
116
|
return { request: { suiteId: qualifiedSuiteId(suite.sourceId, suite.id), serverKey, config: sseConfig } };
|
|
98
117
|
}
|
|
99
|
-
const expand = expander(suite,
|
|
118
|
+
const expand = expander(suite, suiteDataDir(pluginDataRoot, suite.sourceId, suite.id), resolver);
|
|
100
119
|
const serverName = deriveServerName(suite.id, serverKey);
|
|
101
120
|
if (server.type === 'stdio') {
|
|
102
121
|
const args = await expand.all(server.args ?? []);
|
|
@@ -115,7 +134,7 @@ async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolve
|
|
|
115
134
|
command: server.command.startsWith('./') ? joinInside(suite.root, server.command.slice(2)) : server.command,
|
|
116
135
|
args: args.values,
|
|
117
136
|
env: env.values,
|
|
118
|
-
cwd: resolveCwd(cwd.value, suite.root,
|
|
137
|
+
cwd: resolveCwd(cwd.value, suite.root, suiteDataDir(pluginDataRoot, suite.sourceId, suite.id)),
|
|
119
138
|
...bridgePolicy(server),
|
|
120
139
|
failOnStartupError: true
|
|
121
140
|
}
|
|
@@ -158,6 +177,9 @@ function mapAuth(auth) {
|
|
|
158
177
|
function bridgePolicy(server) {
|
|
159
178
|
return {
|
|
160
179
|
toolCallTimeoutMs: server.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS,
|
|
180
|
+
// Only a DECLARED startup timeout is a policy: host compatibility mode
|
|
181
|
+
// cannot enforce one, so an undeclared server stays enforceable there and
|
|
182
|
+
// the built-in bridge applies its own default at connect time.
|
|
161
183
|
...(server.startupTimeoutMs === undefined ? {} : { startupTimeoutMs: server.startupTimeoutMs }),
|
|
162
184
|
...(server.enabledTools === undefined ? {} : { enabledTools: server.enabledTools }),
|
|
163
185
|
...(server.disabledTools === undefined ? {} : { disabledTools: server.disabledTools })
|
|
@@ -1,56 +1,73 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
3
4
|
import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalog/validate.js';
|
|
5
|
+
import { effectiveSurfaces } from '../model/types.js';
|
|
4
6
|
export const USER_MCP_SOURCE = '@user-mcp';
|
|
5
7
|
export const USER_MCP_SUITE = 'user-mcp';
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* User-created services reuse the owned bridge lifecycle without changing host configuration.
|
|
10
|
+
*
|
|
11
|
+
* The declarations live in the shared Agent layout root
|
|
12
|
+
* (`~/.agents/mcp.json`), so a service the user adds by hand is ordinary
|
|
13
|
+
* `mcpServers` JSON in the same place other Agent tools look.
|
|
14
|
+
*
|
|
15
|
+
* The suite always carries an `mcp` document — an absent or malformed
|
|
16
|
+
* `mcp.json` leaves an empty one — so callers read `.mcp.servers` directly
|
|
17
|
+
* instead of re-checking the optional field.
|
|
18
|
+
*/
|
|
19
|
+
export async function loadUserMcpSuite(agentsRoot) {
|
|
20
|
+
const path = userMcpPath(agentsRoot);
|
|
9
21
|
let mcp = { schema: MCP_SCHEMA_ID, servers: {} };
|
|
10
22
|
const errors = [];
|
|
11
23
|
try {
|
|
12
|
-
mcp = await validateUserMcp(
|
|
24
|
+
mcp = await validateUserMcp(agentsRoot, JSON.parse(await readFile(path, 'utf8')));
|
|
13
25
|
}
|
|
14
26
|
catch (error) {
|
|
15
27
|
if (error.code !== 'ENOENT')
|
|
16
|
-
errors.push(`mcp
|
|
28
|
+
errors.push(`mcp.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
17
29
|
}
|
|
18
30
|
return {
|
|
19
31
|
sourceId: USER_MCP_SOURCE,
|
|
20
32
|
id: USER_MCP_SUITE,
|
|
21
|
-
root:
|
|
33
|
+
root: agentsRoot,
|
|
22
34
|
manifest: { layout: 'agent-plugin-v1', path, id: USER_MCP_SUITE, name: USER_MCP_SUITE },
|
|
23
35
|
skills: [],
|
|
24
36
|
mcp,
|
|
25
37
|
surfaces: { skills: 0, mcp: Object.keys(mcp.servers).length, commands: 0, agents: 0, hooks: 0, lsp: 0 },
|
|
26
38
|
dimension: 'user',
|
|
27
39
|
enabled: true,
|
|
40
|
+
// A user-created suite has no install entry and no overrides, so every
|
|
41
|
+
// surface keeps its enabled default.
|
|
42
|
+
activeSurfaces: effectiveSurfaces(undefined),
|
|
28
43
|
installedAt: 'user',
|
|
29
44
|
errors
|
|
30
45
|
};
|
|
31
46
|
}
|
|
32
|
-
async function validateUserMcp(
|
|
47
|
+
async function validateUserMcp(agentsRoot, raw) {
|
|
33
48
|
const errors = await validateAgainstSchema(MCP_SCHEMA_ID, raw);
|
|
34
49
|
if (errors.length > 0)
|
|
35
50
|
throw new Error(`invalid MCP configuration: ${errors.join('; ')}`);
|
|
36
|
-
const result = await validateMcpJson(
|
|
51
|
+
const result = await validateMcpJson(agentsRoot, raw);
|
|
37
52
|
if (result.config === undefined || result.errors.length > 0)
|
|
38
53
|
throw new Error(result.errors.join('; '));
|
|
39
54
|
return result.config;
|
|
40
55
|
}
|
|
41
56
|
/** Add one service atomically; reject collisions and malformed config before writing. */
|
|
42
|
-
export async function addUserMcpServer(
|
|
57
|
+
export async function addUserMcpServer(agentsRoot, name, server) {
|
|
43
58
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
|
|
44
59
|
throw new Error('invalid MCP server name');
|
|
45
|
-
const suite = await loadUserMcpSuite(
|
|
60
|
+
const suite = await loadUserMcpSuite(agentsRoot);
|
|
46
61
|
if (suite.errors.length > 0)
|
|
47
62
|
throw new Error(suite.errors.join('; '));
|
|
48
63
|
if (Object.hasOwn(suite.mcp.servers, name))
|
|
49
64
|
throw new Error(`MCP server "${name}" already exists`);
|
|
50
65
|
const document = { $schema: MCP_SCHEMA_ID, mcpServers: { ...suite.mcp.servers, [name]: server } };
|
|
51
|
-
await validateUserMcp(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
await validateUserMcp(agentsRoot, document);
|
|
67
|
+
const path = userMcpPath(agentsRoot);
|
|
68
|
+
await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
|
|
69
|
+
}
|
|
70
|
+
/** The user's hand-written MCP declaration file: `<agentsRoot>/mcp.json`. */
|
|
71
|
+
export function userMcpPath(agentsRoot) {
|
|
72
|
+
return join(agentsRoot, 'mcp.json');
|
|
56
73
|
}
|
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import * as mcpBridge from './mcp-client/bridge.js';
|
|
3
|
-
import {
|
|
4
|
-
import { credentialRefsInServer, deriveServerName, toMcpMounts } from './mcp-config.js';
|
|
3
|
+
import { toMcpMounts } from './mcp-config.js';
|
|
5
4
|
import { mcpCredentialResolver } from './mcp-credentials.js';
|
|
5
|
+
import { SerialPassQueue, RetryScheduler } from './mount-lifecycle.js';
|
|
6
6
|
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
7
|
-
/**
|
|
8
|
-
* Bounded retry schedule for a failed mount/unmount. A crash-looping or
|
|
9
|
-
* permanently broken server must eventually stop consuming attempt budget, so
|
|
10
|
-
* the schedule is capped and resets with every reconcile pass.
|
|
11
|
-
*/
|
|
12
|
-
const RETRY_SCHEDULE_MS = [1_500, 5_000, 15_000, 45_000, 120_000];
|
|
13
|
-
const MAX_RETRY_ATTEMPTS = RETRY_SCHEDULE_MS.length;
|
|
14
7
|
export class McpMountRegistry {
|
|
15
8
|
ctx;
|
|
16
9
|
pluginDataRoot;
|
|
17
|
-
namespace;
|
|
18
10
|
live = new Map();
|
|
19
11
|
names = new Map();
|
|
20
12
|
credentialRefs = new Set();
|
|
@@ -24,11 +16,9 @@ export class McpMountRegistry {
|
|
|
24
16
|
/** The active MCP mount backend ('builtin' bridge or host client compat mode). */
|
|
25
17
|
backendProvider = async () => 'builtin';
|
|
26
18
|
/** Serialize mount and unmount passes so a disable cannot race an in-flight spawn. */
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
retries
|
|
30
|
-
/** Attempt count per mount key; reset whenever a retry succeeds. */
|
|
31
|
-
attempts = new Map();
|
|
19
|
+
passes = new SerialPassQueue();
|
|
20
|
+
/** Delayed re-attempts for mounts that are not live yet. */
|
|
21
|
+
retries;
|
|
32
22
|
/**
|
|
33
23
|
* Mount keys flagged for an explicit rebuild (re-authorize): the next
|
|
34
24
|
* reconcile must tear down and remount them even when the resolved config
|
|
@@ -37,10 +27,13 @@ export class McpMountRegistry {
|
|
|
37
27
|
forcedRemounts = new Set();
|
|
38
28
|
/** Snapshot of the last reconciled suite set, replayed by retry passes. */
|
|
39
29
|
lastEnabled = [];
|
|
40
|
-
constructor(ctx, pluginDataRoot
|
|
30
|
+
constructor(ctx, pluginDataRoot) {
|
|
41
31
|
this.ctx = ctx;
|
|
42
32
|
this.pluginDataRoot = pluginDataRoot;
|
|
43
|
-
this.
|
|
33
|
+
this.retries = new RetryScheduler({
|
|
34
|
+
replay: () => this.reconcile(this.lastEnabled),
|
|
35
|
+
log: message => this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${message}`)
|
|
36
|
+
});
|
|
44
37
|
}
|
|
45
38
|
/** Install the per-suite overrides provider (suiteId -> overrides). */
|
|
46
39
|
setOverridesProvider(provider) {
|
|
@@ -71,6 +64,15 @@ export class McpMountRegistry {
|
|
|
71
64
|
forceRemount(suiteId, serverKey) {
|
|
72
65
|
this.forcedRemounts.add(mountKey(suiteId, serverKey));
|
|
73
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Flag every live mount for a rebuild on the next reconcile: the manual
|
|
69
|
+
* retry path. A bridge whose server died underneath it still matches its own
|
|
70
|
+
* config fingerprint, so nothing else would ever re-verify that mount.
|
|
71
|
+
*/
|
|
72
|
+
forceRemountAll() {
|
|
73
|
+
for (const key of this.live.keys())
|
|
74
|
+
this.forcedRemounts.add(key);
|
|
75
|
+
}
|
|
74
76
|
/** The mount key owning one derived serverName; undefined when not mounted here. */
|
|
75
77
|
serverOwner(serverName) {
|
|
76
78
|
const owner = this.names.get(serverName);
|
|
@@ -82,15 +84,13 @@ export class McpMountRegistry {
|
|
|
82
84
|
return { suiteId: owner.slice(0, separator), serverKey: owner.slice(separator + 1) };
|
|
83
85
|
}
|
|
84
86
|
/** Queue one reconciliation behind any in-flight mount/unmount pass. */
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.reconcileQueue = run.then(() => undefined, () => undefined);
|
|
88
|
-
return run;
|
|
87
|
+
reconcile(enabledSuites) {
|
|
88
|
+
return this.passes.run(() => this.reconcileNow(enabledSuites));
|
|
89
89
|
}
|
|
90
90
|
/** Mount/unmount MCP servers to match the enabled suites exactly. */
|
|
91
91
|
async reconcileNow(enabledSuites) {
|
|
92
92
|
this.lastEnabled = [...enabledSuites];
|
|
93
|
-
const active = enabledSuites.filter(suite => suite.activeSurfaces
|
|
93
|
+
const active = enabledSuites.filter(suite => suite.activeSurfaces.mcp !== false);
|
|
94
94
|
const overrides = await this.overridesProvider();
|
|
95
95
|
const resolver = mcpCredentialResolver(this.ctx);
|
|
96
96
|
const wanted = new Map();
|
|
@@ -98,15 +98,11 @@ export class McpMountRegistry {
|
|
|
98
98
|
this.credentialRefs.clear();
|
|
99
99
|
for (const suite of active) {
|
|
100
100
|
const suiteOverrides = overrides.get(qualifiedSuiteId(suite.sourceId, suite.id));
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
for (const ref of credentialRefsInServer(effective))
|
|
107
|
-
this.credentialRefs.add(ref);
|
|
108
|
-
}
|
|
109
|
-
const { mounts, failures } = await toMcpMounts(suite, this.pluginDataRoot, suiteOverrides, resolver);
|
|
101
|
+
const { mounts, failures, credentialRefs } = await toMcpMounts(suite, this.pluginDataRoot, suiteOverrides, resolver);
|
|
102
|
+
// The references ride the same effective view the mounts are built from,
|
|
103
|
+
// so the credentials panel cannot show one the mounts never use.
|
|
104
|
+
for (const ref of credentialRefs)
|
|
105
|
+
this.credentialRefs.add(ref);
|
|
110
106
|
for (const failure of failures) {
|
|
111
107
|
diagnostics.push({
|
|
112
108
|
suiteId: qualifiedSuiteId(suite.sourceId, suite.id),
|
|
@@ -117,9 +113,10 @@ export class McpMountRegistry {
|
|
|
117
113
|
});
|
|
118
114
|
}
|
|
119
115
|
for (const mount of mounts) {
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
116
|
+
// One derived serverName per suite/server, whatever the dimension: the
|
|
117
|
+
// host keeps an agent's registrations in that agent's own scope and lets
|
|
118
|
+
// them shadow globals, so suffixing a per-session id would only fork the
|
|
119
|
+
// server's identity — including its stored OAuth grant — per session.
|
|
123
120
|
wanted.set(mountKey(mount.suiteId, mount.serverKey), { suite, serverKey: mount.serverKey, request: mount });
|
|
124
121
|
}
|
|
125
122
|
}
|
|
@@ -164,67 +161,30 @@ export class McpMountRegistry {
|
|
|
164
161
|
// next full reconcile re-checks them anyway, so the self-heal path
|
|
165
162
|
// is intact.
|
|
166
163
|
const informational = failure.code === 'foreign-mount' || failure.code === 'duplicate-mount';
|
|
167
|
-
this.
|
|
164
|
+
this.retries.schedule({ key, label: `${entry.suite.id}/${entry.serverKey}`, ...(informational ? {} : { reason: failure.reason }) });
|
|
168
165
|
}
|
|
169
166
|
}
|
|
170
167
|
return diagnostics.filter(diagnostic => diagnostic.reason !== 'unmounted');
|
|
171
168
|
}
|
|
172
|
-
/**
|
|
173
|
-
* Schedule a delayed re-attempt for a mount that still is not live.
|
|
174
|
-
*
|
|
175
|
-
* Mounts are retried because a remote endpoint or a credential may become
|
|
176
|
-
* available slightly after the suite is enabled. The schedule is bounded, and
|
|
177
|
-
* a server that keeps failing simply stops retrying until the next reconcile.
|
|
178
|
-
*/
|
|
179
|
-
scheduleRetry(key, suiteId, serverKey, reason) {
|
|
180
|
-
const pending = this.retries.get(key);
|
|
181
|
-
if (pending !== undefined)
|
|
182
|
-
clearTimeout(pending);
|
|
183
|
-
this.retries.delete(key);
|
|
184
|
-
if (reason === undefined) {
|
|
185
|
-
this.attempts.delete(key);
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
const attempt = (this.attempts.get(key) ?? 0) + 1;
|
|
189
|
-
this.attempts.set(key, attempt);
|
|
190
|
-
if (attempt > MAX_RETRY_ATTEMPTS) {
|
|
191
|
-
this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${suiteId}/${serverKey}: giving up after ${MAX_RETRY_ATTEMPTS} attempts — ${reason}`);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
const delay = RETRY_SCHEDULE_MS[attempt - 1] ?? RETRY_SCHEDULE_MS[RETRY_SCHEDULE_MS.length - 1];
|
|
195
|
-
const timer = setTimeout(() => {
|
|
196
|
-
this.retries.delete(key);
|
|
197
|
-
// Re-run against the last known suite set: a retry must not resurrect a
|
|
198
|
-
// server that has since been disabled or uninstalled.
|
|
199
|
-
void this.reconcile(this.lastEnabled).catch(() => { });
|
|
200
|
-
}, delay);
|
|
201
|
-
timer.unref?.();
|
|
202
|
-
this.retries.set(key, timer);
|
|
203
|
-
}
|
|
204
169
|
/** Dispose every live mount after queued reconciliation passes settle. */
|
|
205
170
|
async disposeAll() {
|
|
206
|
-
|
|
171
|
+
await this.passes.run(async () => {
|
|
207
172
|
for (const [key, live] of [...this.live]) {
|
|
208
173
|
await this.unmount(key, live);
|
|
209
174
|
}
|
|
210
175
|
});
|
|
211
|
-
this.reconcileQueue = run.then(() => undefined, () => undefined);
|
|
212
|
-
await run;
|
|
213
|
-
for (const timer of this.retries.values())
|
|
214
|
-
clearTimeout(timer);
|
|
215
176
|
this.retries.clear();
|
|
216
|
-
this.attempts.clear();
|
|
217
177
|
this.credentialRefs.clear();
|
|
218
178
|
}
|
|
219
179
|
/** Mount one precomputed request (source config merged with overrides). */
|
|
220
180
|
async mountWith(request) {
|
|
221
|
-
const owner = this.
|
|
181
|
+
const owner = this.serverOwner(request.config.serverName);
|
|
222
182
|
if (owner !== undefined) {
|
|
223
183
|
// Two sources shipping the same suite/server pair derive one serverName:
|
|
224
184
|
// the model only needs one copy, so later arrivals skip with an
|
|
225
185
|
// informational diagnostic instead of double-registering.
|
|
226
186
|
return {
|
|
227
|
-
reason: `server "${request.config.serverName}" is already mounted from ${owner} — this suite's copy is redundant and was skipped`,
|
|
187
|
+
reason: `server "${request.config.serverName}" is already mounted from ${owner.suiteId} — this suite's copy is redundant and was skipped`,
|
|
228
188
|
code: 'duplicate-mount'
|
|
229
189
|
};
|
|
230
190
|
}
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* resolved by the Host credentials service (or launch-environment fallback)
|
|
12
12
|
* so keys never persist in plain text.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
16
17
|
import { isSensitiveKey } from './mcp-redaction.js';
|
|
17
18
|
/** The overrides directory under the plugin data root. */
|
|
18
19
|
export function overridesDir(dataRoot) {
|
|
@@ -35,11 +36,10 @@ export async function loadSuiteOverrides(dataRoot, suiteId) {
|
|
|
35
36
|
return {};
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
|
-
/** Persist one suite's overrides
|
|
39
|
+
/** Persist one suite's overrides through the harness atomic write. */
|
|
39
40
|
export async function saveSuiteOverrides(dataRoot, suiteId, overrides) {
|
|
40
41
|
const path = suiteOverridePath(dataRoot, suiteId);
|
|
41
|
-
await
|
|
42
|
-
await writeFile(path, `${JSON.stringify(overrides, null, 2)}\n`, { mode: 0o600 });
|
|
42
|
+
await writeFileAtomic(path, `${JSON.stringify(overrides, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
|
|
43
43
|
}
|
|
44
44
|
/** Keep only recognized fields with correct shapes; drop everything else. */
|
|
45
45
|
export function sanitizeOverrides(raw) {
|
|
@@ -13,7 +13,8 @@ const SENSITIVE_KEY = /(authorization|auth|bearer|token|secret|password|passwd|c
|
|
|
13
13
|
* walk descends into it and the per-value checks apply instead.
|
|
14
14
|
*/
|
|
15
15
|
const STRUCTURE_KEY = /^auth$/i;
|
|
16
|
-
|
|
16
|
+
/** A `${NAME}` credential reference. Deliberately not global: it is only ever probed with `test`. */
|
|
17
|
+
const PLACEHOLDER = /\$\{[^}]+\}/;
|
|
17
18
|
export function redactMcpConfig(value) {
|
|
18
19
|
return redactValue(value);
|
|
19
20
|
}
|
|
@@ -76,7 +77,7 @@ function redactValue(value, key = '') {
|
|
|
76
77
|
}
|
|
77
78
|
if (isSensitiveKey(key)) {
|
|
78
79
|
if (typeof value === 'string' && PLACEHOLDER.test(value))
|
|
79
|
-
return value
|
|
80
|
+
return value;
|
|
80
81
|
return '[redacted]';
|
|
81
82
|
}
|
|
82
83
|
if (key === 'url' && typeof value === 'string')
|