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
@@ -8,6 +8,7 @@
8
8
  * notification path — have a single owner.
9
9
  */
10
10
  import { join } from 'node:path';
11
+ import { MARKET_SETTINGS_DEFAULTS } from '../contracts/settings.js';
11
12
  import { STATE_FILE_NAME } from '../catalog/paths.js';
12
13
  import { settlesWithin } from '../runtime/deadline.js';
13
14
  import { EMPTY_STATE, loadState, saveState } from '../runtime/state-store.js';
@@ -27,7 +28,13 @@ export class CatalogContext {
27
28
  mutationQueue = Promise.resolve();
28
29
  currentRevision = 0;
29
30
  currentScanGeneration = 0;
30
- scanProjectLayoutsEnabled = false;
31
+ /**
32
+ * The project-layout switch in effect. The host settings document owns the
33
+ * value and pushes it here on registration and on every change; the initial
34
+ * value is the contract's declared default, which is what the catalog would
35
+ * read from the document before the settings service resolves.
36
+ */
37
+ scanProjectLayoutsEnabled = MARKET_SETTINGS_DEFAULTS.scanProjectLayouts;
31
38
  /** The derived refresh in flight, shared by every change that lands while it runs. */
32
39
  refreshPass;
33
40
  /** Whether a change landed after the running pass started; it earns one more pass. */
@@ -48,6 +48,10 @@ export class Catalog {
48
48
  get userRoot() {
49
49
  return this.context.userRoot;
50
50
  }
51
+ /** The plugin storage root holding per-suite `${PLUGIN_DATA}` directories and overrides. */
52
+ get dataRoot() {
53
+ return this.context.dataRoot;
54
+ }
51
55
  get sources() {
52
56
  return this.context.state.sources;
53
57
  }
@@ -222,13 +226,17 @@ export class Catalog {
222
226
  async addMcpServer(name, server) {
223
227
  await this.mcp.addServer(name, server);
224
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
+ }
225
233
  /** Read effective service configuration without exposing credential literals. */
226
234
  async serverConfig(kind, id) {
227
235
  return this.mcp.serverConfig(kind, id);
228
236
  }
229
237
  /** Validate a complete replacement before writing; plugin checkouts remain untouched. */
230
- async saveServerConfig(kind, id, config) {
231
- await this.mcp.saveServerConfig(kind, id, config);
238
+ async saveServerConfig(kind, id, config, policy) {
239
+ await this.mcp.saveServerConfig(kind, id, config, policy);
232
240
  }
233
241
  /** One suite's persisted MCP overrides, addressed by qualified suite id. */
234
242
  async mcpOverrides(sourceId, suiteId) {
@@ -238,6 +246,23 @@ export class Catalog {
238
246
  async setMcpOverride(sourceId, suiteId, serverKey, override) {
239
247
  await this.mcp.setOverride(sourceId, suiteId, serverKey, override);
240
248
  }
249
+ /**
250
+ * Enable or disable one declared MCP server, addressed by the same
251
+ * source-qualified suite id its status row carries. Disabling writes an
252
+ * override rather than editing the suite's `mcp.json`, so a refresh cannot
253
+ * clobber the user's choice.
254
+ */
255
+ async setMcpServerEnabled(suiteKey, serverKey, enabled) {
256
+ await this.mcp.setServerEnabled(suiteKey, serverKey, enabled);
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
+ }
241
266
  /** Re-run the MCP reconcile pass without changing any catalog state. */
242
267
  async retryMounts() {
243
268
  await this.mcp.retryMounts();
@@ -1,5 +1,5 @@
1
1
  /** Suite detail and preview projections owned by the application layer. */
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, stat } from 'node:fs/promises';
3
3
  import { parse as parseYaml } from 'yaml';
4
4
  import { discoverLspEntries } from '../catalog/surfaces.js';
5
5
  import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
@@ -7,6 +7,16 @@ import { credentialRefsInServer } from '../runtime/mcp-config.js';
7
7
  import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
8
8
  import { applyOverride } from '../runtime/mcp-overrides.js';
9
9
  import { effectiveSurfaces } from '../model/types.js';
10
+ /** The checkout's last modification, or null when the path cannot be read. */
11
+ async function rootModifiedAt(root) {
12
+ try {
13
+ const stats = await stat(root);
14
+ return new Date(stats.mtimeMs).toISOString();
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
10
20
  /** Build the detail response for one normalized suite. */
11
21
  export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrides = {}) {
12
22
  const remoteUrl = suite.remote?.url;
@@ -18,6 +28,7 @@ export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrid
18
28
  description: suite.manifest.description ?? null,
19
29
  author: suite.manifest.author ?? null,
20
30
  keywords: suite.manifest.keywords ?? [],
31
+ updatedAt: await rootModifiedAt(suite.root),
21
32
  layout: suite.manifest.layout,
22
33
  dimension: suite.dimension,
23
34
  root: remoteUrl ?? suite.root,
@@ -66,7 +77,7 @@ export async function readSkillContent(suite, skillName) {
66
77
  content = await readFile(skill.file, 'utf8');
67
78
  }
68
79
  catch (error) {
69
- throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}`);
80
+ throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
70
81
  }
71
82
  return { name: skill.name, description: skill.description, content, path: skill.file };
72
83
  }
@@ -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,16 +232,19 @@ 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);
242
+ // The status surface reads the live tool registry, so an immediate read
243
+ // races the reconciler's teardown: a just-disabled server still carries
244
+ // its observed tools and reports the orphaned state with its switch on
245
+ // until the pass finishes. Joining the pass here is bounded — one stuck
246
+ // mount costs the request its deadline, never the plugin.
247
+ await this.context.refreshSettled();
142
248
  });
143
249
  }
144
250
  /**
@@ -214,7 +320,11 @@ export class McpService {
214
320
  }
215
321
  return map;
216
322
  }
217
- /** 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
+ */
218
328
  async resolveServerConfig(kind, id) {
219
329
  if (kind === 'lsp' && id.startsWith('direct/')) {
220
330
  const key = id.slice(7);
@@ -230,8 +340,17 @@ export class McpService {
230
340
  for (const [key, server] of Object.entries(suite.mcp?.servers ?? {})) {
231
341
  if (id !== `plugin:${suiteKey}/${key}`)
232
342
  continue;
233
- const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
234
- 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
+ };
235
354
  }
236
355
  }
237
356
  else {
@@ -244,3 +363,56 @@ export class McpService {
244
363
  throw new Error('service configuration is not managed by this plugin');
245
364
  }
246
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
+ }
@@ -1,8 +1,9 @@
1
1
  /** Installed suite and user resources share one inventory; paths never come from HTTP callers. */
2
- import { realpath, unlink } from 'node:fs/promises';
2
+ import { realpath, stat, unlink } from 'node:fs/promises';
3
3
  import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
4
4
  import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
5
- import { isWithin } from '../catalog/paths.js';
5
+ import { pluginRootOf } from '../catalog/plugin-variables.js';
6
+ import { isWithin, suiteDataDir } from '../catalog/paths.js';
6
7
  import { stripFrontmatter } from '../catalog/skills-parse.js';
7
8
  import { parseFrontmatterRecord } from '../runtime/user-store.js';
8
9
  /**
@@ -68,11 +69,22 @@ class PanelResources {
68
69
  rawText,
69
70
  content: stripFrontmatter(rawText),
70
71
  path: file,
71
- description: typeof metadata.description === 'string' ? metadata.description : ''
72
+ description: typeof metadata.description === 'string' ? metadata.description : '',
73
+ ...(pluginRootOf(suite) === undefined ? {} : { suiteRoot: suite.root, suiteData: suiteDataDir(this.catalog.dataRoot, suite.sourceId, suite.id) })
72
74
  });
73
75
  }
74
76
  }
75
- return entries;
77
+ // One pass stamps each entry's own last modification: the panel reads every
78
+ // file's text anyway, and both origins get the same treatment here.
79
+ return await Promise.all(entries.map(async (entry) => {
80
+ try {
81
+ const stats = await stat(entry.path);
82
+ return { ...entry, updatedAt: new Date(stats.mtimeMs).toISOString() };
83
+ }
84
+ catch {
85
+ return entry;
86
+ }
87
+ }));
76
88
  }
77
89
  async get(id) {
78
90
  return (await this.list()).find(entry => (entry.id ?? entry.name) === id);
@@ -1,5 +1,7 @@
1
1
  /** Resolve read-only project roles from the calling session, never from tool-supplied paths. */
2
2
  import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
3
+ import { pluginRootOf } from '../catalog/plugin-variables.js';
4
+ import { suiteDataDir } from '../catalog/paths.js';
3
5
  import { parseAgentRole } from '../runtime/agent-role-router.js';
4
6
  export async function projectAgentRoles(catalog, parent) {
5
7
  const cwd = parent?.session?.header?.cwd;
@@ -10,6 +12,8 @@ export async function projectAgentRoles(catalog, parent) {
10
12
  for (const suite of snapshot.enabledSuites) {
11
13
  if (suite.activeSurfaces.agents === false)
12
14
  continue;
15
+ const suiteRoot = pluginRootOf(suite);
16
+ const suiteData = suiteRoot === undefined ? undefined : suiteDataDir(catalog.dataRoot, suite.sourceId, suite.id);
13
17
  for (const resource of suite.resources?.agents ?? (await defaultMarkdownResources(suite.root, 'agents'))) {
14
18
  const path = resource.file;
15
19
  const rawText = await resourceText(resource).catch(error => {
@@ -27,7 +31,9 @@ export async function projectAgentRoles(catalog, parent) {
27
31
  rawText,
28
32
  title: policy.title ?? resource.name.replace(/\.agent$/, ''),
29
33
  description: policy.description ?? `${suite.manifest.name}: ${resource.name}`,
30
- disabled: policy.disabled
34
+ disabled: policy.disabled,
35
+ ...(suiteRoot === undefined ? {} : { suiteRoot }),
36
+ ...(suiteData === undefined ? {} : { suiteData })
31
37
  });
32
38
  }
33
39
  catch {
@@ -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}`);
@@ -269,7 +284,7 @@ async function assertBoundedTree(root) {
269
284
  stack.push(path);
270
285
  continue;
271
286
  }
272
- let size = 0;
287
+ let size;
273
288
  try {
274
289
  size = (await stat(path)).size;
275
290
  }