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
@@ -3,10 +3,9 @@ import { readFile } from 'node:fs/promises';
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
4
  import { parse as parseYaml } from 'yaml';
5
5
  import { mountSubagentCatalog } from './subagent-catalog.js';
6
- import { bindHostLocale } from './host-locale.js';
7
6
  import { namedAgentRoles } from './agent-role-names.js';
8
7
  import { expandPluginPaths } from '../catalog/plugin-variables.js';
9
- export const AGENT_ROLE_TOOL_NAME = 'subagent_run';
8
+ export const AGENT_ROLE_TOOL_NAME = 'subagent_role';
10
9
  /**
11
10
  * Absolute delegation-depth cap applied to every role child. The host default is
12
11
  * the same value; keeping it explicit preserves the shipped recursion budget.
@@ -63,13 +62,17 @@ function parentRouteOf(parent) {
63
62
  return agent?.session?.requestHeader?.()?.config ?? agent?.options ?? {};
64
63
  }
65
64
  /**
66
- * Resolve the child LLM options one card may contribute.
65
+ * Resolve the child LLM options for one role call.
67
66
  *
68
- * A card route is advisory. Only an exact `provider` plus `model` pair is
69
- * applied, and only when the live LLM runtime accepts it; every other
70
- * declaration — a bare id, a Claude alias, a lone `provider`, an unsupported
71
- * effort — degrades to inheriting the parent route and reports a diagnostic.
72
- * @param policy - the card's parsed routing metadata.
67
+ * The call's own `provider` + `model` pair wins, and the role card supplies the
68
+ * default when the call names neither. Only an exact pair is applied, and only
69
+ * when the live LLM runtime accepts it; a bare id, a Claude alias, a lone
70
+ * `provider` or an unsupported effort degrades to inheriting the parent route
71
+ * and reports a diagnostic. An explicit route with no explicit effort uses the
72
+ * selected model's default, so a card's effort never leaks onto a route it was
73
+ * not written for.
74
+ * @param policy - the card's parsed routing metadata, used as the default route.
75
+ * @param requested - the route the call itself asked for.
73
76
  * @param parent - the calling Agent whose route an unusable declaration falls back to.
74
77
  * @param llm - live LLM runtime owning provider, model and effort validation.
75
78
  * @param signal - tool-call cancellation signal.
@@ -77,27 +80,34 @@ function parentRouteOf(parent) {
77
80
  * @param diagnose - diagnostic sink; degradation is reported, never silent.
78
81
  * @returns the options to send, or undefined for pure inheritance.
79
82
  */
