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
@@ -226,13 +226,17 @@ export class Catalog {
226
226
  async addMcpServer(name, server) {
227
227
  await this.mcp.addServer(name, server);
228
228
  }
229
+ /** Import several pasted services in one write; each entry reports its own outcome. */
230
+ async importMcpServers(servers, overwrite) {
231
+ return this.mcp.importServers(servers, overwrite);
232
+ }
229
233
  /** Read effective service configuration without exposing credential literals. */
230
234
  async serverConfig(kind, id) {
231
235
  return this.mcp.serverConfig(kind, id);
232
236
  }
233
237
  /** Validate a complete replacement before writing; plugin checkouts remain untouched. */
234
- async saveServerConfig(kind, id, config) {
235
- await this.mcp.saveServerConfig(kind, id, config);
238
+ async saveServerConfig(kind, id, config, policy) {
239
+ await this.mcp.saveServerConfig(kind, id, config, policy);
236
240
  }
237
241
  /** One suite's persisted MCP overrides, addressed by qualified suite id. */
238
242
  async mcpOverrides(sourceId, suiteId) {
@@ -251,6 +255,14 @@ export class Catalog {
251
255
  async setMcpServerEnabled(suiteKey, serverKey, enabled) {
252
256
  await this.mcp.setServerEnabled(suiteKey, serverKey, enabled);
253
257
  }
258
+ /**
259
+ * Allow or deny one tool of a declared MCP server, addressed by the same
260
+ * source-qualified suite id its status row carries. The denial lands in the
261
+ * override record, so the suite's own `mcp.json` stays source-owned.
262
+ */
263
+ async setMcpServerToolEnabled(suiteKey, serverKey, tool, enabled) {
264
+ await this.mcp.setServerToolEnabled(suiteKey, serverKey, tool, enabled);
265
+ }
254
266
  /** Re-run the MCP reconcile pass without changing any catalog state. */
255
267
  async retryMounts() {
256
268
  await this.mcp.retryMounts();
@@ -77,7 +77,7 @@ export class InstallStore {
77
77
  await this.context.notifyChanged(true);
78
78
  });
79
79
  }
80
- /** Append config-seeded sources missing from user state and persist them. */
80
+ /** Append seeded sources missing from user state and persist them. */
81
81
  async mergeSources(sources) {
82
82
  const existing = new Set(this.context.state.sources.map(source => source.id));
83
83
  const additions = sources.filter(source => !existing.has(source.id));
@@ -11,13 +11,17 @@ import { isDirectory } from '../catalog/fs-probes.js';
11
11
  import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
12
12
  import { readLocalePreference } from '../runtime/host-locale.js';
13
13
  import { probeHostMcpClient } from '../runtime/mcp-backend.js';
14
+ import { declaredMcpPolicy, namespaceMcpPolicy, resolveMcpPolicy } from '../runtime/mcp-config.js';
14
15
  import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
15
- import { addUserMcpServer, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from '../runtime/mcp-direct-config.js';
16
- import { applyOverride, loadSuiteOverrides, mergeOverridePatch, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
16
+ import { addUserMcpServer, importUserMcpServers, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from '../runtime/mcp-direct-config.js';
17
+ import { applyOverride, loadSuiteOverrides, mergeOverridePatch, parseMcpPolicyPatch, saveSuiteOverrides, withoutPolicyFields } from '../runtime/mcp-overrides.js';
17
18
  import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
18
19
  import { buildMcpStatus } from '../runtime/mcp-status.js';
19
20
  import { resolveRegion } from '../runtime/regions.js';
20
21
  import { applyLspOverrides, lspConfig, restoreRedactedConfig, saveLspOverride, validateServerLsp, validateServerMcp } from '../runtime/server-config.js';
22
+ /** The host compatibility client mounts servers without a startup timeout or tool filters. */
23
+ const HOST_STARTUP_TIMEOUT_UNSUPPORTED = 'the host MCP backend cannot enforce a startup timeout; switch the MCP backend to the built-in client to set one';
24
+ const HOST_TOOL_FILTER_UNSUPPORTED = 'the host MCP backend cannot enforce tool filters; switch the MCP backend to the built-in client to choose tools';
21
25
  export class McpService {
22
26
  context;
23
27
  ports;
@@ -39,7 +43,9 @@ export class McpService {
39
43
  entry.kind = 'direct';
40
44
  entry.managed = true;
41
45
  }
42
- const oauthBackend = (await this.backend()) === 'builtin' && this.reauthorizeAvailable();
46
+ const backend = await this.backend();
47
+ payload.backend = backend;
48
+ const oauthBackend = backend === 'builtin' && this.reauthorizeAvailable();
43
49
  for (const entry of payload.entries) {
44
50
  const auth = entry.config?.auth;
45
51
  entry.canReauthorize =
@@ -58,21 +64,71 @@ export class McpService {
58
64
  await this.context.notifyChanged(true);
59
65
  });
60
66
  }
67
+ /**
68
+ * Import several pasted services in one write. Each entry is checked on its
69
+ * own and reports back with its own outcome, so a paste of many services
70
+ * never fails as a block.
71
+ */
72
+ async importServers(servers, overwrite) {
73
+ if (!Array.isArray(servers))
74
+ throw new Error('servers must be an array');
75
+ const entries = servers.map(entry => {
76
+ if (typeof entry !== 'object' || entry === null)
77
+ throw new Error('each server must be an object');
78
+ const { name, config } = entry;
79
+ if (typeof name !== 'string' || name === '')
80
+ throw new Error('each server needs a name');
81
+ return { name, server: config };
82
+ });
83
+ return this.context.enqueue(async () => {
84
+ const result = await importUserMcpServers(this.context.agentsRoot, entries, overwrite);
85
+ if (result.imported.length > 0)
86
+ await this.context.notifyChanged(true);
87
+ return result;
88
+ });
89
+ }
61
90
  /** Read effective service configuration without exposing credential literals. */
62
91
  async serverConfig(kind, id) {
63
92
  const config = await this.resolveServerConfig(kind, id);
64
- return { kind, id, editable: true, config: redactMcpConfig(config.value) };
93
+ const payload = { kind, id, key: config.key, editable: true, config: redactMcpConfig(config.value) };
94
+ if (kind === 'mcp') {
95
+ payload.backend = await this.backend();
96
+ if (config.policy !== undefined)
97
+ payload.policy = serverPolicyPayload(config.policy, config.override, config.declaredAuth);
98
+ }
99
+ return payload;
65
100
  }
66
- /** Validate a complete replacement before writing; plugin checkouts remain untouched. */
67
- async saveServerConfig(kind, id, config) {
101
+ /**
102
+ * Validate a complete replacement before writing; plugin checkouts remain
103
+ * untouched. `policy` carries what the portable document has no seat for —
104
+ * the two timeouts, the user's tool denials and the OAuth opt-in; an absent
105
+ * field keeps its stored value, `null` clears it.
106
+ */
107
+ async saveServerConfig(kind, id, config, policy) {
68
108
  return this.context.enqueue(async () => {
69
109
  const current = await this.resolveServerConfig(kind, id);
70
110
  const value = restoreRedactedConfig(config, current.value);
71
111
  if (kind === 'mcp') {
72
- const server = await validateServerMcp(current.root, current.key, value);
112
+ const backend = await this.backend();
113
+ const patch = parseMcpPolicyPatch(policy);
114
+ if (backend === 'host' && patch.startupTimeoutMs !== undefined && patch.startupTimeoutMs !== null)
115
+ throw new Error(HOST_STARTUP_TIMEOUT_UNSUPPORTED);
116
+ if (backend === 'host' && patch.disabledTools !== undefined)
117
+ throw new Error(HOST_TOOL_FILTER_UNSUPPORTED);
118
+ const server = await validateServerMcp(current.root, current.key, value, { userOwned: isUserOwnedMcp(current.suiteKey) });
73
119
  const overrides = await loadSuiteOverrides(this.context.dataRoot, current.suiteKey);
74
- const enabled = overrides[current.key]?.enabled;
75
- overrides[current.key] = { config: server, ...(enabled === undefined ? {} : { enabled }) };
120
+ const existing = overrides[current.key] ?? {};
121
+ // The complete configuration already carries every connection input, so
122
+ // a leftover url/headers/env/args field would shadow what was just
123
+ // saved. Enablement and the policy fields the save did not touch survive.
124
+ const next = { config: server };
125
+ if (existing.enabled !== undefined)
126
+ next.enabled = existing.enabled;
127
+ applyAuth(next, patch.auth, existing.auth);
128
+ applyToolList(next, patch.disabledTools, existing.disabledTools);
129
+ applyTimeout(next, 'toolCallTimeoutMs', patch.toolCallTimeoutMs, existing.toolCallTimeoutMs);
130
+ applyTimeout(next, 'startupTimeoutMs', patch.startupTimeoutMs, existing.startupTimeoutMs);
131
+ overrides[current.key] = next;
76
132
  await saveSuiteOverrides(this.context.dataRoot, current.suiteKey, overrides);
77
133
  }
78
134
  else {
@@ -104,6 +160,53 @@ export class McpService {
104
160
  * in the suite's parsed mcp.json.
105
161
  */
106
162
  async setOverride(sourceId, suiteId, serverKey, override) {
163
+ if (override !== null && (override.startupTimeoutMs !== undefined || override.disabledTools !== undefined) && (await this.backend()) === 'host')
164
+ throw new Error(override.startupTimeoutMs !== undefined ? HOST_STARTUP_TIMEOUT_UNSUPPORTED : HOST_TOOL_FILTER_UNSUPPORTED);
165
+ // Merge onto the stored record: the UI never receives literal secret
166
+ // values, so a verbatim write would drop keys it simply redacted.
167
+ await this.updateServerOverride(sourceId, suiteId, serverKey, current => (override === null ? null : mergeOverridePatch(current ?? {}, override)));
168
+ }
169
+ /**
170
+ * Enable or disable one declared server, addressed by the source-qualified
171
+ * suite id the status rows carry. The qualified id is split here rather than
172
+ * by callers so the separator stays a server-side detail.
173
+ */
174
+ async setServerEnabled(suiteKey, serverKey, enabled) {
175
+ const { sourceId, suiteId } = splitSuiteKey(suiteKey);
176
+ await this.setOverride(sourceId, suiteId, serverKey, { enabled });
177
+ }
178
+ /**
179
+ * Allow or deny one tool of a declared server, addressed by the
180
+ * source-qualified suite id the status rows carry. The stored record lists
181
+ * only the tools the user turned off, so a server with nothing denied
182
+ * carries no tool field at all.
183
+ */
184
+ async setServerToolEnabled(suiteKey, serverKey, tool, enabled) {
185
+ const { sourceId, suiteId } = splitSuiteKey(suiteKey);
186
+ if (tool === '')
187
+ throw new Error('tool name is required');
188
+ if ((await this.backend()) === 'host')
189
+ throw new Error(HOST_TOOL_FILTER_UNSUPPORTED);
190
+ await this.updateServerOverride(sourceId, suiteId, serverKey, current => {
191
+ const denied = new Set(current?.disabledTools ?? []);
192
+ if (enabled)
193
+ denied.delete(tool);
194
+ else
195
+ denied.add(tool);
196
+ const next = { ...(current ?? {}) };
197
+ if (denied.size === 0)
198
+ delete next.disabledTools;
199
+ else
200
+ next.disabledTools = [...denied];
201
+ return Object.keys(next).length === 0 ? null : next;
202
+ });
203
+ }
204
+ /**
205
+ * Rewrite one declared server's override record and remount it. The mutate
206
+ * callback receives the stored record — absent when none is persisted — and
207
+ * returns the replacement, or `null` to drop the record entirely.
208
+ */
209
+ async updateServerOverride(sourceId, suiteId, serverKey, mutate) {
107
210
  return this.context.enqueue(async () => {
108
211
  const suiteKey = qualifiedSuiteId(sourceId, suiteId);
109
212
  const source = this.context.state.sources.find(entry => entry.id === sourceId);
@@ -129,14 +232,11 @@ export class McpService {
129
232
  // Override files are keyed by the qualified id so two sources' same-named
130
233
  // suites never share one override record.
131
234
  const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
132
- if (override === null) {
235
+ const next = mutate(overrides[serverKey]);
236
+ if (next === null)
133
237
  delete overrides[serverKey];
134
- }
135
- else {
136
- // Merge onto the stored record: the UI never receives literal secret
137
- // values, so a verbatim write would drop keys it simply redacted.
138
- overrides[serverKey] = mergeOverridePatch(overrides[serverKey] ?? {}, override);
139
- }
238
+ else
239
+ overrides[serverKey] = next;
140
240
  await saveSuiteOverrides(this.context.dataRoot, suiteKey, overrides);
141
241
  await this.context.notifyChanged(true);
142
242
  // The status surface reads the live tool registry, so an immediate read
@@ -147,17 +247,6 @@ export class McpService {
147
247
  await this.context.refreshSettled();
148
248
  });
149
249
  }
150
- /**
151
- * Enable or disable one declared server, addressed by the source-qualified
152
- * suite id the status rows carry. The qualified id is split here rather than
153
- * by callers so the separator stays a server-side detail.
154
- */
155
- async setServerEnabled(suiteKey, serverKey, enabled) {
156
- const separator = suiteKey.indexOf('/');
157
- if (separator <= 0)
158
- throw new Error(`invalid suite id "${suiteKey}"`);
159
- await this.setOverride(suiteKey.slice(0, separator), suiteKey.slice(separator + 1), serverKey, { enabled });
160
- }
161
250
  /**
162
251
  * Re-run the MCP reconcile pass: retries failed mounts and clears residual
163
252
  * tools, without changing any catalog state.
@@ -231,7 +320,11 @@ export class McpService {
231
320
  }
232
321
  return map;
233
322
  }
234
- /** Resolve one editable server: its key, owning suite, root, and effective config. */
323
+ /**
324
+ * Resolve one editable server: its key, owning suite, root, and the service
325
+ * document the editor renders. An MCP document carries the portable shape
326
+ * only; the client policy behind it rides `policy`.
327
+ */
235
328
  async resolveServerConfig(kind, id) {
236
329
  if (kind === 'lsp' && id.startsWith('direct/')) {
237
330
  const key = id.slice(7);
@@ -247,8 +340,17 @@ export class McpService {
247
340
  for (const [key, server] of Object.entries(suite.mcp?.servers ?? {})) {
248
341
  if (id !== `plugin:${suiteKey}/${key}`)
249
342
  continue;
250
- const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
251
- return { key, suiteKey, root: suite.root, value: { ...applyOverride(server, overrides[key]) } };
343
+ const override = (await loadSuiteOverrides(this.context.dataRoot, suiteKey))[key];
344
+ const declared = namespaceMcpPolicy(suite, key);
345
+ return {
346
+ key,
347
+ suiteKey,
348
+ root: suite.root,
349
+ value: { ...withoutPolicyFields(applyOverride(server, override)) },
350
+ policy: resolveMcpPolicy(declaredMcpPolicy(server, declared), override),
351
+ ...(override === undefined ? {} : { override }),
352
+ ...(declared?.auth?.enabled === undefined ? {} : { declaredAuth: declared.auth.enabled })
353
+ };
252
354
  }
253
355
  }
254
356
  else {
@@ -261,3 +363,56 @@ export class McpService {
261
363
  throw new Error('service configuration is not managed by this plugin');
262
364
  }
263
365
  }
366
+ /** Split one source-qualified suite id; the separator stays a server-side detail. */
367
+ function splitSuiteKey(suiteKey) {
368
+ const separator = suiteKey.indexOf('/');
369
+ if (separator <= 0)
370
+ throw new Error(`invalid suite id "${suiteKey}"`);
371
+ return { sourceId: suiteKey.slice(0, separator), suiteId: suiteKey.slice(separator + 1) };
372
+ }
373
+ /** Project one resolved policy onto the wire shape the editor renders. */
374
+ function serverPolicyPayload(policy, override, declaredAuth) {
375
+ const userDenied = override?.disabledTools ?? null;
376
+ const userAuth = override?.auth?.enabled ?? null;
377
+ const suiteAuth = declaredAuth ?? null;
378
+ return {
379
+ toolCallTimeout: policy.toolCallTimeout,
380
+ startupTimeout: policy.startupTimeout,
381
+ deniedTools: { user: userDenied, suite: policy.suiteDisabledTools ?? null, effective: policy.disabledTools ?? [] },
382
+ auth: { user: userAuth, suite: suiteAuth, effective: userAuth ?? suiteAuth ?? true }
383
+ };
384
+ }
385
+ /** The user-owned MCP declaration suite: its file is local data, not a package. */
386
+ function isUserOwnedMcp(suiteKey) {
387
+ return suiteKey === `${USER_MCP_SOURCE}/${USER_MCP_SUITE}`;
388
+ }
389
+ /** Write the OAuth opt-in from a save: absent keeps the stored value, `null` clears it. */
390
+ function applyAuth(target, patch, stored) {
391
+ if (patch === undefined) {
392
+ if (stored !== undefined)
393
+ target.auth = stored;
394
+ return;
395
+ }
396
+ if (patch !== null)
397
+ target.auth = patch;
398
+ }
399
+ /** Write the user's deny list: absent keeps it, `null` clears it, entries replace it. */
400
+ function applyToolList(target, patch, stored) {
401
+ if (patch === undefined) {
402
+ if (stored !== undefined)
403
+ target.disabledTools = stored;
404
+ return;
405
+ }
406
+ if (patch !== null && patch.length > 0)
407
+ target.disabledTools = patch;
408
+ }
409
+ /** Write one timeout from a save: absent keeps the stored value, `null` clears it, a number sets it. */
410
+ function applyTimeout(target, field, patch, stored) {
411
+ if (patch === undefined) {
412
+ if (stored !== undefined)
413
+ target[field] = stored;
414
+ return;
415
+ }
416
+ if (patch !== null)
417
+ target[field] = patch;
418
+ }
@@ -43,6 +43,21 @@ export async function downloadArchive(url, tempFile, options = {}) {
43
43
  const response = await fetch(trimmed, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' });
44
44
  if (!response.ok)
45
45
  throw new Error(`archive download failed: HTTP ${response.status} for ${trimmed}`);
46
+ // Fetch follows redirects. The origin was checked above, but the final URL
47
+ // must preserve the HTTPS-only policy as well; otherwise a trusted HTTPS URL
48
+ // could downgrade the payload request to plain HTTP.
49
+ if (options.allowHttp !== true) {
50
+ const finalUrl = response.url === '' ? trimmed : response.url;
51
+ let finalProtocol;
52
+ try {
53
+ finalProtocol = new URL(finalUrl).protocol;
54
+ }
55
+ catch {
56
+ throw new Error('archive redirected to an invalid URL');
57
+ }
58
+ if (finalProtocol !== 'https:')
59
+ throw new Error('archive redirected to non-https URL');
60
+ }
46
61
  const declared = Number(response.headers.get('content-length') ?? '0');
47
62
  if (Number.isFinite(declared) && declared > ARCHIVE_MAX_BYTES) {
48
63
  throw new Error(`archive exceeds the ${ARCHIVE_MAX_BYTES} byte limit: ${declared}`);
@@ -23,7 +23,8 @@ import { join } from 'node:path';
23
23
  import { sanitizeId } from './paths.js';
24
24
  import { isFile } from './fs-probes.js';
25
25
  import { isRecord } from './component-files.js';
26
- import { isRecognizedSchema, validatePluginManifest } from './validate.js';
26
+ import { formatSchemaErrors, isRecognizedSchema, validatePluginManifest } from './validate.js';
27
+ import { EXTENSION_NAMESPACE, NAMESPACE_SCHEMA_VERSIONS, V1_IGNORED_MANIFEST_KEYS } from '../model/layouts.js';
27
28
  import { PLUGIN_LAYOUTS, MANIFEST_ALIASES, MARKETPLACE_PATHS } from '../model/layouts.js';
28
29
  /**
29
30
  * Every manifest file a directory carries, in selection order: highest
@@ -65,14 +66,20 @@ export function componentDeclarations(record) {
65
66
  * go to `fallbacks`, so the winner stays usable while the caller can still
66
67
  * report why the higher-priority declaration was ignored. The winner's own
67
68
  * diagnostics stay in `errors` and remain fatal.
69
+ *
70
+ * `notes` carries the winner's non-fatal diagnostics — §5.2 unknown
71
+ * top-level fields, §8.1 extension data problems. Callers surface them
72
+ * without failing the suite.
68
73
  */
69
- export async function readManifest(root, errors, hint, fallbacks = []) {
74
+ export async function readManifest(root, errors, hint, fallbacks = [], notes = []) {
70
75
  const candidates = await detectManifests(root);
71
76
  for (const [index, candidate] of candidates.entries()) {
72
77
  const attempt = [];
73
- const manifest = await readOneManifest(root, candidate, attempt, hint);
78
+ const attemptNotes = [];
79
+ const manifest = await readOneManifest(root, candidate, attempt, hint, attemptNotes);
74
80
  if (manifest !== undefined) {
75
81
  errors.push(...attempt);
82
+ notes.push(...attemptNotes);
76
83
  return manifest;
77
84
  }
78
85
  if (index === candidates.length - 1)
@@ -83,7 +90,7 @@ export async function readManifest(root, errors, hint, fallbacks = []) {
83
90
  return undefined;
84
91
  }
85
92
  /** Parse one manifest candidate; undefined when this candidate is unusable. */
86
- async function readOneManifest(root, candidate, errors, hint) {
93
+ async function readOneManifest(root, candidate, errors, hint, notes = []) {
87
94
  let raw;
88
95
  try {
89
96
  raw = JSON.parse(await readFile(candidate.path, 'utf8'));
@@ -130,21 +137,47 @@ async function readOneManifest(root, candidate, errors, hint) {
130
137
  }
131
138
  }
132
139
  }
133
- const problems = kind === 'agent-plugin-v1' ? await validatePluginManifest(raw) : [];
134
- if (problems.length > 0) {
135
- errors.push(...problems.map(problem => `${candidate.path}: ${problem}`));
140
+ // v1 validation verdicts split per §5.2: fatal violations reject this
141
+ // candidate (the next one by priority is tried), ignored violations —
142
+ // unknown top-level fields and the whole `extensions` subtree — are
143
+ // reported through `notes` and the manifest keeps loading.
144
+ const verdict = kind === 'agent-plugin-v1' ? await validatePluginManifest(raw) : undefined;
145
+ if (verdict !== undefined && verdict.fatal.length > 0) {
146
+ errors.push(...formatSchemaErrors(verdict.fatal).map(problem => `${candidate.path}: ${problem}`));
136
147
  return undefined;
137
148
  }
149
+ const ignored = verdict === undefined ? [] : formatSchemaErrors(verdict.ignored);
150
+ if (kind === 'agent-plugin-v1') {
151
+ // §5.2 / §6.1: the portable manifest carries no component configuration
152
+ // and no client semantics beyond `extensions`; every inline key this
153
+ // manager would otherwise read is an unknown top-level field here.
154
+ for (const key of V1_IGNORED_MANIFEST_KEYS) {
155
+ if (raw[key] !== undefined)
156
+ ignored.push(`${key}: not part of the portable manifest; ignored`);
157
+ }
158
+ }
159
+ notes.push(...ignored.map(problem => `${candidate.path}: ${problem}`));
160
+ const harness = kind === 'agent-plugin-v1' ? parseHarnessExtension(record['extensions'], notes) : undefined;
138
161
  const name = pickString(record.name) ?? hint?.name ?? syntheticManifestName(root);
139
162
  const version = pickString(record.version) ?? hint?.version;
140
163
  const description = pickString(record.description) ?? hint?.description;
141
164
  const author = record.author;
142
165
  return {
143
- components: { ...componentDeclarations(hint ?? {}), ...fallbackComponents, ...componentDeclarations(raw) },
144
- ...(typeof raw.skillInstructions === 'string' ? { skillInstructions: raw.skillInstructions } : {}),
145
- ...(typeof raw.systemPrompt === 'string' ? { systemPrompt: raw.systemPrompt } : {}),
146
- ...(typeof raw.systemPromptPath === 'string' ? { systemPromptPath: raw.systemPromptPath } : {}),
147
- ...(typeof raw.sessionStart?.skill === 'string'
166
+ components: {
167
+ ...(kind === 'agent-plugin-v1' ? {} : componentDeclarations(hint ?? {})),
168
+ ...fallbackComponents,
169
+ ...(kind === 'agent-plugin-v1' ? {} : componentDeclarations(raw))
170
+ },
171
+ ...(typeof raw.skillInstructions === 'string' && kind !== 'agent-plugin-v1'
172
+ ? { skillInstructions: raw.skillInstructions }
173
+ : {}),
174
+ ...(typeof raw.systemPrompt === 'string' && kind !== 'agent-plugin-v1'
175
+ ? { systemPrompt: raw.systemPrompt }
176
+ : {}),
177
+ ...(typeof raw.systemPromptPath === 'string' && kind !== 'agent-plugin-v1'
178
+ ? { systemPromptPath: raw.systemPromptPath }
179
+ : {}),
180
+ ...(kind !== 'agent-plugin-v1' && typeof raw.sessionStart?.skill === 'string'
148
181
  ? { startupSkill: raw.sessionStart.skill }
149
182
  : {}),
150
183
  layout: kind,
@@ -161,9 +194,87 @@ async function readOneManifest(root, candidate, errors, hint) {
161
194
  ? { author: pickString(record.homepage) }
162
195
  : {}),
163
196
  keywords: Array.isArray(record.keywords) ? record.keywords.filter((entry) => typeof entry === 'string') : [],
164
- ...(isRecognizedSchema(record.$schema) ? { schemaVersion: record.$schema } : {})
197
+ ...(isRecognizedSchema(record.$schema) ? { schemaVersion: record.$schema } : {}),
198
+ ...(harness === undefined ? {} : { harness })
165
199
  };
166
200
  }
201
+ /**
202
+ * Read this client's §8.1 extension data from a v1 manifest's `extensions`
203
+ * object. Unknown namespaces are ignored without validating their contents
204
+ * (§8.1); our own namespace is validated against the namespace contract, and
205
+ * an unusable value is reported through `notes` instead of failing the
206
+ * plugin.
207
+ *
208
+ * @returns the parsed namespace data, or undefined when the suite declares
209
+ * none; diagnostics land in `notes`.
210
+ */
211
+ function parseHarnessExtension(extensions, notes) {
212
+ if (!isRecord(extensions) || extensions[EXTENSION_NAMESPACE] === undefined)
213
+ return undefined;
214
+ return parseHarnessNamespace(extensions[EXTENSION_NAMESPACE], notes);
215
+ }
216
+ /** Validate one namespace value against the namespace contract; diagnostics go to `notes`. */
217
+ function parseHarnessNamespace(value, notes) {
218
+ if (!isRecord(value)) {
219
+ notes.push(`extensions.${EXTENSION_NAMESPACE}: value must be an object; ignored`);
220
+ return undefined;
221
+ }
222
+ const schemaVersion = value['schemaVersion'];
223
+ if (typeof schemaVersion !== 'string' || !NAMESPACE_SCHEMA_VERSIONS.has(schemaVersion)) {
224
+ notes.push(`extensions.${EXTENSION_NAMESPACE}: unsupported or missing schemaVersion ${JSON.stringify(schemaVersion)}; supported: ${[...NAMESPACE_SCHEMA_VERSIONS].join(', ')}; namespace extensions are ignored`);
225
+ return undefined;
226
+ }
227
+ const policies = Object.create(null);
228
+ const rawPolicies = value['mcpServers'];
229
+ if (rawPolicies !== undefined) {
230
+ if (!isRecord(rawPolicies)) {
231
+ notes.push(`extensions.${EXTENSION_NAMESPACE}.mcpServers: value must be an object; ignored`);
232
+ return { schemaVersion, mcpServers: policies };
233
+ }
234
+ for (const [name, policy] of Object.entries(rawPolicies)) {
235
+ if (!isRecord(policy)) {
236
+ notes.push(`extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}: policy must be an object; ignored`);
237
+ continue;
238
+ }
239
+ const auth = policy['auth'];
240
+ if (auth !== undefined &&
241
+ (!isRecord(auth) || (auth['enabled'] !== undefined && typeof auth['enabled'] !== 'boolean') || (auth['scope'] !== undefined && typeof auth['scope'] !== 'string'))) {
242
+ notes.push(`extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.auth: invalid auth declaration; ignored`);
243
+ continue;
244
+ }
245
+ const enabledTools = optionalStringArray(policy['enabledTools'], `extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.enabledTools`, notes);
246
+ const disabledTools = optionalStringArray(policy['disabledTools'], `extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.disabledTools`, notes);
247
+ const startupTimeoutMs = optionalPositiveNumber(policy['startupTimeoutMs'], `extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.startupTimeoutMs`, notes);
248
+ const toolCallTimeoutMs = optionalPositiveNumber(policy['toolCallTimeoutMs'], `extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.toolCallTimeoutMs`, notes);
249
+ policies[name] = {
250
+ ...(enabledTools === undefined ? {} : { enabledTools }),
251
+ ...(disabledTools === undefined ? {} : { disabledTools }),
252
+ ...(startupTimeoutMs === undefined ? {} : { startupTimeoutMs }),
253
+ ...(toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs }),
254
+ ...(auth === undefined || !isRecord(auth) ? {} : { auth: { enabled: auth['enabled'] !== false, ...(typeof auth['scope'] === 'string' ? { scope: auth['scope'] } : {}) } })
255
+ };
256
+ }
257
+ }
258
+ return { schemaVersion, mcpServers: policies };
259
+ }
260
+ function optionalStringArray(value, label, notes) {
261
+ if (value === undefined)
262
+ return undefined;
263
+ if (!Array.isArray(value) || !value.every(entry => typeof entry === 'string')) {
264
+ notes.push(`${label}: must be an array of strings; ignored`);
265
+ return undefined;
266
+ }
267
+ return value;
268
+ }
269
+ function optionalPositiveNumber(value, label, notes) {
270
+ if (value === undefined)
271
+ return undefined;
272
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
273
+ notes.push(`${label}: must be a positive number; ignored`);
274
+ return undefined;
275
+ }
276
+ return value;
277
+ }
167
278
  function pickString(value) {
168
279
  return typeof value === 'string' && value !== '' ? value : undefined;
169
280
  }