dsh-agent-plugins-market 0.7.1 → 0.7.2

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.
Files changed (61) hide show
  1. package/README.md +29 -24
  2. package/README.zh.md +29 -24
  3. package/client/client.js +1172 -159
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +14 -2
  6. package/lib/application/install-store.js +1 -1
  7. package/lib/application/mcp-service.js +185 -30
  8. package/lib/catalog/archive.js +15 -0
  9. package/lib/catalog/manifests.js +124 -13
  10. package/lib/catalog/scan-resolvers.js +117 -12
  11. package/lib/catalog/surfaces.js +73 -27
  12. package/lib/catalog/validate.js +205 -54
  13. package/lib/contracts/market.js +2 -0
  14. package/lib/index.js +6 -2
  15. package/lib/model/layouts.js +34 -0
  16. package/lib/model/preset-source.js +8 -0
  17. package/lib/routes.js +31 -2
  18. package/lib/runtime/agent-role-router.js +176 -51
  19. package/lib/runtime/host-locale.js +6 -12
  20. package/lib/runtime/mcp-client/connection.js +3 -2
  21. package/lib/runtime/mcp-client/oauth.js +82 -14
  22. package/lib/runtime/mcp-client/tools.js +2 -1
  23. package/lib/runtime/mcp-config.js +178 -34
  24. package/lib/runtime/mcp-direct-config.js +49 -6
  25. package/lib/runtime/mcp-mounts.js +15 -3
  26. package/lib/runtime/mcp-overrides.js +131 -1
  27. package/lib/runtime/mcp-redaction.js +8 -0
  28. package/lib/runtime/mcp-status.js +21 -5
  29. package/lib/runtime/server-config.js +49 -8
  30. package/lib/runtime/subagent-catalog.js +62 -22
  31. package/lib/runtime/tool-registry-observer.js +8 -4
  32. package/lib/types/application/catalog.d.ts +10 -1
  33. package/lib/types/application/install-store.d.ts +1 -1
  34. package/lib/types/application/mcp-service.d.ts +32 -3
  35. package/lib/types/application/queries.d.ts +6 -1
  36. package/lib/types/catalog/manifests.d.ts +6 -2
  37. package/lib/types/catalog/surfaces.d.ts +14 -4
  38. package/lib/types/catalog/validate.d.ts +72 -11
  39. package/lib/types/contracts/market.d.ts +67 -0
  40. package/lib/types/contracts/mcp-status.d.ts +11 -0
  41. package/lib/types/model/layouts.d.ts +20 -0
  42. package/lib/types/model/preset-source.d.ts +18 -0
  43. package/lib/types/model/types.d.ts +37 -1
  44. package/lib/types/runtime/agent-role-router.d.ts +90 -16
  45. package/lib/types/runtime/host-locale.d.ts +1 -1
  46. package/lib/types/runtime/mcp-client/oauth.d.ts +4 -0
  47. package/lib/types/runtime/mcp-config.d.ts +75 -13
  48. package/lib/types/runtime/mcp-direct-config.d.ts +22 -0
  49. package/lib/types/runtime/mcp-overrides.d.ts +44 -0
  50. package/lib/types/runtime/mcp-redaction.d.ts +6 -0
  51. package/lib/types/runtime/server-config.d.ts +26 -1
  52. package/lib/types/runtime/subagent-catalog.d.ts +14 -2
  53. package/lib/types/runtime/tool-registry-observer.d.ts +5 -3
  54. package/package.json +8 -8
  55. package/schemas/1.0.0/mcp.schema.json +0 -22
  56. package/schemas/1.1.0/mcp.schema.json +120 -0
  57. package/schemas/1.1.0/plugin.schema.json +65 -0
  58. package/schemas/README.md +25 -19
  59. package/schemas/agent-plugins/spec.md +23 -9
  60. package/schemas/com.deepseek.harness/namespace.schema.json +42 -0
  61. package/schemas/com.deepseek.harness/spec.md +52 -0
@@ -4,14 +4,23 @@
4
4
  * The portable format is translated, not executed directly: stdio commands
5
5
  * resolve against the suite root (spec §7.2.1), `${PLUGIN_ROOT}` /
6
6
  * `${PLUGIN_DATA}` expand against the suite root and its data directory, and