80
- export async function resolveAgentOptions(policy, parent, llm, signal, subject, diagnose) {
81
- // One value, so a half-specified route can never be forwarded downstream.
82
- const requested = policy.provider === undefined || policy.model === undefined ? undefined : { provider: policy.provider, model: policy.model };
83
- if (requested === undefined && (policy.provider !== undefined || policy.model !== undefined)) {
83
+ export async function resolveAgentOptions(policy, requested, parent, llm, signal, subject, diagnose) {
84
+ const cardRoute = policy.provider !== undefined && policy.model !== undefined ? { provider: policy.provider, model: policy.model } : undefined;
85
+ if (cardRoute === undefined && (policy.provider !== undefined || policy.model !== undefined)) {
84
86
  const declared = [policy.provider, policy.model].filter(value => value !== undefined).join('/');
85
87
  diagnose(`agent "${subject}": route "${declared}" needs an exact provider and model pair; it is ignored and the child inherits the parent route`);
86
88
  }
87
- const effort = policy.reasoningEffort;
88
- if (effort === undefined && requested === undefined)
89
+ const callRoute = requested.provider !== undefined && requested.model !== undefined ? { provider: requested.provider, model: requested.model } : undefined;
90
+ if (callRoute === undefined && (requested.provider !== undefined || requested.model !== undefined)) {
91
+ const declared = [requested.provider, requested.model].filter(value => value !== undefined).join('/');
92
+ diagnose(`agent "${subject}": the call's route "${declared}" needs an exact provider and model pair; it is ignored`);
93
+ }
94
+ // One value, so a half-specified route can never be forwarded downstream.
95
+ const route = callRoute ?? cardRoute;
96
+ const routeEffort = callRoute === undefined ? policy.reasoningEffort : undefined;
97
+ const effort = requested.reasoningEffort ?? routeEffort;
98
+ if (effort === undefined && route === undefined)
89
99
  return undefined;
90
100
  const inherited = parentRouteOf(parent);
91
- const provider = requested?.provider ?? inherited.provider;
92
- const model = requested?.model ?? inherited.model;
101
+ const provider = route?.provider ?? inherited.provider;
102
+ const model = route?.model ?? inherited.model;
93
103
  if (provider === undefined || model === undefined) {
94
104
  /* v8 ignore next 3 -- an effective route is always known once a parent Agent ran a request. */
95
105
  if (effort !== undefined)
96
106
  diagnose(`agent "${subject}": reasoning effort "${effort}" has no effective route to validate against; it is ignored`);
97
107
  return undefined;
98
108
  }
99
- const routeChanged = provider !== inherited.provider || model !== inherited.model;
100
- const effectiveEffort = effort ?? (routeChanged ? undefined : inherited.reasoningEffort);
109
+ // An explicit route choice starts from the selected model's default effort.
110
+ const effectiveEffort = effort ?? (route === undefined ? inherited.reasoningEffort : undefined);
101
111
  try {
102
112
  await llm.resolveCallConfig({ provider, model, ...(effectiveEffort === undefined ? {} : { reasoningEffort: effectiveEffort }) }, signal);
103
113
  }
@@ -109,7 +119,7 @@ export async function resolveAgentOptions(policy, parent, llm, signal, subject,
109
119
  // Cancellation raised during the adapter lookup must not create a child.
110
120
  signal.throwIfAborted();
111
121
  return {
112
- ...(requested ?? {}),
122
+ ...(route ?? {}),
113
123
  ...(effort === undefined ? {} : { reasoningEffort: effort })
114
124
  };
115
125
  }
@@ -187,15 +197,16 @@ function sessionCwd(agent) {
187
197
  return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
188
198
  }
189
199
  /**
190
- * Start one role child and return its durable id without waiting for the result.
191
- * Resume state, steering, and the settlement notice belong to the host's
192
- * continuation manager; this executor owns only role resolution.
200
+ * Run one role child through the requested channel.
201
+ * @param host - the exact live seams this executor owns: tools, llm and subagents.
202
+ * @param mode - which channel the call asked for.
203
+ * @param jobs - the host jobs registry; only the `background` channel needs it.
193
204
  */
194
- export async function executeAgentRole(host, listRoles, agentName, prompt, parent, signal, diagnose = () => { }) {
205
+ export async function executeAgentRole(host, listRoles, agentName, prompt, parent, requestedRoute, mode, signal, diagnose = () => { }, jobs) {
195
206
  if (parent === undefined)
196
- throw new Error('subagent_run requires a calling agent');
207
+ throw new Error(`${AGENT_ROLE_TOOL_NAME} requires a calling agent`);
197
208
  if (prompt.trim() === '')
198
- throw new Error('subagent_run requires a non-empty prompt');
209
+ throw new Error(`${AGENT_ROLE_TOOL_NAME} requires a non-empty prompt`);
199
210
  signal.throwIfAborted();
200
211
  const [entry, ...duplicates] = namedAgentRoles(await listRoles(parent)).filter(candidate => candidate.callName === agentName);
201
212
  if (entry === undefined || duplicates.length > 0)
@@ -205,51 +216,165 @@ export async function executeAgentRole(host, listRoles, agentName, prompt, paren
205
216
  const policy = await readAgentRole(entry, sessionCwd(parent));
206
217
  if (policy.disabled)
207
218
  throw new Error(`agent "${agentName}" is disabled`);
208
- const agentOptions = await resolveAgentOptions(policy, parent, host.llm, signal, agentName, diagnose);
219
+ const agentOptions = await resolveAgentOptions(policy, requestedRoute, parent, host.llm, signal, agentName, diagnose);
209
220
  signal.throwIfAborted();
210
- const started = await host.subagents.startContinuable({
211
- provider: 'spawn',
212
- label: agentName,
213
- request: {
214
- prompt: [{ type: 'text', text: prompt }],
215
- parent,
216
- maxDepth: MAX_AGENT_ROLE_DEPTH,
217
- persona: policy.content,
218
- ...(agentOptions === undefined ? {} : { agentOptions })
219
- },
220
- signal
221
- });
222
- return { subagentId: started.childId };
221
+ const request = {
222
+ prompt: [{ type: 'text', text: prompt }],
223
+ parent,
224
+ maxDepth: MAX_AGENT_ROLE_DEPTH,
225
+ persona: policy.content,
226
+ ...(agentOptions === undefined ? {} : { agentOptions })
227
+ };
228
+ if (mode === 'continuable') {
229
+ const started = await host.subagents.startContinuable({ provider: 'spawn', label: agentName, request, signal });
230
+ return { kind: 'continuable', subagentId: started.childId };
231
+ }
232
+ if (mode === 'background') {
233
+ if (jobs === undefined) {
234
+ throw new Error(`background role jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs, or call ${AGENT_ROLE_TOOL_NAME} with run_in_background false`);
235
+ }
236
+ // The run's own controller outlives `jobs.start`, which returns at
237
+ // registration: cancellation and disposal both go through the job's hooks.
238
+ return {
239
+ kind: 'background',
240
+ jobId: jobs.start({
241
+ kind: 'subagent',
242
+ label: agentName,
243
+ owner: parent,
244
+ run: () => {
245
+ const controller = new AbortController();
246
+ return {
247
+ cancel: (reason) => controller.abort(reason ?? 'background role job killed'),
248
+ done: (async () => {
249
+ const run = await host.subagents.start('spawn', { ...request, signal: controller.signal });
250
+ try {
251
+ const result = await run.result;
252
+ return result.stopReason === 'completed' ? { status: 'completed' } : { status: 'failed', detail: result.diagnostic ?? result.stopReason };
253
+ }
254
+ finally {
255
+ await run.dispose();
256
+ }
257
+ })()
258
+ };
259
+ }
260
+ })
261
+ };
262
+ }
263
+ const run = await host.subagents.start('spawn', { ...request, signal });
264
+ try {
265
+ const result = await run.result;
266
+ if (result.stopReason !== 'completed') {
267
+ const detail = result.diagnostic === undefined ? '' : `: ${result.diagnostic}`;
268
+ throw new Error(`agent "${agentName}" did not complete (${result.stopReason})${detail}`);
269
+ }
270
+ // The registry performs the authoritative lossless snapshot of the child's
271
+ // output blocks on the way out, exactly as the host's own delegation tool does.
272
+ return { kind: 'foreground', runId: run.id, output: result.output };
273
+ }
274
+ finally {
275
+ await run.dispose();
276
+ }
223
277
  }
224
- /** Mount once after tools, llm and subagents are available; the returned disposer belongs to the plugin lifecycle. */
225
- export function mountAgentRoleTool(ctx, listRoles, t = bindHostLocale(undefined)) {
278
+ /**
279
+ * Mount once after tools, llm and subagents are available; the returned disposer belongs to the plugin lifecycle.
280
+ *
281
+ * The delegation contract lives here rather than in the injected catalog: a tool
282
+ * description is present in every request, while the catalog message is
283
+ * re-published whenever roles change, so guidance kept out of the catalog is
284
+ * sent once and stays readable through compaction.
285
+ */
286
+ export function mountAgentRoleTool(ctx, listRoles) {
226
287
  const host = ctx;
227
288
  const tool = defineTool({
228
289
  name: AGENT_ROLE_TOOL_NAME,
229
- description: 'Delegate a self-contained task to a role from the current subagent catalog. The child starts without the parent conversation and runs ' +
230
- 'in the background: this returns a durable subagent id immediately. When the run settles the runtime sends you a notice carrying its ' +
231
- "outcome and closing message, and send_message steers the child while it runs. The role's saved persona and model settings are applied automatically.",
290
+ description: "Delegate a task to a role child defined by an installed plugin: the named role's own instructions and its configured model apply, and the child starts without this conversation. This tool runs in " +
291
+ 'the background by default, immediately returns a durable subagent id, and keeps the child available for later turns; when that run settles, the runtime sends the parent a notice carrying its outcome ' +
292
+ 'and final reply. Set `run_in_background: false` only when your next action depends on receiving the result. A child usually runs for minutes, so while it works on the background path, continue with ' +
293
+ 'independent work instead of waiting. `prompt` must stand alone, since the child cannot see this conversation and a question it asks while it runs goes unanswered. When the notice arrives, verify its ' +
294
+ "assertions against the files and relay the result to the user; the child's output is not visible to them. The current catalog lists the available roles and states the usage guidance; when no listed " +
295
+ "role matches, use the host's own `subagent` tools instead.",
232
296
  parameters: {
233
297
  agent: { type: 'string', required: true, description: 'Exact callable role name from the current subagent catalog.' },
234
- prompt: { type: 'string', required: true, description: 'Complete task and context for the isolated child.' }
298
+ prompt: {
299
+ type: 'string',
300
+ required: true,
301
+ description: "The complete, self-contained task for the role child. It does not share this conversation's context, so include everything it needs: what the task is and why, the relevant files and known findings, the output you expect, and the boundaries of the task."
302
+ },
303
+ provider: {
304
+ type: 'string',
305
+ description: "LLM provider route for the child. Supply together with model to override the route the role card declares; omit both to use the card's route or inherit the parent route."
306
+ },
307
+ model: {
308
+ type: 'string',
309
+ description: "Model id interpreted by provider. Supply together with provider to override the route the role card declares; omit both to use the card's route or inherit the parent route."
310
+ },
311
+ reasoning_effort: {
312
+ type: 'string',
313
+ description: "Adapter-owned reasoning effort for the effective child route. Omit to use the card's effort on the card's route, or the selected model's default after an explicit route change."
314
+ },
315
+ run_in_background: {
316
+ type: 'boolean',
317
+ description: "Which channel runs the child. Omit for the default: a continuable child whose durable subagent id comes back at once, with steering and a settlement notice. Set true for a tracked background job that returns a job id collected with `job_output` and stopped with `job_kill`. Set false for one foreground child whose report comes back as this call's result."
318
+ }
235
319
  },
236
320
  output: {
237
321
  schema: {
238
- type: 'object',
239
- additionalProperties: false,
240
- properties: { subagentId: { type: 'string', required: true } }
322
+ oneOf: [
323
+ {
324
+ type: 'object',
325
+ additionalProperties: false,
326
+ properties: {
327
+ kind: { type: 'string', required: true, const: 'continuable' },
328
+ subagentId: { type: 'string', required: true }
329
+ }
330
+ },
331
+ {
332
+ type: 'object',
333
+ additionalProperties: false,
334
+ properties: {
335
+ kind: { type: 'string', required: true, const: 'background' },
336
+ jobId: { type: 'string', required: true }
337
+ }
338
+ },
339
+ {
340
+ type: 'object',
341
+ additionalProperties: false,
342
+ properties: {
343
+ kind: { type: 'string', required: true, const: 'foreground' },
344
+ runId: { type: 'string', required: true },
345
+ output: { type: 'array', required: true, items: { type: 'json' } }
346
+ }
347
+ }
348
+ ]
241
349
  },
242
- render: (_args, value) => [{ type: 'text', text: `started subagent ${value.subagentId}` }]
350
+ render: (_args, value) => [
351
+ {
352
+ type: 'text',
353
+ text: value.kind === 'continuable'
354
+ ? `started subagent ${value.subagentId}`
355
+ : value.kind === 'background'
356
+ ? `started background subagent job ${value.jobId}`
357
+ : `subagent ${value.runId} finished`
358
+ }
359
+ ]
243
360
  },
244
361
  isConcurrencySafe: () => true,
245
362
  async execute(args, exec) {
246
- return executeAgentRole(host, listRoles, args.agent, args.prompt, exec.agent, exec.signal, message => ctx.logger?.warn(message));
363
+ // The parameter keeps the host tool's name and boolean shape while this
364
+ // tool owns three channels: omitting it keeps the durable child, `true`
365
+ // runs a tracked job, and `false` returns the report from one foreground run.
366
+ const mode = args.run_in_background === undefined ? 'continuable' : args.run_in_background ? 'background' : 'foreground';
367
+ return executeAgentRole(host, listRoles, args.agent, args.prompt, exec.agent, {
368
+ ...(args.provider === undefined ? {} : { provider: args.provider }),
369
+ ...(args.model === undefined ? {} : { model: args.model }),
370
+ ...(args.reasoning_effort === undefined ? {} : { reasoningEffort: args.reasoning_effort })
371
+ }, mode, exec.signal, message => ctx.logger?.warn(message), ctx.get('jobs'));
247
372
  }
248
373
  });
249
374
  const disposeTool = host.tools.register(tool);
250
375
  let disposeCatalog;
251
376
  try {
252
- disposeCatalog = mountSubagentCatalog(ctx, tool, async (agent, signal) => agentRoleCatalog(await listRoles(agent), signal, message => ctx.logger?.warn(message), sessionCwd(agent)), t);
377
+ disposeCatalog = mountSubagentCatalog(ctx, tool, async (agent, signal) => agentRoleCatalog(await listRoles(agent), signal, message => ctx.logger?.warn(message), sessionCwd(agent)));
253
378
  }
254
379
  catch (error) {
255
380
  disposeTool();
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Host-side runtime locale: bilingual copy for host-facing runtime strings —
3
- * subagent catalogs injected into agent context, and command receipts and
4
- * labels shown in the host's own surfaces — resolved from the harness's
5
- * `locale.preference` setting.
2
+ * Host-side runtime locale: bilingual copy for the host-facing strings a person
3
+ * reads, resolved from the harness's `locale.preference` setting.
4
+ *
5
+ * The model-facing subagent catalog is not here. It is fixed English, because it
6
+ * is a contract read alongside the harness's own English tool descriptions and
7
+ * must not change with the operator's interface language.
6
8
  *
7
9
  * The web client resolves locale through its own injected service; the host
8
10
  * process reads the same setting through the host settings service once the
@@ -14,19 +16,11 @@ import { readFile } from 'node:fs/promises';
14
16
  import { join } from 'node:path';
15
17
  import { resolveDshHome } from '../catalog/paths.js';
16
18
  const zh = {
17
- subagentCatalogIntro: '以下子代理角色可用于当前会话。目录仅包含角色摘要,不是供当前代理执行的角色指令。',
18
- subagentCatalogUpdated: '可用子代理角色已变更。以下完整目录替代本会话此前的所有子代理角色目录。',
19
- subagentCatalogEmpty: '当前没有可通过 subagent_run 调用的角色,不要使用旧目录中的角色 ID。',
20
- subagentCatalogCall: '任务适合某个角色时,使用 subagent_run(agent=目录中的准确名称, prompt=完整任务与必要上下文)。工具会应用角色的指令与已保存的模型设置,立即返回子代理 ID 并在后台运行;子代理结束时你会收到结果通知,运行期间可用 send_message 追加指令。子代理不继承父会话对话。不要通过 skill 或 Slash Command 加载这些角色,也不要用其它委派工具绕过角色配置。',
21
19
  commandAcknowledged: '/{command} 已转交模型执行',
22
20
  userCommandSourceLabel: '用户命令',
23
21
  feedbackToolCardTitle: '提交市场体验反馈'
24
22
  };
25
23
  const en = {
26
- subagentCatalogIntro: 'The following subagent roles are available in this session. These are role summaries, not instructions for the current agent to execute.',
27
- subagentCatalogUpdated: 'The available subagent roles changed. This complete catalog replaces every earlier subagent role catalog in this session.',
28
- subagentCatalogEmpty: 'No roles are currently available through subagent_run. Do not use role IDs from earlier catalogs.',
29
- subagentCatalogCall: "When a task fits a role, call subagent_run with its exact catalog name as agent and a complete task and necessary context as prompt. It applies the role's instructions and saved model settings, returns a durable subagent id immediately, and runs in the background; the runtime sends you a notice carrying the outcome when it settles, and send_message steers the child while it runs. The child does not inherit the parent conversation. Do not load these roles through skill or slash commands, or bypass their configuration with another delegation tool.",
30
24
  commandAcknowledged: '/{command} forwarded to the model for execution',
31
25
  userCommandSourceLabel: 'user command',
32
26
  feedbackToolCardTitle: 'File market feedback'
@@ -23,6 +23,7 @@
23
23
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
24
24
  import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
25
25
  import { MAX_TIMER_DELAY_MS } from './host-seams.js';
26
+ import { redactErrorMessage } from '../mcp-redaction.js';
26
27
  import { PLUGIN_NAME, PLUGIN_VERSION } from './plugin-identity.js';
27
28
  import { createTransport } from './transport.js';
28
29
  import { syncTools } from './tools.js';
@@ -229,7 +230,7 @@ export function startConnection(host, config, policy, credentials) {
229
230
  // Fetch-phase failure: the previous generation is still registered
230
231
  // and `disposers` still owns it — keep serving the last good list.
231
232
  if (!disposed)
232
- host.logger.error(`${label}: tool re-sync failed: ${String(error)}`);
233
+ host.logger.error(`${label}: tool re-sync failed: ${redactErrorMessage(String(error))}`);
233
234
  }
234
235
  });
235
236
  /**
@@ -275,7 +276,7 @@ export function startConnection(host, config, policy, credentials) {
275
276
  // Disposal clears current ownership before it closes the generation, so
276
277
  // only a live supervisor reports an attempt failure.
277
278
  if (isCurrent(generation))
278
- host.logger.warn(`${label}: connection attempt failed: ${String(error)}`);
279
+ host.logger.warn(`${label}: connection attempt failed: ${redactErrorMessage(String(error))}`);
279
280
  // A pending browser leg outlives the initialize timeout: the human is
280
281
  // mid-approval. Hold this generation open — transport and loopback
281
282
  // listener stay alive — until the leg settles; abandoning it would
@@ -28,7 +28,10 @@
28
28
  * @module runtime/mcp-client/oauth
29
29
  */
30
30
  import { createServer } from 'node:http';
31
+ import { randomBytes } from 'node:crypto';
32
+ import { isDeepStrictEqual } from 'node:util';
31
33
  import { credentialKey, scrubbedParentEnv } from './host-seams.js';
34
+ import { redactErrorMessage, redactUrl } from '../mcp-redaction.js';
32
35
  import { PLUGIN_NAME, PLUGIN_VERSION } from './plugin-identity.js';
33
36
  /** OAuth client identity presented during dynamic registration. */
34
37
  const CLIENT_NAME = `DeepSeek Harness (${PLUGIN_NAME})`;
@@ -65,6 +68,18 @@ function asMcpAuthRecord(record) {
65
68
  ...(payload.tokens === undefined ? {} : { tokens: payload.tokens })
66
69
  };
67
70
  }
71
+ /**
72
+ * Merge one locally changed durable field without overwriting a concurrent
73
+ * process's update. If the value read before the mutation no longer matches
74
+ * the store's locked current value, the current value wins conservatively.
75
+ */
76
+ function mergeDurableField(base, desired, current) {
77
+ if (isDeepStrictEqual(base, desired))
78
+ return current;
79
+ if (isDeepStrictEqual(base, current))
80
+ return desired;
81
+ return current;
82
+ }
68
83
  /**
69
84
  * Error thrown when the human leg fails or is abandoned: the browser never
70
85
  * returned, the callback carried an OAuth error, or no browser could open.
@@ -89,7 +104,7 @@ export const platformBrowserOpener = async (authorizationUrl, serverName, log) =
89
104
  detached: platform !== 'win32'
90
105
  });
91
106
  child.once('error', error => {
92
- log(`${serverName}: could not open a browser automatically (${String(error)}); open this URL manually: ${authorizationUrl.toString()}`);
107
+ log(`${serverName}: could not open a browser automatically (${redactErrorMessage(String(error))}); open this URL manually: ${redactUrl(authorizationUrl.toString())}`);
93
108
  });
94
109
  child.once('spawn', () => {
95
110
  child.unref();
@@ -113,6 +128,8 @@ export class LoopbackOAuthClientProvider {
113
128
  providerState = {};
114
129
  /** The transport generation whose `finishAuth` completes the browser leg. */
115
130
  activeTransport;
131
+ /** One process-local OAuth state value for the current browser leg. */
132
+ expectedState;
116
133
  /** Resolves `true` when the leg saved tokens, `false` when it failed; undefined while no leg is open. */
117
134
  browserLegSettled;
118
135
  callbackServer;
@@ -148,6 +165,12 @@ export class LoopbackOAuthClientProvider {
148
165
  software_version: PLUGIN_VERSION
149
166
  };
150
167
  }
168
+ /** Issue one unpredictable state value for the SDK's authorization request. */
169
+ state() {
170
+ const state = randomBytes(32).toString('base64url');
171
+ this.expectedState = state;
172
+ return state;
173
+ }
151
174
  async clientInformation() {
152
175
  return (await this.load()).clientInformation;
153
176
  }
@@ -177,6 +200,11 @@ export class LoopbackOAuthClientProvider {
177
200
  if (transport === undefined) {
178
201
  throw new AuthorizationAbortedError(`${this.serverName}: no active transport to complete authorization into`);
179
202
  }
203
+ // The SDK normally calls state() before this hook. Keep a defensive path
204
+ // for direct callers too, and overwrite any caller-supplied value with the
205
+ // state issued by this provider.
206
+ const state = this.expectedState ?? this.state();
207
+ authorizationUrl.searchParams.set('state', state);
180
208
  this.browserLegSettled = Promise.withResolvers();
181
209
  let succeeded = false;
182
210
  try {
@@ -222,6 +250,7 @@ export class LoopbackOAuthClientProvider {
222
250
  this.callbackServer?.close(() => undefined);
223
251
  this.callbackServer = undefined;
224
252
  this.boundPort = undefined;
253
+ this.expectedState = undefined;
225
254
  }
226
255
  saveCodeVerifier(codeVerifier) {
227
256
  // The verifier only needs to survive the browser leg; keeping it out of
@@ -287,17 +316,41 @@ export class LoopbackOAuthClientProvider {
287
316
  async mutate(mutator) {
288
317
  const run = this.writeChain.then(async () => {
289
318
  await this.load();
319
+ const base = {
320
+ clientInformation: this.providerState.clientInformation,
321
+ tokens: this.providerState.tokens
322
+ };
290
323
  mutator(this.providerState);
291
324
  if (this.store === undefined)
292
325
  return;
293
- const record = {
294
- kind: 'grant',
295
- payload: {
296
- ...(this.providerState.clientInformation === undefined ? {} : { clientInformation: this.providerState.clientInformation }),
297
- ...(this.providerState.tokens === undefined ? {} : { tokens: this.providerState.tokens })
298
- }
326
+ const desired = {
327
+ clientInformation: this.providerState.clientInformation,
328
+ tokens: this.providerState.tokens
329
+ };
330
+ const saved = await this.store.modifyRecord(this.recordKey, async (current) => {
331
+ const currentPayload = asMcpAuthRecord(current);
332
+ // Never replace a record owned by another credential provider just
333
+ // because this process raced it; the next read will observe it as
334
+ // absent and the caller can decide what to do.
335
+ if (current !== undefined && currentPayload === undefined)
336
+ return current;
337
+ const payload = currentPayload ?? {};
338
+ const clientInformation = mergeDurableField(base.clientInformation, desired.clientInformation, payload.clientInformation);
339
+ const tokens = mergeDurableField(base.tokens, desired.tokens, payload.tokens);
340
+ const merged = {
341
+ kind: 'grant',
342
+ payload: {
343
+ ...(clientInformation === undefined ? {} : { clientInformation }),
344
+ ...(tokens === undefined ? {} : { tokens })
345
+ }
346
+ };
347
+ return merged;
348
+ });
349
+ const savedPayload = asMcpAuthRecord(saved);
350
+ this.providerState = {
351
+ ...(savedPayload ?? {}),
352
+ ...(this.providerState.codeVerifier === undefined ? {} : { codeVerifier: this.providerState.codeVerifier })
299
353
  };
300
- await this.store.modifyRecord(this.recordKey, () => Promise.resolve(record));
301
354
  });
302
355
  // A failed write must not poison the chain; the caller's rejection is the
303
356
  // signal, and the next write re-reads whatever is durable now.
@@ -317,7 +370,7 @@ export class LoopbackOAuthClientProvider {
317
370
  });
318
371
  server.on('error', error => {
319
372
  this.clearCallbackTimer();
320
- reject(new AuthorizationAbortedError(`${this.serverName}: loopback callback server failed: ${String(error)}`));
373
+ reject(new AuthorizationAbortedError(`${this.serverName}: loopback callback server failed: ${redactErrorMessage(String(error))}`));
321
374
  });
322
375
  server.listen(this.storage.callbackPort ?? 0, '127.0.0.1', () => {
323
376
  const address = server.address();
@@ -343,19 +396,34 @@ export class LoopbackOAuthClientProvider {
343
396
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
344
397
  const oauthError = url.searchParams.get('error');
345
398
  const code = url.searchParams.get('code');
346
- const respond = (status, message) => {
399
+ const respond = (status, message, closeServer = true) => {
347
400
  // Drain the request, then answer with a plain page the user can close.
348
401
  request.resume();
349
402
  request.socket.end(`HTTP/1.1 ${status} ${status === 200 ? 'OK' : 'Bad Request'}\r\n` + 'content-type: text/plain; charset=utf-8\r\nconnection: close\r\n\r\n' + `${message}\n`);
350
- request.on('close', () => {
351
- server.close(() => undefined);
352
- });
403
+ if (closeServer) {
404
+ request.on('close', () => {
405
+ server.close(() => undefined);
406
+ });
407
+ }
353
408
  };
409
+ // Keep the listener alive for malformed or forged callbacks so the real
410
+ // browser redirect can still complete the same authorization leg.
411
+ if (url.pathname !== '/callback') {
412
+ respond(404, 'Not an OAuth callback endpoint.', false);
413
+ return;
414
+ }
415
+ const expectedState = this.expectedState;
416
+ const callbackState = url.searchParams.get('state');
417
+ if (expectedState === undefined || callbackState !== expectedState) {
418
+ respond(400, 'Authorization callback carried an invalid state.', false);
419
+ return;
420
+ }
354
421
  this.clearCallbackTimer();
355
422
  this.callbackServer = undefined;
356
423
  this.boundPort = undefined;
424
+ this.expectedState = undefined;
357
425
  if (oauthError !== null) {
358
- const description = url.searchParams.get('error_description') ?? '';
426
+ const description = redactErrorMessage(url.searchParams.get('error_description') ?? '');
359
427
  respond(400, `Authorization failed: ${oauthError}${description === '' ? '' : ` — ${description}`}`);
360
428
  reject(new AuthorizationAbortedError(`${this.serverName}: the authorization server returned "${oauthError}"${description === '' ? '' : ` (${description})`}`));
361
429
  return;
@@ -21,6 +21,7 @@ import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
21
21
  import { z } from 'zod';
22
22
  import { assertSupportedJsonSchema } from './json-schema-subset.js';
23
23
  import { containsImage, extractText, prepareImageProjection } from './projection.js';
24
+ import { redactErrorMessage } from '../mcp-redaction.js';
24
25
  /**
25
26
  * DeepSeek function-name contract: at most 64 characters. Wire-protocol
26
27
  * constant, not configuration.
@@ -122,7 +123,7 @@ export async function syncTools(client, host, opts, previous) {
122
123
  // sees either the full generation or none of it — never a partial set.
123
124
  for (const dispose of disposers.values())
124
125
  dispose();
125
- host.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`);
126
+ host.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${redactErrorMessage(String(error))}`);
126
127
  if (opts.registrationFailure === 'throw')
127
128
  throw error;
128
129
  return new Map();