dsh-agent-plugins-market 0.7.0 → 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 (114) hide show
  1. package/README.md +31 -26
  2. package/README.zh.md +31 -26
  3. package/client/client.js +29164 -1136
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +8 -1
  6. package/lib/application/catalog.js +27 -2
  7. package/lib/application/details.js +13 -2
  8. package/lib/application/install-store.js +1 -1
  9. package/lib/application/mcp-service.js +191 -19
  10. package/lib/application/panel-resources.js +16 -4
  11. package/lib/application/project-agent-roles.js +7 -1
  12. package/lib/catalog/archive.js +16 -1
  13. package/lib/catalog/manifests.js +124 -13
  14. package/lib/catalog/paths.js +8 -0
  15. package/lib/catalog/plugin-variables.js +38 -0
  16. package/lib/catalog/project-hooks.js +14 -1
  17. package/lib/catalog/scan-resolvers.js +117 -12
  18. package/lib/catalog/skills-parse.js +47 -0
  19. package/lib/catalog/source-catalog.js +8 -3
  20. package/lib/catalog/suite-components.js +2 -2
  21. package/lib/catalog/surfaces.js +73 -27
  22. package/lib/catalog/validate.js +205 -54
  23. package/lib/contracts/market.js +3 -0
  24. package/lib/contracts/settings.js +63 -0
  25. package/lib/index.js +32 -13
  26. package/lib/model/layouts.js +44 -1
  27. package/lib/model/preset-source.js +8 -0
  28. package/lib/routes.js +43 -2
  29. package/lib/runtime/agent-role-router.js +203 -56
  30. package/lib/runtime/commands-mounts.js +50 -9
  31. package/lib/runtime/deadline.js +32 -6
  32. package/lib/runtime/dynamic-context.js +140 -0
  33. package/lib/runtime/feedback-tool.js +7 -3
  34. package/lib/runtime/host-locale.js +45 -15
  35. package/lib/runtime/lsp-mounts.js +23 -2
  36. package/lib/runtime/mcp-backend.js +14 -15
  37. package/lib/runtime/mcp-client/config.js +8 -0
  38. package/lib/runtime/mcp-client/connection.js +9 -8
  39. package/lib/runtime/mcp-client/host-seams.js +8 -88
  40. package/lib/runtime/mcp-client/oauth.js +82 -14
  41. package/lib/runtime/mcp-client/tools.js +2 -1
  42. package/lib/runtime/mcp-config.js +182 -35
  43. package/lib/runtime/mcp-direct-config.js +49 -6
  44. package/lib/runtime/mcp-mounts.js +15 -3
  45. package/lib/runtime/mcp-overrides.js +131 -1
  46. package/lib/runtime/mcp-redaction.js +8 -0
  47. package/lib/runtime/mcp-status.js +21 -5
  48. package/lib/runtime/project-runtime.js +23 -9
  49. package/lib/runtime/reconcile-scheduler.js +6 -8
  50. package/lib/runtime/reconciler.js +2 -1
  51. package/lib/runtime/regions.js +0 -19
  52. package/lib/runtime/server-config.js +49 -8
  53. package/lib/runtime/settings-namespace.js +21 -13
  54. package/lib/runtime/skills-provider.js +33 -11
  55. package/lib/runtime/source-auto-update.js +10 -14
  56. package/lib/runtime/subagent-catalog.js +62 -22
  57. package/lib/runtime/timer-seat.js +80 -0
  58. package/lib/runtime/tool-registry-observer.js +24 -20
  59. package/lib/runtime/user-commands.js +5 -1
  60. package/lib/runtime/user-panels.js +110 -41
  61. package/lib/runtime/user-store.js +114 -58
  62. package/lib/types/application/catalog-context.d.ts +6 -0
  63. package/lib/types/application/catalog.d.ts +19 -1
  64. package/lib/types/application/install-store.d.ts +1 -1
  65. package/lib/types/application/mcp-service.d.ts +38 -3
  66. package/lib/types/application/ports.d.ts +1 -1
  67. package/lib/types/application/queries.d.ts +8 -1
  68. package/lib/types/catalog/manifests.d.ts +6 -2
  69. package/lib/types/catalog/paths.d.ts +6 -0
  70. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  71. package/lib/types/catalog/skills-parse.d.ts +13 -0
  72. package/lib/types/catalog/source-catalog.d.ts +7 -2
  73. package/lib/types/catalog/surfaces.d.ts +14 -4
  74. package/lib/types/catalog/validate.d.ts +72 -11
  75. package/lib/types/contracts/market.d.ts +77 -0
  76. package/lib/types/contracts/mcp-status.d.ts +11 -0
  77. package/lib/types/contracts/settings.d.ts +59 -0
  78. package/lib/types/model/layouts.d.ts +30 -0
  79. package/lib/types/model/preset-source.d.ts +18 -0
  80. package/lib/types/model/types.d.ts +37 -1
  81. package/lib/types/runtime/agent-role-router.d.ts +96 -18
  82. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  83. package/lib/types/runtime/deadline.d.ts +10 -11
  84. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  85. package/lib/types/runtime/host-locale.d.ts +20 -2
  86. package/lib/types/runtime/lsp-mounts.d.ts +7 -0
  87. package/lib/types/runtime/mcp-backend.d.ts +8 -15
  88. package/lib/types/runtime/mcp-client/config.d.ts +8 -0
  89. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  90. package/lib/types/runtime/mcp-client/oauth.d.ts +4 -0
  91. package/lib/types/runtime/mcp-config.d.ts +75 -13
  92. package/lib/types/runtime/mcp-direct-config.d.ts +22 -0
  93. package/lib/types/runtime/mcp-overrides.d.ts +44 -0
  94. package/lib/types/runtime/mcp-redaction.d.ts +6 -0
  95. package/lib/types/runtime/project-runtime.d.ts +10 -3
  96. package/lib/types/runtime/reconcile-scheduler.d.ts +1 -1
  97. package/lib/types/runtime/regions.d.ts +1 -4
  98. package/lib/types/runtime/server-config.d.ts +26 -1
  99. package/lib/types/runtime/settings-namespace.d.ts +10 -10
  100. package/lib/types/runtime/skills-provider.d.ts +11 -2
  101. package/lib/types/runtime/source-auto-update.d.ts +3 -4
  102. package/lib/types/runtime/subagent-catalog.d.ts +14 -2
  103. package/lib/types/runtime/timer-seat.d.ts +51 -0
  104. package/lib/types/runtime/tool-registry-observer.d.ts +14 -6
  105. package/lib/types/runtime/user-panels.d.ts +65 -12
  106. package/lib/types/runtime/user-store.d.ts +65 -16
  107. package/package.json +23 -6
  108. package/schemas/1.0.0/mcp.schema.json +0 -22
  109. package/schemas/1.1.0/mcp.schema.json +120 -0
  110. package/schemas/1.1.0/plugin.schema.json +65 -0
  111. package/schemas/README.md +25 -19
  112. package/schemas/agent-plugins/spec.md +23 -9
  113. package/schemas/com.deepseek.harness/namespace.schema.json +42 -0
  114. package/schemas/com.deepseek.harness/spec.md +52 -0