7
- * every `${NAME}` — including ones inside a streamable-http `url` — expands
8
- * through the optional DSH credentials seam at mount time. Legacy HTTP+SSE
9
- * servers are supported by the market's own bridge (the host client had no
10
- * such transport); unrecognized shapes are still skipped with a per-server
11
- * reason.
7
+ * the child environment carries both variables (§9.1) with the data directory
8
+ * created before launch. Legacy HTTP+SSE servers are supported by the
9
+ * market's own bridge (the host client had no such transport); unrecognized
10
+ * shapes are still skipped with a per-server reason.
11
+ *
12
+ * Placeholder discipline follows the package's origin. A suite mounted from
13
+ * the portable v1 format (`portable: true`) is package data: §9.2 forbids any
14
+ * expansion beyond the two built-in variables, and per-server client policy
15
+ * (OAuth, tool lists, timeouts) comes from the `com.deepseek.harness`
16
+ * namespace instead of the `mcp.json` body. Dialect-native files and
17
+ * user-owned config keep the credential seam: every `${NAME}` — including
18
+ * ones inside a streamable-http `url` — expands through the optional DSH
19
+ * credentials resolver at mount time, and missing references fail closed per
20
+ * server instead of becoming empty strings.
12
21
  */
13
22
  import { createHash } from 'node:crypto';
14
- import { DEFAULT_TOOL_CALL_TIMEOUT_MS } from './mcp-client/config.js';
23
+ import { DEFAULT_STARTUP_TIMEOUT_MS, DEFAULT_TOOL_CALL_TIMEOUT_MS } from './mcp-client/config.js';
15
24
  import { resolveCwd } from '../catalog/validate.js';
16
25
  import { qualifiedSuiteId, suiteDataDir } from '../catalog/paths.js';
17
26
  import { applyOverride } from './mcp-overrides.js';
@@ -21,26 +30,10 @@ const SERVER_NAME_MAX = 32;
21
30
  const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
22
31
  const BUILTIN_PLACEHOLDERS = new Set([...PLUGIN_ROOT_VARIABLES, ...PLUGIN_DATA_VARIABLES]);