@@ -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)
@@ -28,7 +28,7 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
28
28
  if (suite.mcp === undefined || suite.installedAt === undefined || !suite.enabled)
29
29
  continue;
30
30
  const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
31
- for (const { serverKey, server: effective, override, enabled, credentialRefs: refs } of effectiveMcpServers(suite, overrides.get(suiteKey))) {
31
+ for (const { serverKey, server: effective, override, enabled, credentialRefs: refs, policy } of effectiveMcpServers(suite, overrides.get(suiteKey))) {
32
32
  const serverName = deriveServerName(suite.id, serverKey);
33
33
  const tools = observedByServer.get(serverName) ?? [];
34
34
  claimedServers.add(serverName);
@@ -69,10 +69,13 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
69
69
  transport: effective.type,
70
70
  endpoint: endpointOf(effective),
71
71
  config: redactMcpConfig(effective),
72
- tools: disabled && !orphaned ? [] : tools.map(tool => ({ name: tool.name, ...(tool.description === undefined ? {} : { description: tool.description }) })),
72
+ tools: disabled && !orphaned ? [] : observedTools(tools),
73
73
  advertisedTools: tools.length > 0,
74
74
  retryable: diagnostic?.code === 'mount-failed' || diagnostic?.code === 'unmount-failed',
75
75
  ...(effective.type === 'stdio' || effective.auth !== undefined ? {} : { oauthDefault: true }),
76
+ ...(policy.enabledTools === undefined ? {} : { suiteEnabledTools: policy.enabledTools }),
77
+ ...(policy.suiteDisabledTools === undefined ? {} : { suiteDisabledTools: policy.suiteDisabledTools }),
78
+ ...(override?.disabledTools === undefined ? {} : { userDisabledTools: override.disabledTools }),
76
79
  ...(diagnostic?.code === undefined ? {} : { code: diagnostic.code }),
77
80
  ...(reason === undefined ? {} : { reason }),
78
81
  ...(credentialRefs.length === 0 ? {} : { credentialRefs })
@@ -97,7 +100,7 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
97
100
  transport: stale.server.type,
98
101
  endpoint: endpointOf(stale.server),
99
102
  config: redactMcpConfig(stale.server),
100
- tools: tools.map(tool => ({ name: tool.name, ...(tool.description === undefined ? {} : { description: tool.description }) })),
103
+ tools: observedTools(tools),
101
104
  reason: 'MCP tools remain after this plugin was disabled or uninstalled',
102
105
  ...(staleRefs.length === 0 ? {} : { credentialRefs: staleRefs })
103
106
  });
@@ -109,7 +112,7 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
109
112
  kind: 'direct',
110
113
  state: 'connected',
111
114
  transport: 'observed',
112
- tools: tools.map(tool => ({ name: tool.name, ...(tool.description === undefined ? {} : { description: tool.description }) }))
115
+ tools: observedTools(tools)
113
116
  });
114
117
  }
115
118
  const totals = {
@@ -124,6 +127,15 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
124
127
  };
125
128
  return { entries, observedAt: new Date().toISOString(), totals, directObservationOnly: true };
126
129
  }
130
+ /** One tool projection for the status wire: the input schema rides along only while it stays small. */
131
+ const MAX_SCHEMA_CHARS = 20_000;
132
+ function observedTools(tools) {
133
+ return tools.map(tool => ({
134
+ name: tool.name,
135
+ ...(tool.description === undefined ? {} : { description: tool.description }),
136
+ ...(tool.parameters === undefined || JSON.stringify(tool.parameters).length > MAX_SCHEMA_CHARS ? {} : { parameters: tool.parameters })
137
+ }));
138
+ }
127
139
  function groupObservedTools(observed, knownServerNames) {
128
140
  const grouped = new Map();
129
141
  for (const tool of observed) {
@@ -150,7 +162,11 @@ function groupObservedTools(observed, knownServerNames) {
150
162
  if (serverName === undefined || rawName === undefined || rawName === '')
151
163
  continue;
152
164
  const list = grouped.get(serverName) ?? [];
153
- list.push({ name: rawName, ...(tool.description === undefined ? {} : { description: tool.description }) });
165
+ list.push({
166
+ name: rawName,
167
+ ...(tool.description === undefined ? {} : { description: tool.description }),
168
+ ...(tool.parameters === undefined ? {} : { parameters: tool.parameters })
169
+ });
154
170
  grouped.set(serverName, list);
155
171
  }
156
172
  return grouped;
@@ -1,12 +1,14 @@
1
1
  import { CommandMountRegistry } from './commands-mounts.js';
2
+ import { expandPluginPaths, pluginRootOf } from '../catalog/plugin-variables.js';
3
+ import { suiteDataDir } from '../catalog/paths.js';
2
4
  import { McpMountRegistry } from './mcp-mounts.js';
3
5
  import { HooksMountRegistry } from './hooks-mounts.js';
4
6
  import { readFile } from 'node:fs/promises';
5
7
  import { stripFrontmatter } from '../catalog/skills-parse.js';
6
8
  import { parseFrontmatterRecord } from './user-store.js';
7
9
  /** Mount under existing and newly created agents; the caller owns refresh and teardown. */
8
- export function mountProjectCommands(ctx, catalog, t) {
9
- return mountProjectSurface(ctx, catalog, 'commands', scope => new CommandMountRegistry(scope, t));
10
+ export function mountProjectCommands(ctx, catalog, t, dataRoot) {
11
+ return mountProjectSurface(ctx, catalog, 'commands', scope => new CommandMountRegistry(scope, t, dataRoot));
10
12
  }
11
13
  /** MCP uses a separate injected child so network startup cannot delay local commands. */
12
14
  export function mountProjectMcp(ctx, catalog, dataRoot) {
@@ -21,14 +23,18 @@ export function mountProjectHooks(ctx, catalog) {
21
23
  return mountProjectSurface(ctx, catalog, ['shell', 'sessionProjections'], scope => new HooksMountRegistry(scope));
22
24
  }
23
25
  /** Kimi's startup skill and system-prompt declarations use the existing scoped prompt service. */
24
- export function mountSuiteInstructions(ctx, catalog) {
25
- return mountProjectSurface(ctx, catalog, 'systemPrompt', scope => {
26
+ export function mountSuiteInstructions(ctx, catalog, dataRoot) {
27
+ return mountProjectSurface(ctx, catalog, 'systemPrompt', (scope, agent) => {
26
28
  let text = '';
27
29
  const host = scope;
28
30
  const dispose = host.systemPrompt.section({ name: 'agent-plugins:instructions', order: 500, text: () => text });
29
31
  return {
30
32
  async reconcile(projectSuites) {
31
- const result = await suiteInstructions([...(await catalog.enabledUserSuites()), ...projectSuites]);
33
+ const projectDir = agent.session.header.cwd;
34
+ const result = await suiteInstructions([...(await catalog.enabledUserSuites()), ...projectSuites], {
35
+ ...(dataRoot === undefined ? {} : { dataRoot }),
36
+ ...(projectDir === undefined ? {} : { projectDir })
37
+ });
32
38
  text = result.text;
33
39
  return result.errors;
34
40
  },
@@ -39,14 +45,22 @@ export function mountSuiteInstructions(ctx, catalog) {
39
45
  };
40
46
  }, true);
41
47
  }
42
- export async function suiteInstructions(suites) {
48
+ export async function suiteInstructions(suites, paths = {}) {
43
49
  const chunks = [];
44
50
  const errors = [];
45
51
  for (const suite of suites) {
46
52
  if (!suite.enabled || suite.activeSurfaces.skills === false)
47
53
  continue;
54
+ // Startup instructions are author-written suite text like any other
55
+ // surface: the paths they name have to resolve before the model reads them.
56
+ const root = pluginRootOf(suite);
57
+ const context = {
58
+ ...(root === undefined ? {} : { root, ...(paths.dataRoot === undefined ? {} : { data: suiteDataDir(paths.dataRoot, suite.sourceId, suite.id) }) }),
59
+ ...(paths.projectDir === undefined ? {} : { projectDir: paths.projectDir })
60
+ };
61
+ const expand = (text) => expandPluginPaths(text, context);
48
62
  if (suite.systemPrompt !== undefined)
49
- chunks.push(suite.systemPrompt);
63
+ chunks.push(expand(suite.systemPrompt));
50
64
  if (suite.manifest.startupSkill === undefined)
51
65
  continue;
52
66
  const skill = suite.skills.find(skill => skill.name === suite.manifest.startupSkill);
@@ -58,9 +72,9 @@ export async function suiteInstructions(suites) {
58
72
  const content = await readFile(skill.file, 'utf8');
59
73
  if (parseFrontmatterRecord(content).disabled === true)
60
74
  continue;
61
- chunks.push(stripFrontmatter(content));
75
+ chunks.push(expand(stripFrontmatter(content)));
62
76
  if (suite.manifest.skillInstructions !== undefined)
63
- chunks.push(suite.manifest.skillInstructions);
77
+ chunks.push(expand(suite.manifest.skillInstructions));
64
78
  }
65
79
  catch {
66
80
  errors.push({ suiteId: `${suite.sourceId}/${suite.id}`, reason: `startup skill ${skill.name} is unreadable` });
@@ -1,3 +1,4 @@
1
+ import { coalesce } from './timer-seat.js';
1
2
  /** Credential updates are batched for this long before one reconcile runs. */
2
3
  const CREDENTIAL_DEBOUNCE_MS = 150;
3
4
  export class ReconcileScheduler {
@@ -15,13 +16,13 @@ export class ReconcileScheduler {
15
16
  this.runtime = runtime;
16
17
  this.host = host;
17
18
  const eventHost = ctx;
19
+ // One reconcile per burst of credential updates: the host fires one event
20
+ // per reference, and every mount that reads them must see the whole set.
21
+ this.credentialFlush = coalesce(ctx, () => this.flushCredentialUpdates(), CREDENTIAL_DEBOUNCE_MS);
18
22
  this.releaseCredentialUpdates =
19
23
  eventHost.on?.('credentials/reference-updated', ref => {
20
24
  this.pendingCredentialRefs.add(ref);
21
- if (this.credentialFlush !== undefined)
22
- clearTimeout(this.credentialFlush);
23
- this.credentialFlush = setTimeout(() => this.flushCredentialUpdates(), CREDENTIAL_DEBOUNCE_MS);
24
- this.credentialFlush.unref?.();
25
+ this.credentialFlush();
25
26
  }) ?? (() => { });
26
27
  }
27
28
  /** Reconcile now, joining the pass in flight and remembering one follow-up for it. */
@@ -46,9 +47,7 @@ export class ReconcileScheduler {
46
47
  dispose() {
47
48
  this.disposed = true;
48
49
  this.releaseCredentialUpdates();
49
- if (this.credentialFlush !== undefined)
50
- clearTimeout(this.credentialFlush);
51
- this.credentialFlush = undefined;
50
+ this.credentialFlush.dispose();
52
51
  this.pendingCredentialRefs.clear();
53
52
  }
54
53
  async reconcileOnce() {
@@ -86,7 +85,6 @@ export class ReconcileScheduler {
86
85
  * mid-reconcile is never dropped.
87
86
  */
88
87
  flushCredentialUpdates() {
89
- this.credentialFlush = undefined;
90
88
  const refs = [...this.pendingCredentialRefs];
91
89
  this.pendingCredentialRefs.clear();
92
90
  // Re-read the catalog first: an unknown ref means the snapshot predates
@@ -12,9 +12,10 @@ export class RuntimeReconciler {
12
12
  disposed = false;
13
13
  constructor(ctx, dataRoot, t = bindHostLocale(undefined)) {
14
14
  this.mcp = new McpMountRegistry(ctx, dataRoot);
15
- this.commands = new CommandMountRegistry(ctx, t);
15
+ this.commands = new CommandMountRegistry(ctx, t, dataRoot);
16
16
  this.hooks = new HooksMountRegistry(ctx);
17
17
  this.lspRegistry = new LspMountRegistry(ctx);
18
+ this.lspRegistry.setPluginDataRoot(dataRoot);
18
19
  }
19
20
  /** The LSP mount registry, consumed by the LSP status surface. */
20
21
  get lsp() {
@@ -1,24 +1,5 @@
1
- /**
2
- * Download regions for GitHub acquisition, modeled on dshmarket's regions.js:
3
- * one setting answers "where are you" instead of a row of mirror toggles.
4
- *
5
- * The China route proxies github.com clones through a public prefix proxy —
6
- * `git clone https://gh-proxy.com/https://github.com/owner/repo` — rather
7
- * than rewriting the hostname, which is what lets one prefix cover clone,
8
- * fetch, and release downloads without a mapping table per service. Public
9
- * proxies come and go, so the route carries an environment escape hatch
10
- * (`DSH_MARKET_GITHUB_PROXY`), and the region itself is a setting the user
11
- * can flip at any time; sources already cloned keep their proxied origin and
12
- * follow it on refresh.
13
- *
14
- * @module runtime/regions
15
- */
16
1
  /** The China-route GitHub prefix proxy, no trailing slash. */
17
2
  const DEFAULT_GITHUB_PROXY = 'https://gh-proxy.com';
18
- /** Narrow an untrusted stored value to a setting; anything else reads as auto. */
19
- export function narrowDownloadRegion(value) {
20
- return value === 'global' || value === 'china' ? value : 'auto';
21
- }
22
3
  /**
23
4
  * Resolve the effective region: an explicit choice wins; `auto` follows the
24
5
  * interface language — a Chinese-language user defaults to the China route.
@@ -5,6 +5,24 @@ import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalo
5
5
  import { parseLspServers } from '../catalog/lsp-spec.js';
6
6
  import { qualifiedSuiteId } from '../catalog/paths.js';
7
7
  import { redactMcpConfig } from './mcp-redaction.js';
8
+ /**
9
+ * The errors the closed-set package shape produces. A user-owned service does
10
+ * not obey that shape, and the per-transport checks still require what it needs.
11
+ */
12
+ const PACKAGE_SHAPE_KEYWORDS = new Set(['additionalProperties', 'oneOf', 'required', 'const']);
13
+ /**
14
+ * A rejected MCP configuration. The message keeps the flat form the API has
15
+ * always returned, and `fields` carries the same reasons keyed by field so the
16
+ * editor can put each one beside its input.
17
+ */
18
+ export class McpConfigError extends Error {
19
+ fields;
20
+ constructor(fields) {
21
+ super(`invalid MCP configuration: ${fields.map(entry => (entry.field === '' ? entry.message : `${entry.field} ${entry.message}`)).join('; ')}`);
22
+ this.fields = fields;
23
+ this.name = 'McpConfigError';
24
+ }
25
+ }
8
26
  /** Restore only unchanged redacted leaves, rejecting invented masked values. */
9
27
  export function restoreRedactedConfig(input, original, redacted = redactMcpConfig(original)) {
10
28
  if (typeof input === 'string' && input.includes('[redacted]')) {
@@ -21,15 +39,38 @@ export function restoreRedactedConfig(input, original, redacted = redactMcpConfi
21
39
  }
22
40
  return input;
23
41
  }
24
- export async function validateServerMcp(root, key, config) {
42
+ /**
43
+ * Validate one service definition before it is stored.
44
+ *
45
+ * A service the user declared themselves is local data rather than a
46
+ * distributable package: keys this client does not know are kept as written
47
+ * instead of being rejected by the closed-set package schema, and the
48
+ * package-only rules (bare command names, plugin-relative paths) stay off.
49
+ */
50
+ export async function validateServerMcp(root, key, config, options) {
51
+ const userOwned = options?.userOwned === true;
25
52
  const document = { $schema: MCP_SCHEMA_ID, mcpServers: { [key]: config } };
26
- const errors = await validateAgainstSchema(MCP_SCHEMA_ID, document);
27
- if (errors.length > 0)
28
- throw new Error(`invalid MCP configuration: ${errors.join('; ')}`);
29
- const result = await validateMcpJson(root, document);
30
- const server = result.config?.servers[key];
31
- if (server === undefined || result.errors.length > 0)
32
- throw new Error(`invalid MCP configuration: ${result.errors.join('; ')}`);
53
+ // A JSON pointer needs its own escapes so a server key containing `/` or `~`
54
+ // still yields the right prefix for the field paths below.
55
+ const pointer = `/mcpServers/${key.replaceAll('~', '~0').replaceAll('/', '~1')}`;
56
+ const structural = (await validateAgainstSchema(MCP_SCHEMA_ID, document))
57
+ .filter(error => error.instancePath === pointer || error.instancePath.startsWith(`${pointer}/`))
58
+ // The closed-set package shape reports `additionalProperties` plus the
59
+ // `oneOf`/`required` pair its failure produces; user-owned data does not
60
+ // obey it, and the per-transport checks below still require what it needs.
61
+ .filter(error => !(userOwned && PACKAGE_SHAPE_KEYWORDS.has(error.keyword)))
62
+ .map(error => ({
63
+ field: error.instancePath.slice(pointer.length).replace(/^\//, '').replaceAll('/', '.'),
64
+ message: error.message
65
+ }));
66
+ if (structural.length > 0)
67
+ throw new McpConfigError(structural);
68
+ const result = await validateMcpJson(root, document, userOwned ? { packageRules: false } : undefined);
69
+ if (result.config === undefined || result.errors.length > 0)
70
+ throw new McpConfigError(result.errors.map(message => ({ field: '', message })));
71
+ const server = result.config.servers[key];
72
+ if (server === undefined)
73
+ throw new McpConfigError([{ field: '', message: `server "${key}" was rejected` }]);
33
74
  return server;
34
75
  }
35
76
  export function validateServerLsp(key, config) {
@@ -1,6 +1,6 @@
1
+ import { MARKET_SETTINGS_NAMESPACE, resolveMarketSettings } from '../contracts/settings.js';
1
2
  import { FEEDBACK_TOOL_NAME, mountFeedbackTool } from './feedback-tool.js';
2
- import { MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, readMcpBackend } from './mcp-backend.js';
3
- import { narrowDownloadRegion } from './regions.js';
3
+ import { MarketSettingsSchema, readMcpBackend } from './mcp-backend.js';
4
4
  export class MarketSettingsNamespace {
5
5
  ctx;
6
6
  dataRoot;
@@ -28,7 +28,7 @@ export class MarketSettingsNamespace {
28
28
  }
29
29
  /** The persisted MCP backend choice; the built-in client until registration lands. */
30
30
  async backend() {
31
- return this.scope !== undefined && this.scope.get().mcpEnhanced === false ? 'host' : 'builtin';
31
+ return this.settings().mcpEnhanced ? 'builtin' : 'host';
32
32
  }
33
33
  /**
34
34
  * Persist a backend choice.
@@ -41,7 +41,17 @@ export class MarketSettingsNamespace {
41
41
  }
42
42
  /** The persisted download-region setting. */
43
43
  async downloadRegion() {
44
- return narrowDownloadRegion(this.scope?.get().downloadRegion);
44
+ return this.settings().downloadRegion;
45
+ }
46
+ /**
47
+ * The resolved settings, whether or not the namespace is registered yet.
48
+ *
49
+ * Every reader goes through here so "the field is absent" has exactly one
50
+ * answer — {@link resolveMarketSettings} — instead of each caller inventing
51
+ * its own fallback for the window before registration lands.
52
+ */
53
+ settings() {
54
+ return resolveMarketSettings(this.scope?.get());
45
55
  }
46
56
  /** Release every watcher and unmount the feedback tool. */
47
57
  dispose() {
@@ -54,9 +64,7 @@ export class MarketSettingsNamespace {
54
64
  try {
55
65
  this.ctx.logger?.info?.('[dsh-agent-plugins-market] settings inject resolved — registering namespace');
56
66
  const settings = settingsCtx.settings;
57
- const scope = settings.register(MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, {
58
- base: { mcpEnhanced: true, downloadRegion: 'auto', feedbackEnabled: true, scanProjectLayouts: false }
59
- });
67
+ const scope = settings.register(MARKET_SETTINGS_NAMESPACE, MarketSettingsSchema);
60
68
  this.scope = scope;
61
69
  this.ctx.logger?.info?.('[dsh-agent-plugins-market] settings namespace registered — plugin-config card will serve');
62
70
  this.syncProjectLayouts();
@@ -68,9 +76,9 @@ export class MarketSettingsNamespace {
68
76
  if (backend === 'host')
69
77
  void scope.update({ mcpEnhanced: false }).catch(() => { });
70
78
  });
71
- let previousBackend = scope.get().mcpEnhanced !== false;
79
+ let previousBackend = this.settings().mcpEnhanced;
72
80
  this.watchers.push(scope.watch(() => {
73
- const backend = scope.get().mcpEnhanced !== false;
81
+ const backend = this.settings().mcpEnhanced;
74
82
  if (backend === previousBackend)
75
83
  return;
76
84
  previousBackend = backend;
@@ -89,23 +97,23 @@ export class MarketSettingsNamespace {
89
97
  this.ctx.logger?.error?.(`[dsh-agent-plugins-market] settings namespace registration failed — the plugin-config card will be hidden this boot: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
90
98
  }
91
99
  }
92
- /** Apply the background source-update switch; OFF is the default. */
100
+ /** Apply the background source-update switch. */
93
101
  syncAutoUpdateSources() {
94
102
  try {
95
- this.host.setAutoUpdateSources(this.scope?.get().autoUpdateSources === true);
103
+ this.host.setAutoUpdateSources(this.settings().autoUpdateSources);
96
104
  }
97
105
  catch (error) {
98
106
  this.ctx.logger?.error?.(`[dsh-agent-plugins-market] background source update switch failed: ${String(error)}`);
99
107
  }
100
108
  }
101
109
  syncProjectLayouts() {
102
- void this.host.setScanProjectLayouts(this.scope?.get().scanProjectLayouts === true).catch(error => {
110
+ void this.host.setScanProjectLayouts(this.settings().scanProjectLayouts).catch(error => {
103
111
  this.ctx.logger?.error?.(`[dsh-agent-plugins-market] project layout reconciliation failed: ${String(error)}`);
104
112
  });
105
113
  }
106
114
  syncFeedbackTool() {
107
115
  try {
108
- const wanted = this.scope?.get().feedbackEnabled !== false;
116
+ const wanted = this.settings().feedbackEnabled;
109
117
  if (!wanted) {
110
118
  if (this.feedbackDisposer !== undefined) {
111
119
  this.feedbackDisposer();