23
32
  /**
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
- }
39
- /**
40
- * Build one mount request per supported mcp.json server, resolving every
41
- * `${NAME}` through the credential resolver before a child process or HTTP
42
- * request is created. Missing references fail closed per server instead of
43
- * becoming empty strings.
33
+ * Build one mount request per supported mcp.json server, expanding built-in
34
+ * variables (and, for user-owned data, `${NAME}` credential references)
35
+ * before a child process or HTTP request is created. Missing references fail
36
+ * closed per server instead of becoming empty strings.
44
37
  *
45
38
  * @param overrides user-owned per-server overrides (url/headers/env/args
46
39
  * replacement plus enable/disable); applied before mount. Disabled servers
@@ -74,6 +67,141 @@ export async function toMcpMounts(suite, pluginDataRoot, overrides = {}, resolve
74
67
  }
75
68
  return { mounts, failures, credentialRefs: [...credentialRefs] };
76
69
  }
70
+ /**
71
+ * Whether a suite's MCP declarations are package data under the portable
72
+ * format. Installed v1 suites are; the user's own `~/.agents/mcp.json`,
73
+ * project-native config, and every dialect-native file are user-owned data
74
+ * where `${NAME}` credential references stay resolvable.
75
+ */
76
+ export function isPortableMcp(suite) {
77
+ return suite.dimension !== 'user' && suite.manifest.layout === 'agent-plugin-v1';
78
+ }
79
+ /**
80
+ * The `com.deepseek.harness` policy declared for one portable server.
81
+ * Server keys in the namespace match the server's own name in `mcp.json`;
82
+ * `serverKey` is that name at discovery time.
83
+ */
84
+ export function namespaceMcpPolicy(suite, serverKey) {
85
+ return suite.manifest.harness?.mcpServers?.[serverKey];
86
+ }
87
+ /**
88
+ * The effective view of one suite's `mcp.json` servers: the user's per-server
89
+ * overrides applied, every row flagged with whether it is used at all, and each
90
+ * row's credential references read off the effective shape. Mounting, status
91
+ * and detail rendering read this one projection, so a server cannot be listed
92
+ * as enabled while mounting something else.
93
+ *
94
+ * Portable v1 suites (`isPortableMcp`) carry package data: their credential
95
+ * reference list is empty by definition (§9.2 leaves unrecognized
96
+ * placeholder-like text literal), and client policy comes from the
97
+ * `com.deepseek.harness` namespace instead. Overrides applied by the user are
98
+ * user-owned data, so a reference a user typed into an override still
99
+ * resolves.
100
+ */
101
+ export function effectiveMcpServers(suite, overrides = {}) {
102
+ const rows = [];
103
+ const portable = isPortableMcp(suite);
104
+ for (const [serverKey, source] of Object.entries(suite.mcp?.servers ?? {})) {
105
+ const override = overrides[serverKey];
106
+ const declared = namespaceMcpPolicy(suite, serverKey);
107
+ const policy = resolveMcpPolicy(declaredMcpPolicy(source, declared), override);
108
+ const connection = applyNamespaceAuth(applyOverride(source, override), declared);
109
+ const effective = applyMcpPolicy(connection, policy);
110
+ const credentialRefs = portable ? credentialRefsInServer(overrideValues(override)) : credentialRefsInServer(effective);
111
+ rows.push({ serverKey, server: effective, override, enabled: override?.enabled !== false, credentialRefs, policy });
112
+ }
113
+ return rows;
114
+ }
115
+ /** Credential references a user typed into their own override values. */
116
+ function overrideValues(override) {
117
+ if (override === undefined)
118
+ return { type: 'stdio', command: '' };
119
+ return {
120
+ type: 'stdio',
121
+ command: '',
122
+ ...(override.args !== undefined ? { args: override.args } : {}),
123
+ ...(override.env !== undefined ? { env: override.env } : {}),
124
+ ...(override.headers !== undefined ? { headers: override.headers } : {}),
125
+ ...(override.url !== undefined ? { url: override.url, type: 'streamable-http' } : {})
126
+ };
127
+ }
128
+ /**
129
+ * The suite's declared per-server policy. The `com.deepseek.harness`
130
+ * namespace seat carries the policy a portable `mcp.json` has no room for and
131
+ * wins over an inline declaration; dialect-native files declare it inline.
132
+ */
133
+ export function declaredMcpPolicy(server, policy) {
134
+ const enabledTools = policy?.enabledTools ?? server.enabledTools;
135
+ const disabledTools = policy?.disabledTools ?? server.disabledTools;
136
+ const startupTimeoutMs = policy?.startupTimeoutMs ?? server.startupTimeoutMs;
137
+ const toolCallTimeoutMs = policy?.toolCallTimeoutMs ?? server.toolCallTimeoutMs;
138
+ return {
139
+ ...(enabledTools === undefined ? {} : { enabledTools }),
140
+ ...(disabledTools === undefined ? {} : { disabledTools }),
141
+ ...(startupTimeoutMs === undefined ? {} : { startupTimeoutMs }),
142
+ ...(toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs })
143
+ };
144
+ }
145
+ /**
146
+ * Bring the suite's declaration and the user's override together.
147
+ *
148
+ * A timeout is a plain override: the user's value wins, a declaration fills in
149
+ * when the user set none, and the built-in default stands behind both. Tool
150
+ * filtering only tightens: the effective deny list is the suite's entries
151
+ * unioned with the user's, and the suite's allow-list stands as declared, so a
152
+ * user cannot open a tool the suite left out.
153
+ */
154
+ export function resolveMcpPolicy(declared, override) {
155
+ const enabledTools = dedupe(declared.enabledTools);
156
+ const suiteDisabledTools = dedupe(declared.disabledTools);
157
+ const disabledTools = dedupe([...(declared.disabledTools ?? []), ...(override?.disabledTools ?? [])]);
158
+ return {
159
+ ...(enabledTools === undefined ? {} : { enabledTools }),
160
+ ...(suiteDisabledTools === undefined ? {} : { suiteDisabledTools }),
161
+ ...(disabledTools === undefined ? {} : { disabledTools }),
162
+ toolCallTimeout: timeoutResolution(override?.toolCallTimeoutMs, declared.toolCallTimeoutMs, DEFAULT_TOOL_CALL_TIMEOUT_MS),
163
+ startupTimeout: timeoutResolution(override?.startupTimeoutMs, declared.startupTimeoutMs, DEFAULT_STARTUP_TIMEOUT_MS)
164
+ };
165
+ }
166
+ function timeoutResolution(user, suite, fallback) {
167
+ const effective = user ?? suite ?? fallback;
168
+ return {
169
+ user: user ?? null,
170
+ suite: suite ?? null,
171
+ effective,
172
+ source: user !== undefined ? 'user' : suite !== undefined ? 'suite' : 'default'
173
+ };
174
+ }
175
+ /** Apply one resolved policy onto a server, in the shape the bridge reads. */
176
+ function applyMcpPolicy(server, policy) {
177
+ return {
178
+ ...server,
179
+ toolCallTimeoutMs: policy.toolCallTimeout.effective,
180
+ // An undeclared startup timeout stays absent: the built-in bridge applies
181
+ // its own default at connect time, and host compatibility mode, which
182
+ // cannot enforce one, keeps accepting the server.
183
+ ...(policy.startupTimeout.source === 'default' ? {} : { startupTimeoutMs: policy.startupTimeout.effective }),
184
+ ...(policy.enabledTools === undefined ? {} : { enabledTools: policy.enabledTools }),
185
+ ...(policy.disabledTools === undefined ? {} : { disabledTools: policy.disabledTools })
186
+ };
187
+ }
188
+ /**
189
+ * Apply the namespace policy's OAuth seat onto one server. A remote server
190
+ * keeps its OAuth opt-out from the namespace; a policy on a portable server
191
+ * also supplies the credential seam the portable file may not declare.
192
+ */
193
+ function applyNamespaceAuth(server, policy) {
194
+ if (policy?.auth === undefined || server.type === 'stdio')
195
+ return server;
196
+ return { ...server, auth: { enabled: policy.auth.enabled, ...(policy.auth.scope === undefined ? {} : { scope: policy.auth.scope }) } };
197
+ }
198
+ /** Deduplicate a tool-name list, preserving declaration order; an empty result is absent. */
199
+ function dedupe(values) {
200
+ if (values === undefined)
201
+ return undefined;
202
+ const names = [...new Set(values.filter(name => name !== ''))];
203
+ return names.length > 0 ? names : undefined;
204
+ }
77
205
  /** Find external credential references used by one MCP server definition. */
78
206
  export function credentialRefsInServer(server) {
79
207
  const values = [];
@@ -96,9 +224,11 @@ export function credentialRefsInServer(server) {
96
224
  return [...refs].sort();
97
225
  }
98
226
  async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolver) {
227
+ const portable = isPortableMcp(suite);
228
+ const dataDir = suiteDataDir(pluginDataRoot, suite.sourceId, suite.id);
99
229
  if (server.type === 'sse') {
100
230
  // The market bridge supports the legacy HTTP+SSE transport natively.
101
- const expand = expander(suite, suiteDataDir(pluginDataRoot, suite.sourceId, suite.id), resolver);
231
+ const expand = expander(suite, dataDir, resolver, portable);
102
232
  const url = await expand.one(server.url);
103
233
  const headers = await expand.map(server.headers ?? {});
104
234
  const missing = unique([...url.missing, ...headers.missing]);
@@ -115,7 +245,7 @@ async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolve
115
245
  };
116
246
  return { request: { suiteId: qualifiedSuiteId(suite.sourceId, suite.id), serverKey, config: sseConfig } };
117
247
  }
118
- const expand = expander(suite, suiteDataDir(pluginDataRoot, suite.sourceId, suite.id), resolver);
248
+ const expand = expander(suite, dataDir, resolver, portable);
119
249
  const serverName = deriveServerName(suite.id, serverKey);
120
250
  if (server.type === 'stdio') {
121
251
  const args = await expand.all(server.args ?? []);
@@ -133,8 +263,10 @@ async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolve
133
263
  serverName,
134
264
  command: server.command.startsWith('./') ? joinInside(suite.root, server.command.slice(2)) : server.command,
135
265
  args: args.values,
136
- env: env.values,
137
- cwd: resolveCwd(cwd.value, suite.root, suiteDataDir(pluginDataRoot, suite.sourceId, suite.id)),
266
+ // §9.1: configured env overlays the base, then the client sets
267
+ // PLUGIN_ROOT and PLUGIN_DATA, replacing same-name entries.
268
+ env: { ...env.values, PLUGIN_ROOT: suite.root, PLUGIN_DATA: dataDir },
269
+ cwd: resolveCwd(cwd.value, suite.root, dataDir),
138
270
  ...bridgePolicy(server),
139
271
  failOnStartupError: true
140
272
  }
@@ -185,8 +317,16 @@ function bridgePolicy(server) {
185
317
  ...(server.disabledTools === undefined ? {} : { disabledTools: server.disabledTools })
186
318
  };
187
319
  }
188
- /** Per-mount expansion context; credential lookups are memoized per call. */
189
- function expander(suite, pluginData, resolver) {
320
+ /**
321
+ * Per-mount expansion context.
322
+ *
323
+ * Portable package data (`portable: true`) honors §9.2: only the two built-in
324
+ * variables expand, single pass, and unrecognized placeholder-like text stays
325
+ * literal — the credential resolver is never consulted. User-owned data
326
+ * keeps the `${NAME}` seam, with `${NAME:-default}` fallbacks and missing
327
+ * references reported.
328
+ */
329
+ function expander(suite, pluginData, resolver, portable) {
190
330
  // Promise-valued cache: concurrent expansions of the same reference share
191
331
  // one in-flight lookup, so a config using a token twice resolves it once.
192
332
  const inflight = new Map();
@@ -214,15 +354,19 @@ function expander(suite, pluginData, resolver) {
214
354
  replacement = suite.root;
215
355
  else if (PLUGIN_DATA_VARIABLES.has(name))
216
356
  replacement = pluginData;
217
- else
357
+ else if (!portable)
218
358
  replacement = (await lookup(name))?.value;
219
359
  if (replacement === undefined || replacement === '') {
220
360
  if (fallback !== undefined && fallback !== '')
221
361
  replacement = fallback;
222
- else {
362
+ else if (!portable) {
223
363
  missing.push(name);
224
364
  replacement = '';
225
365
  }
366
+ else {
367
+ // §9.2: unrecognized placeholder-like text stays literal.
368
+ replacement = match[0];
369
+ }
226
370
  }
227
371
  output += replacement;
228
372
  cursor = index + match[0].length;
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
4
- import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalog/validate.js';
4
+ import { MCP_SCHEMA_ID, validateMcpJson } from '../catalog/validate.js';
5
5
  import { effectiveSurfaces } from '../model/types.js';
6
6
  export const USER_MCP_SOURCE = '@user-mcp';
7
7
  export const USER_MCP_SUITE = 'user-mcp';
@@ -44,13 +44,16 @@ export async function loadUserMcpSuite(agentsRoot) {
44
44
  errors
45
45
  };
46
46
  }
47
+ /**
48
+ * The user's own declaration file is local data, not a distributable package:
49
+ * keys this client does not know ride along untouched rather than failing the
50
+ * whole file, and the closed-set rule the package schema applies to suites
51
+ * does not apply here.
52
+ */
47
53
  async function validateUserMcp(agentsRoot, raw) {
48
- const errors = await validateAgainstSchema(MCP_SCHEMA_ID, raw);
49
- if (errors.length > 0)
50
- throw new Error(`invalid MCP configuration: ${errors.join('; ')}`);
51
- const result = await validateMcpJson(agentsRoot, raw);
54
+ const result = await validateMcpJson(agentsRoot, raw, { packageRules: false });
52
55
  if (result.config === undefined || result.errors.length > 0)
53
- throw new Error(result.errors.join('; '));
56
+ throw new Error(`invalid MCP configuration: ${result.errors.join('; ')}`);
54
57
  return result.config;
55
58
  }
56
59
  /** Add one service atomically; reject collisions and malformed config before writing. */
@@ -71,3 +74,43 @@ export async function addUserMcpServer(agentsRoot, name, server) {
71
74
  export function userMcpPath(agentsRoot) {
72
75
  return join(agentsRoot, 'mcp.json');
73
76
  }
77
+ const SERVER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
78
+ /**
79
+ * Import several pasted servers in one write.
80
+ *
81
+ * Every entry is judged on its own so one bad definition cannot take the rest
82
+ * of the paste with it, and an existing name is kept unless the caller asked to
83
+ * overwrite. The file is written once, after every entry has been checked, so a
84
+ * rejected entry never lands half-written beside the accepted ones.
85
+ */
86
+ export async function importUserMcpServers(agentsRoot, entries, overwrite = false) {
87
+ const suite = await loadUserMcpSuite(agentsRoot);
88
+ if (suite.errors.length > 0)
89
+ throw new Error(suite.errors.join('; '));
90
+ const servers = { ...suite.mcp.servers };
91
+ const imported = [];
92
+ const skipped = [];
93
+ for (const entry of entries) {
94
+ if (!SERVER_NAME_PATTERN.test(entry.name)) {
95
+ skipped.push({ name: entry.name, reason: 'invalid name' });
96
+ continue;
97
+ }
98
+ if (Object.hasOwn(servers, entry.name) && !overwrite) {
99
+ skipped.push({ name: entry.name, reason: 'already exists' });
100
+ continue;
101
+ }
102
+ try {
103
+ await validateUserMcp(agentsRoot, { $schema: MCP_SCHEMA_ID, mcpServers: { ...servers, [entry.name]: entry.server } });
104
+ }
105
+ catch (reason) {
106
+ skipped.push({ name: entry.name, reason: reason instanceof Error ? reason.message : String(reason) });
107
+ continue;
108
+ }
109
+ servers[entry.name] = entry.server;
110
+ imported.push(entry.name);
111
+ }
112
+ if (imported.length > 0) {
113
+ await writeFileAtomic(userMcpPath(agentsRoot), `${JSON.stringify({ $schema: MCP_SCHEMA_ID, mcpServers: servers }, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
114
+ }
115
+ return { imported, skipped };
116
+ }
@@ -1,9 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { mkdir } from 'node:fs/promises';
2
3
  import * as mcpBridge from './mcp-client/bridge.js';
3
4
  import { toMcpMounts } from './mcp-config.js';
4
5
  import { mcpCredentialResolver } from './mcp-credentials.js';
5
6
  import { SerialPassQueue, RetryScheduler } from './mount-lifecycle.js';
6
- import { qualifiedSuiteId } from '../catalog/paths.js';
7
+ import { qualifiedSuiteId, suiteDataDir } from '../catalog/paths.js';
8
+ import { redactErrorMessage } from './mcp-redaction.js';
7
9
  export class McpMountRegistry {
8
10
  ctx;
9
11
  pluginDataRoot;
@@ -178,6 +180,16 @@ export class McpMountRegistry {
178
180
  }
179
181
  /** Mount one precomputed request (source config merged with overrides). */
180
182
  async mountWith(request) {
183
+ // §9.1: the client-managed PLUGIN_DATA directory must exist and be
184
+ // writable before any plugin subprocess starts. Created recursively and
185
+ // idempotently on every mount so an uninstalled-then-reinstalled suite
186
+ // starts over with a fresh directory while an update keeps its contents.
187
+ if (request.config.transport === 'stdio') {
188
+ const separator = request.suiteId.indexOf('/');
189
+ const sourceId = separator === -1 ? request.suiteId : request.suiteId.slice(0, separator);
190
+ const suiteId = separator === -1 ? request.suiteId : request.suiteId.slice(separator + 1);
191
+ await mkdir(suiteDataDir(this.pluginDataRoot, sourceId, suiteId), { recursive: true });
192
+ }
181
193
  const owner = this.serverOwner(request.config.serverName);
182
194
  if (owner !== undefined) {
183
195
  // Two sources shipping the same suite/server pair derive one serverName:
@@ -251,7 +263,7 @@ export class McpMountRegistry {
251
263
  // Ignore teardown errors: the startup failure is the real signal.
252
264
  }
253
265
  }
254
- return { reason: `mount failed: ${error instanceof Error ? error.message : String(error)}`, code: 'mount-failed' };
266
+ return { reason: `mount failed: ${redactErrorMessage(error instanceof Error ? error.message : String(error))}`, code: 'mount-failed' };
255
267
  }
256
268
  this.live.set(mountKey(request.suiteId, request.serverKey), {
257
269
  suiteId: request.suiteId,
@@ -271,7 +283,7 @@ export class McpMountRegistry {
271
283
  return undefined;
272
284
  }
273
285
  catch (error) {
274
- const reason = `unmount failed: ${error instanceof Error ? error.message : String(error)}`;
286
+ const reason = `unmount failed: ${redactErrorMessage(error instanceof Error ? error.message : String(error))}`;
275
287
  this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${reason} (${live.suiteId}/${live.serverKey})`);
276
288
  return reason;
277
289
  }
@@ -15,6 +15,8 @@ import { readFile } from 'node:fs/promises';
15
15
  import { join } from 'node:path';
16
16
  import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
17
17
  import { isSensitiveKey } from './mcp-redaction.js';
18
+ /** The largest timeout a bridge config and the host timer can hold. */
19
+ export const MAX_TIMEOUT_MS = 2_147_483_647;
18
20
  /** The overrides directory under the plugin data root. */
19
21
  export function overridesDir(dataRoot) {
20
22
  return join(dataRoot, 'overrides');
@@ -69,11 +71,41 @@ export function sanitizeOverrides(raw) {
69
71
  if (Array.isArray(record['args']) && record['args'].every(entry => typeof entry === 'string')) {
70
72
  override.args = record['args'];
71
73
  }
74
+ const toolCallTimeoutMs = timeoutMs(record['toolCallTimeoutMs']);
75
+ if (toolCallTimeoutMs !== undefined)
76
+ override.toolCallTimeoutMs = toolCallTimeoutMs;
77
+ const startupTimeoutMs = timeoutMs(record['startupTimeoutMs']);
78
+ if (startupTimeoutMs !== undefined)
79
+ override.startupTimeoutMs = startupTimeoutMs;
80
+ const disabledTools = toolNames(record['disabledTools']);
81
+ if (disabledTools !== undefined)
82
+ override.disabledTools = disabledTools;
72
83
  if (Object.keys(override).length > 0)
73
84
  result[serverKey] = override;
74
85
  }
75
86
  return result;
76
87
  }
88
+ /**
89
+ * Keep a timeout that a millisecond count can hold. Anything else passes
90
+ * nothing through, so one malformed field cannot take the rest of the record
91
+ * with it.
92
+ */
93
+ function timeoutMs(raw) {
94
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0 || raw > MAX_TIMEOUT_MS)
95
+ return undefined;
96
+ return raw;
97
+ }
98
+ /**
99
+ * Keep a tool-name list for a deny list: non-empty names, deduplicated, with
100
+ * the empty result reading as "nothing denied" so the field never persists as
101
+ * an empty array.
102
+ */
103
+ function toolNames(raw) {
104
+ if (!Array.isArray(raw))
105
+ return undefined;
106
+ const names = [...new Set(raw.filter((entry) => typeof entry === 'string' && entry !== ''))];
107
+ return names.length > 0 ? names : undefined;
108
+ }
77
109
  /** Keep a well-formed auth block; anything else passes nothing through. */
78
110
  function sanitizeAuth(raw) {
79
111
  if (typeof raw !== 'object' || raw === null)
@@ -94,6 +126,69 @@ export function sanitizeOverridePatch(patch) {
94
126
  return undefined;
95
127
  return sanitizeOverrides({ server: patch })['server'];
96
128
  }
129
+ /**
130
+ * Validate one policy save as it arrives over the wire. An absent field leaves
131
+ * its stored value alone, `null` clears one back to inheritance, and a positive
132
+ * whole number of milliseconds within the timer range sets it; anything else
133
+ * rejects the save with a readable reason.
134
+ */
135
+ export function parseTimeoutPatch(raw) {
136
+ if (raw === undefined)
137
+ return {};
138
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
139
+ throw new Error('policy must be an object');
140
+ const record = raw;
141
+ return {
142
+ ...(record['toolCallTimeoutMs'] === undefined ? {} : { toolCallTimeoutMs: timeoutField(record['toolCallTimeoutMs'], 'tool call timeout') }),
143
+ ...(record['startupTimeoutMs'] === undefined ? {} : { startupTimeoutMs: timeoutField(record['startupTimeoutMs'], 'startup timeout') })
144
+ };
145
+ }
146
+ /**
147
+ * Validate a client-supplied policy patch. An absent field keeps its stored
148
+ * value, `null` clears it back to the suite's declaration or the default, and
149
+ * any other shape is rejected with the reason it failed.
150
+ */
151
+ export function parseMcpPolicyPatch(raw) {
152
+ if (raw === undefined)
153
+ return {};
154
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
155
+ throw new Error('policy must be an object');
156
+ const record = raw;
157
+ const patch = {};
158
+ if (record['toolCallTimeoutMs'] !== undefined)
159
+ patch.toolCallTimeoutMs = timeoutField(record['toolCallTimeoutMs'], 'tool call timeout');
160
+ if (record['startupTimeoutMs'] !== undefined)
161
+ patch.startupTimeoutMs = timeoutField(record['startupTimeoutMs'], 'startup timeout');
162
+ if (record['disabledTools'] !== undefined) {
163
+ if (record['disabledTools'] === null)
164
+ patch.disabledTools = null;
165
+ else {
166
+ const names = toolNames(record['disabledTools']);
167
+ if (names === undefined && record['disabledTools'].length > 0)
168
+ throw new Error('denied tools must be a list of tool names');
169
+ patch.disabledTools = names ?? [];
170
+ }
171
+ }
172
+ if (record['auth'] !== undefined) {
173
+ if (record['auth'] === null)
174
+ patch.auth = null;
175
+ else {
176
+ const auth = sanitizeAuth(record['auth']);
177
+ if (auth === undefined)
178
+ throw new Error('auth must be an object with a boolean "enabled"');
179
+ patch.auth = auth;
180
+ }
181
+ }
182
+ return patch;
183
+ }
184
+ function timeoutField(value, label) {
185
+ if (value === null)
186
+ return null;
187
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > MAX_TIMEOUT_MS) {
188
+ throw new Error(`${label} must be a positive whole number of milliseconds no greater than ${MAX_TIMEOUT_MS}`);
189
+ }
190
+ return value;
191
+ }
97
192
  /**
98
193
  * Merge one client patch into the override already on disk.
99
194
  *
@@ -113,7 +208,18 @@ export function mergeOverridePatch(existing, patch) {
113
208
  ...(headers === undefined ? {} : { headers }),
114
209
  ...(env === undefined ? {} : { env }),
115
210
  ...(patch.args !== undefined ? { args: patch.args } : existing.args === undefined ? {} : { args: existing.args }),
116
- ...(patch.auth !== undefined ? { auth: patch.auth } : existing.auth === undefined ? {} : { auth: existing.auth })
211
+ ...(patch.auth !== undefined ? { auth: patch.auth } : existing.auth === undefined ? {} : { auth: existing.auth }),
212
+ ...(patch.toolCallTimeoutMs !== undefined
213
+ ? { toolCallTimeoutMs: patch.toolCallTimeoutMs }
214
+ : existing.toolCallTimeoutMs === undefined
215
+ ? {}
216
+ : { toolCallTimeoutMs: existing.toolCallTimeoutMs }),
217
+ ...(patch.startupTimeoutMs !== undefined
218
+ ? { startupTimeoutMs: patch.startupTimeoutMs }
219
+ : existing.startupTimeoutMs === undefined
220
+ ? {}
221
+ : { startupTimeoutMs: existing.startupTimeoutMs }),
222
+ ...(patch.disabledTools !== undefined ? { disabledTools: patch.disabledTools } : existing.disabledTools === undefined ? {} : { disabledTools: existing.disabledTools })
117
223
  };
118
224
  }
119
225
  function mergeStringMap(existing, patch) {
@@ -156,22 +262,46 @@ function stringMap(value) {
156
262
  export function applyOverride(server, override) {
157
263
  if (override === undefined)
158
264
  return server;
265
+ const policy = policyFields(override);
159
266
  if (override.config !== undefined)
160
267
  server = override.config;
161
268
  if (server.type === 'stdio') {
162
269
  return {
163
270
  ...server,
271
+ ...policy,
164
272
  ...(override.args !== undefined ? { args: override.args } : {}),
165
273
  ...(override.env !== undefined ? { env: override.env } : {})
166
274
  };
167
275
  }
168
276
  return {
169
277
  ...server,
278
+ ...policy,
170
279
  ...(override.url !== undefined ? { url: override.url } : {}),
171
280
  ...(override.headers !== undefined ? { headers: override.headers } : {}),
172
281
  ...(override.auth !== undefined ? { auth: override.auth } : {})
173
282
  };
174
283
  }
284
+ /** The user-owned policy fields of one override, in server shape. */
285
+ function policyFields(override) {
286
+ return {
287
+ ...(override.toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs: override.toolCallTimeoutMs }),
288
+ ...(override.startupTimeoutMs === undefined ? {} : { startupTimeoutMs: override.startupTimeoutMs }),
289
+ ...(override.disabledTools === undefined ? {} : { disabledTools: override.disabledTools })
290
+ };
291
+ }
292
+ /**
293
+ * One server with its client policy fields removed: the shape the portable
294
+ * `mcp.json` schema accepts. The service editor renders and saves this shape,
295
+ * so a timeout never reaches a document that has no seat for it.
296
+ */
297
+ export function withoutPolicyFields(server) {
298
+ const portable = { ...server };
299
+ delete portable['enabledTools'];
300
+ delete portable['disabledTools'];
301
+ delete portable['startupTimeoutMs'];
302
+ delete portable['toolCallTimeoutMs'];
303
+ return portable;
304
+ }
175
305
  function sanitizeFileName(raw) {
176
306
  return raw.replace(/[^A-Za-z0-9_-]+/g, '_');
177
307
  }
@@ -45,6 +45,14 @@ export function redactUrl(raw) {
45
45
  .join('&');
46
46
  return safeQuery === '' ? base : `${base}?${safeQuery}`;
47
47
  }
48
+ /**
49
+ * Redact every URL embedded in an error message before the message reaches a
50
+ * log line or a diagnostic: connection failures echo the server's endpoint,
51
+ * and endpoints carry credential-bearing queries.
52
+ */
53
+ export function redactErrorMessage(raw) {
54
+ return raw.replace(/https?:\/\/[^\s"'<>]+/gi, value => redactUrl(value));
55
+ }
48
56
  function splitOnce(value, separator) {
49
57
  const index = value.indexOf(separator);
50
58
  if (index === -1)