@vellumai/assistant 0.12.2-staging.1 → 0.12.2-staging.3

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 (194) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  3. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  4. package/openapi.yaml +50 -4
  5. package/package.json +1 -1
  6. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  10. package/src/__tests__/conversation-error.test.ts +4 -1
  11. package/src/__tests__/conversation-runtime-assembly.test.ts +33 -3
  12. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  13. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  14. package/src/__tests__/credential-routes.test.ts +38 -0
  15. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  16. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  17. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  18. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  19. package/src/__tests__/host-cu-proxy.test.ts +185 -30
  20. package/src/__tests__/list-all-apps.test.ts +40 -8
  21. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  22. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  23. package/src/__tests__/mcp-health-check.test.ts +32 -32
  24. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  25. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  26. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  27. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  28. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  29. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  30. package/src/__tests__/skills.test.ts +10 -0
  31. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  32. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  33. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  34. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  35. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  36. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  37. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  38. package/src/acp/acp-claude-oauth.ts +328 -14
  39. package/src/acp/acp-credentials.ts +19 -0
  40. package/src/acp/claude-token-refresh.ts +150 -0
  41. package/src/acp/prepare-agent-env.ts +21 -6
  42. package/src/apps/app-store.ts +6 -5
  43. package/src/browser/operations.ts +2 -1
  44. package/src/browser/types.ts +7 -0
  45. package/src/browser/virtual-desktop-target.ts +34 -0
  46. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  47. package/src/calls/__tests__/voice-control-protocol.test.ts +69 -0
  48. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -0
  49. package/src/calls/progress-narration.ts +15 -2
  50. package/src/calls/voice-control-protocol.ts +132 -0
  51. package/src/calls/voice-session-bridge.ts +56 -26
  52. package/src/calls/voice-triage-escalate.ts +3 -3
  53. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  54. package/src/cli/commands/browser.help.ts +43 -0
  55. package/src/cli/commands/browser.ts +52 -8
  56. package/src/cli/commands/oauth/status.ts +5 -0
  57. package/src/cli/commands/plugins.ts +16 -5
  58. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  59. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  60. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  62. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  63. package/src/cli/lib/bundled-marketplace.json +13 -0
  64. package/src/cli/lib/install-from-github.ts +17 -18
  65. package/src/cli/lib/install-from-platform.ts +14 -9
  66. package/src/cli/lib/list-installed-plugins.ts +21 -35
  67. package/src/cli/lib/plugin-details.ts +21 -8
  68. package/src/cli/lib/uninstall-plugin.ts +59 -1
  69. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  70. package/src/config/bundled-skills/computer-use/SKILL.md +43 -4
  71. package/src/config/bundled-skills/computer-use/TOOLS.json +3 -3
  72. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  73. package/src/config/feature-flag-registry.json +11 -3
  74. package/src/config/loader.ts +1 -0
  75. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  76. package/src/config/schemas/mcp.ts +9 -7
  77. package/src/config/schemas/monitoring.ts +9 -0
  78. package/src/config/schemas/services.ts +10 -0
  79. package/src/config/schemas/voice.ts +2 -2
  80. package/src/config/skills.ts +13 -21
  81. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  82. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  83. package/src/daemon/conversation-client-surface.ts +26 -0
  84. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  85. package/src/daemon/conversation-surfaces.ts +6 -1
  86. package/src/daemon/conversation-tool-setup.ts +37 -43
  87. package/src/daemon/host-cu-proxy.ts +94 -31
  88. package/src/daemon/host-proxy-preactivation.ts +2 -0
  89. package/src/daemon/mcp-reload-service.ts +7 -0
  90. package/src/daemon/message-types/sync.ts +1 -0
  91. package/src/daemon/providers-setup.ts +4 -0
  92. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  93. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  94. package/src/desktop/desktop-automation-lease.ts +270 -0
  95. package/src/desktop/desktop-browser-client.test.ts +413 -0
  96. package/src/desktop/desktop-browser-client.ts +430 -0
  97. package/src/desktop/desktop-browser-cursor.ts +40 -0
  98. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  99. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  100. package/src/desktop/desktop-browser-operations.ts +130 -0
  101. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  102. package/src/desktop/desktop-chrome-session.ts +58 -8
  103. package/src/desktop/desktop-dependencies.test.ts +29 -0
  104. package/src/desktop/desktop-dependencies.ts +26 -5
  105. package/src/desktop/desktop-display.ts +9 -0
  106. package/src/desktop/desktop-panel-config.ts +14 -1
  107. package/src/desktop/desktop-session-manager.test.ts +86 -3
  108. package/src/desktop/desktop-session-manager.ts +144 -24
  109. package/src/desktop/desktop-stream-bridge.ts +1 -1
  110. package/src/desktop/virtual-desktop-feature.ts +18 -0
  111. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  112. package/src/i18n/__tests__/i18n.test.ts +9 -5
  113. package/src/i18n/messages.ts +13 -4
  114. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  115. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  116. package/src/ipc/assistant-server.ts +15 -1
  117. package/src/ipc/cli-client.ts +13 -5
  118. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +143 -0
  119. package/src/live-voice/__tests__/live-voice-events.test.ts +9 -7
  120. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  121. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  122. package/src/live-voice/__tests__/protocol.test.ts +47 -0
  123. package/src/live-voice/__tests__/session-controls.test.ts +113 -0
  124. package/src/live-voice/live-voice-metrics.ts +9 -0
  125. package/src/live-voice/live-voice-session.ts +88 -7
  126. package/src/live-voice/protocol.ts +69 -0
  127. package/src/live-voice/session-controls.ts +139 -0
  128. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  129. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  130. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  131. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  132. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  133. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  134. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  135. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  136. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  137. package/src/mcp/client.ts +64 -16
  138. package/src/mcp/credential-target.ts +105 -0
  139. package/src/mcp/effective-config.ts +42 -15
  140. package/src/mcp/manager.ts +61 -8
  141. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  142. package/src/mcp/mcp-auth-state.ts +38 -6
  143. package/src/mcp/mcp-oauth-provider.ts +101 -33
  144. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  145. package/src/monitoring/mount-watch.ts +348 -0
  146. package/src/monitoring/worker.ts +9 -0
  147. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  148. package/src/oauth/connection-resolver.test.ts +27 -0
  149. package/src/oauth/connection-resolver.ts +25 -1
  150. package/src/oauth/provider-serializer.ts +9 -0
  151. package/src/oauth/seed-providers.ts +121 -2
  152. package/src/onboarding/checkin-event.ts +2 -1
  153. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  154. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  155. package/src/plugins/external-plugin-loader.ts +49 -86
  156. package/src/plugins/installed-plugin-dirs.ts +6 -4
  157. package/src/plugins/mcp-servers.ts +9 -15
  158. package/src/plugins/mtime-cache.ts +2 -1
  159. package/src/plugins/user-loader.ts +2 -1
  160. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  161. package/src/providers/inference/endpoint-probe.ts +27 -12
  162. package/src/providers/openai/responses-provider.ts +9 -2
  163. package/src/providers/opencode/client.test.ts +167 -0
  164. package/src/providers/opencode/client.ts +73 -4
  165. package/src/runtime/AGENTS.md +4 -0
  166. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  167. package/src/runtime/http-server.ts +6 -6
  168. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  169. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  170. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  171. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  172. package/src/runtime/routes/app-management-routes.ts +17 -2
  173. package/src/runtime/routes/browser-context.ts +39 -0
  174. package/src/runtime/routes/browser-routes.ts +16 -47
  175. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  176. package/src/runtime/routes/credential-routes.ts +1 -4
  177. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  178. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  179. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  180. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  181. package/src/runtime/routes/oauth-providers.ts +7 -0
  182. package/src/runtime/routes/plugins-routes.ts +14 -1
  183. package/src/runtime/sync/resource-sync-events.ts +4 -0
  184. package/src/tools/browser/browser-execution.ts +44 -13
  185. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  186. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  187. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  188. package/src/tools/computer-use/definitions.ts +3 -4
  189. package/src/tools/ui-surface/channel-variants.ts +10 -59
  190. package/src/util/abort-reasons.ts +12 -1
  191. package/src/util/plugin-manifest.ts +203 -0
  192. package/src/watch/watch-retro.ts +6 -8
  193. package/src/desktop/desktop-feature.test.ts +0 -18
  194. package/src/desktop/desktop-feature.ts +0 -19
@@ -15,14 +15,27 @@
15
15
  * connection.
16
16
  */
17
17
  type McpAuthState =
18
- | { status: "pending"; authUrl: string; attemptId: string; expiresAt: number }
18
+ | {
19
+ status: "pending";
20
+ authUrl: string;
21
+ attemptId: string;
22
+ targetKey: string;
23
+ expiresAt: number;
24
+ }
19
25
  | {
20
26
  status: "complete";
21
27
  serverId: string;
22
28
  attemptId: string;
29
+ targetKey: string;
23
30
  completedAt: number;
24
31
  }
25
- | { status: "error"; error: string; attemptId: string; failedAt: number };
32
+ | {
33
+ status: "error";
34
+ error: string;
35
+ attemptId: string;
36
+ targetKey: string;
37
+ failedAt: number;
38
+ };
26
39
 
27
40
  const activeMcpAuthFlows = new Map<string, McpAuthState>();
28
41
 
@@ -41,11 +54,13 @@ export function setMcpAuthPending(
41
54
  serverId: string,
42
55
  authUrl: string,
43
56
  attemptId: string,
57
+ targetKey = serverId,
44
58
  ): void {
45
59
  activeMcpAuthFlows.set(serverId, {
46
60
  status: "pending",
47
61
  authUrl,
48
62
  attemptId,
63
+ targetKey,
49
64
  expiresAt: Date.now() + PENDING_TTL_MS,
50
65
  });
51
66
  }
@@ -59,15 +74,20 @@ export function setMcpAuthPending(
59
74
  export function setMcpAuthComplete(
60
75
  serverId: string,
61
76
  attemptId: string,
77
+ targetKey = serverId,
62
78
  ): boolean {
63
79
  const current = activeMcpAuthFlows.get(serverId);
64
- if (current && current.attemptId !== attemptId) {
80
+ if (
81
+ current &&
82
+ (current.attemptId !== attemptId || current.targetKey !== targetKey)
83
+ ) {
65
84
  return false; // superseded
66
85
  }
67
86
  activeMcpAuthFlows.set(serverId, {
68
87
  status: "complete",
69
88
  serverId,
70
89
  attemptId,
90
+ targetKey,
71
91
  completedAt: Date.now(),
72
92
  });
73
93
  return true;
@@ -81,15 +101,20 @@ export function setMcpAuthError(
81
101
  serverId: string,
82
102
  error: string,
83
103
  attemptId: string,
104
+ targetKey = serverId,
84
105
  ): boolean {
85
106
  const current = activeMcpAuthFlows.get(serverId);
86
- if (current && current.attemptId !== attemptId) {
107
+ if (
108
+ current &&
109
+ (current.attemptId !== attemptId || current.targetKey !== targetKey)
110
+ ) {
87
111
  return false; // superseded
88
112
  }
89
113
  activeMcpAuthFlows.set(serverId, {
90
114
  status: "error",
91
115
  error,
92
116
  attemptId,
117
+ targetKey,
93
118
  failedAt: Date.now(),
94
119
  });
95
120
  return true;
@@ -100,7 +125,10 @@ export function setMcpAuthError(
100
125
  * expired entries on every read so a long-polling CLI can never observe
101
126
  * a stale flow past its TTL.
102
127
  */
103
- export function getMcpAuthState(serverId: string): McpAuthState | null {
128
+ export function getMcpAuthState(
129
+ serverId: string,
130
+ targetKey?: string,
131
+ ): McpAuthState | null {
104
132
  const now = Date.now();
105
133
  for (const [id, state] of activeMcpAuthFlows) {
106
134
  if (state.status === "pending" && now > state.expiresAt) {
@@ -117,5 +145,9 @@ export function getMcpAuthState(serverId: string): McpAuthState | null {
117
145
  activeMcpAuthFlows.delete(id);
118
146
  }
119
147
  }
120
- return activeMcpAuthFlows.get(serverId) ?? null;
148
+ const state = activeMcpAuthFlows.get(serverId) ?? null;
149
+ if (state && targetKey !== undefined && state.targetKey !== targetKey) {
150
+ return null;
151
+ }
152
+ return state;
121
153
  }
@@ -38,23 +38,16 @@ import {
38
38
  import { openInHostBrowser } from "../util/browser.js";
39
39
  import { getLogger } from "../util/logger.js";
40
40
  import { APP_VERSION } from "../version.js";
41
+ import {
42
+ MCP_OAUTH_CREDENTIAL_LEAVES,
43
+ mcpOAuthCredentialKey,
44
+ type McpOAuthCredentialTarget,
45
+ pluginMcpOAuthCredentialPrefix,
46
+ workspaceMcpOAuthCredentialTarget,
47
+ } from "./credential-target.js";
41
48
 
42
49
  const log = getLogger("mcp-oauth");
43
50
 
44
- // Credential store key helpers
45
- function tokensKey(serverId: string): string {
46
- return `mcp:${serverId}:tokens`;
47
- }
48
- function clientInfoKey(serverId: string): string {
49
- return `mcp:${serverId}:client_info`;
50
- }
51
- function discoveryKey(serverId: string): string {
52
- return `mcp:${serverId}:discovery`;
53
- }
54
- function clientBindingKey(serverId: string): string {
55
- return `mcp:${serverId}:client_binding`;
56
- }
57
-
58
51
  /**
59
52
  * Logo shown on an authorization server's consent screen.
60
53
  *
@@ -91,12 +84,15 @@ export interface McpOAuthProviderOptions {
91
84
  * URL to the IPC caller (CLI / web client).
92
85
  */
93
86
  onAuthorizationUrl?: (url: string) => void;
87
+ /** Credential identity. Defaults to the legacy workspace server id. */
88
+ credentialTarget?: McpOAuthCredentialTarget;
94
89
  }
95
90
 
96
91
  export class McpOAuthProvider implements OAuthClientProvider {
97
92
  private readonly serverId: string;
98
93
  private readonly serverUrl: string;
99
94
  private readonly interactive: boolean;
95
+ private readonly credentialTarget: McpOAuthCredentialTarget;
100
96
  private _codeVerifier: string | undefined;
101
97
  private _state: string | undefined;
102
98
  private _redirectUrl: string | undefined;
@@ -121,6 +117,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
121
117
  this.serverUrl = serverUrl;
122
118
  this.interactive = interactive;
123
119
  this._onAuthorizationUrl = options.onAuthorizationUrl;
120
+ this.credentialTarget =
121
+ options.credentialTarget ?? workspaceMcpOAuthCredentialTarget(serverId);
124
122
  }
125
123
 
126
124
  // --- redirectUrl ---
@@ -167,7 +165,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
167
165
  // --- Tokens ---
168
166
 
169
167
  async tokens(): Promise<OAuthTokens | undefined> {
170
- const raw = await getSecureKeyAsync(tokensKey(this.serverId));
168
+ const raw = await getSecureKeyAsync(
169
+ mcpOAuthCredentialKey(this.credentialTarget, "tokens"),
170
+ );
171
171
  if (!raw) {
172
172
  return undefined;
173
173
  }
@@ -191,7 +191,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
191
191
  // the previous refresh_token when the incoming response omits one.
192
192
  let toPersist: OAuthTokens = tokens;
193
193
  if (!tokens.refresh_token) {
194
- const previous = await getSecureKeyAsync(tokensKey(this.serverId));
194
+ const previous = await getSecureKeyAsync(
195
+ mcpOAuthCredentialKey(this.credentialTarget, "tokens"),
196
+ );
195
197
  if (previous) {
196
198
  try {
197
199
  const parsed = JSON.parse(previous) as OAuthTokens;
@@ -204,7 +206,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
204
206
  }
205
207
  }
206
208
  const ok = await setSecureKeyAsync(
207
- tokensKey(this.serverId),
209
+ mcpOAuthCredentialKey(this.credentialTarget, "tokens"),
208
210
  JSON.stringify(toPersist),
209
211
  );
210
212
  if (!ok) {
@@ -263,7 +265,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
263
265
  * fronts the resource.
264
266
  */
265
267
  async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
266
- const raw = await getSecureKeyAsync(clientInfoKey(this.serverId));
268
+ const raw = await getSecureKeyAsync(
269
+ mcpOAuthCredentialKey(this.credentialTarget, "client_info"),
270
+ );
267
271
  if (!raw) {
268
272
  return undefined;
269
273
  }
@@ -294,7 +298,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
294
298
  info: OAuthClientInformationMixed,
295
299
  ): Promise<void> {
296
300
  const ok = await setSecureKeyAsync(
297
- clientInfoKey(this.serverId),
301
+ mcpOAuthCredentialKey(this.credentialTarget, "client_info"),
298
302
  JSON.stringify(info),
299
303
  );
300
304
  if (!ok) {
@@ -313,7 +317,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
313
317
  redirectUri: this._redirectUrl,
314
318
  };
315
319
  const boundOk = await setSecureKeyAsync(
316
- clientBindingKey(this.serverId),
320
+ mcpOAuthCredentialKey(this.credentialTarget, "client_binding"),
317
321
  JSON.stringify(binding),
318
322
  );
319
323
  if (!boundOk) {
@@ -349,7 +353,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
349
353
  * redirect check already covers the case this plugin actually changes.
350
354
  */
351
355
  private async describeStaleBinding(): Promise<string | null> {
352
- const raw = await getSecureKeyAsync(clientBindingKey(this.serverId));
356
+ const raw = await getSecureKeyAsync(
357
+ mcpOAuthCredentialKey(this.credentialTarget, "client_binding"),
358
+ );
353
359
  if (!raw) {
354
360
  // Registered before the binding was recorded. Keep it: discarding
355
361
  // every pre-existing registration would remake all of them at once.
@@ -410,7 +416,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
410
416
  // --- Discovery State ---
411
417
 
412
418
  async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
413
- const raw = await getSecureKeyAsync(discoveryKey(this.serverId));
419
+ const raw = await getSecureKeyAsync(
420
+ mcpOAuthCredentialKey(this.credentialTarget, "discovery"),
421
+ );
414
422
  if (!raw) {
415
423
  return undefined;
416
424
  }
@@ -423,7 +431,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
423
431
 
424
432
  async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
425
433
  const ok = await setSecureKeyAsync(
426
- discoveryKey(this.serverId),
434
+ mcpOAuthCredentialKey(this.credentialTarget, "discovery"),
427
435
  JSON.stringify(state),
428
436
  );
429
437
  if (!ok) {
@@ -498,7 +506,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
498
506
  );
499
507
 
500
508
  if (scope === "all" || scope === "tokens") {
501
- const result = await deleteSecureKeyAsync(tokensKey(this.serverId));
509
+ const result = await deleteSecureKeyAsync(
510
+ mcpOAuthCredentialKey(this.credentialTarget, "tokens"),
511
+ );
502
512
  if (result === "error") {
503
513
  log.warn(
504
514
  { serverId: this.serverId },
@@ -512,7 +522,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
512
522
  }
513
523
  }
514
524
  if (scope === "all" || scope === "client") {
515
- const result = await deleteSecureKeyAsync(clientInfoKey(this.serverId));
525
+ const result = await deleteSecureKeyAsync(
526
+ mcpOAuthCredentialKey(this.credentialTarget, "client_info"),
527
+ );
516
528
  if (result === "error") {
517
529
  log.warn(
518
530
  { serverId: this.serverId },
@@ -527,14 +539,18 @@ export class McpOAuthProvider implements OAuthClientProvider {
527
539
  // The binding describes the registration being dropped, so it goes
528
540
  // with it. Leaving it would let a later registration inherit the
529
541
  // previous one's issuer and redirect URI.
530
- await deleteSecureKeyAsync(clientBindingKey(this.serverId));
542
+ await deleteSecureKeyAsync(
543
+ mcpOAuthCredentialKey(this.credentialTarget, "client_binding"),
544
+ );
531
545
  }
532
546
  if (scope === "all" || scope === "verifier") {
533
547
  this._codeVerifier = undefined;
534
548
  this._state = undefined;
535
549
  }
536
550
  if (scope === "all" || scope === "discovery") {
537
- const result = await deleteSecureKeyAsync(discoveryKey(this.serverId));
551
+ const result = await deleteSecureKeyAsync(
552
+ mcpOAuthCredentialKey(this.credentialTarget, "discovery"),
553
+ );
538
554
  if (result === "error") {
539
555
  log.warn(
540
556
  { serverId: this.serverId },
@@ -617,8 +633,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
617
633
  /**
618
634
  * Check whether OAuth tokens exist in the credential store for a server.
619
635
  */
620
- export async function hasMcpOAuthTokens(serverId: string): Promise<boolean> {
621
- const raw = await getSecureKeyAsync(tokensKey(serverId));
636
+ export async function hasMcpOAuthTokens(
637
+ target: McpOAuthCredentialTarget,
638
+ ): Promise<boolean> {
639
+ const raw = await getSecureKeyAsync(mcpOAuthCredentialKey(target, "tokens"));
622
640
  return raw != null && raw.length > 0;
623
641
  }
624
642
 
@@ -631,10 +649,14 @@ export async function deleteMcpOAuthCredentials(
631
649
  ): Promise<{ ok: boolean; failedKeys: string[] }> {
632
650
  const [tokensResult, clientResult, bindingResult, discoveryResult] =
633
651
  await Promise.all([
634
- deleteSecureKeyAsync(tokensKey(serverId)),
635
- deleteSecureKeyAsync(clientInfoKey(serverId)),
636
- deleteSecureKeyAsync(clientBindingKey(serverId)),
637
- deleteSecureKeyAsync(discoveryKey(serverId)),
652
+ ...MCP_OAUTH_CREDENTIAL_LEAVES.map((leaf) =>
653
+ deleteSecureKeyAsync(
654
+ mcpOAuthCredentialKey(
655
+ workspaceMcpOAuthCredentialTarget(serverId),
656
+ leaf,
657
+ ),
658
+ ),
659
+ ),
638
660
  ]);
639
661
  const results = [
640
662
  { key: "tokens", result: tokensResult },
@@ -660,3 +682,49 @@ export async function deleteMcpOAuthCredentials(
660
682
  );
661
683
  return { ok, failedKeys };
662
684
  }
685
+
686
+ export interface PluginMcpOAuthCleanupResult {
687
+ readonly ok: boolean;
688
+ readonly unreachable: boolean;
689
+ readonly matchedKeys: string[];
690
+ readonly failedKeys: string[];
691
+ }
692
+
693
+ /** Delete every endpoint-scoped MCP OAuth record owned by one plugin. */
694
+ export async function deletePluginMcpOAuthCredentials(
695
+ pluginName: string,
696
+ ): Promise<PluginMcpOAuthCleanupResult> {
697
+ const { listSecureKeysAsync } = await import("../security/secure-keys.js");
698
+ const listed = await listSecureKeysAsync();
699
+ if (listed.unreachable) {
700
+ return {
701
+ ok: false,
702
+ unreachable: true,
703
+ matchedKeys: [],
704
+ failedKeys: [],
705
+ };
706
+ }
707
+
708
+ const prefix = pluginMcpOAuthCredentialPrefix(pluginName);
709
+ const matchedKeys = listed.accounts.filter((key) => key.startsWith(prefix));
710
+ const deletionResults = await Promise.all(
711
+ matchedKeys.map(async (key) => {
712
+ try {
713
+ return { key, result: await deleteSecureKeyAsync(key) };
714
+ } catch {
715
+ return { key, result: "error" as const };
716
+ }
717
+ }),
718
+ );
719
+ const failedKeys = deletionResults
720
+ .filter(({ result }) => result === "error")
721
+ .map(({ key }) => key);
722
+ const ok = failedKeys.length === 0;
723
+ if (!ok) {
724
+ log.warn(
725
+ { pluginName, failedKeys },
726
+ "Some plugin MCP OAuth credentials could not be deleted",
727
+ );
728
+ }
729
+ return { ok, unreachable: false, matchedKeys, failedKeys };
730
+ }
@@ -0,0 +1,236 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ collectMountSnapshot,
5
+ diffMountSnapshots,
6
+ isWatchedMount,
7
+ mountDiffIsEmpty,
8
+ type MountInfoEntry,
9
+ type MountSnapshot,
10
+ parseMountinfo,
11
+ type PathFsSnapshot,
12
+ unescapeMountField,
13
+ } from "../mount-watch.js";
14
+
15
+ const SAMPLE_MOUNTINFO = `22 1 0:21 / / rw,relatime - overlay overlay rw,lowerdir=/a,upperdir=/b,workdir=/c
16
+ 36 22 0:26 / /workspace rw,relatime - virtiofs kataShared rw
17
+ 48 22 0:27 / /data rw,relatime - virtiofs kataShared rw
18
+ 25 22 0:23 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw
19
+ `;
20
+
21
+ function entry(
22
+ fields: Partial<MountInfoEntry> & Pick<MountInfoEntry, "mountPoint" | "fsType">,
23
+ ): MountInfoEntry {
24
+ return {
25
+ mountId: fields.mountId ?? "1",
26
+ parentId: fields.parentId ?? "0",
27
+ majorMinor: fields.majorMinor ?? "0:1",
28
+ root: fields.root ?? "/",
29
+ mountPoint: fields.mountPoint,
30
+ mountOptions: fields.mountOptions ?? "rw,relatime",
31
+ fsType: fields.fsType,
32
+ source: fields.source ?? "kataShared",
33
+ superOptions: fields.superOptions ?? "rw",
34
+ };
35
+ }
36
+
37
+ function pathFs(
38
+ fields: Partial<PathFsSnapshot> & Pick<PathFsSnapshot, "path">,
39
+ ): PathFsSnapshot {
40
+ return {
41
+ path: fields.path,
42
+ type: fields.type ?? 0x65735546,
43
+ blockSize: fields.blockSize ?? 4096,
44
+ blocks: fields.blocks ?? 12_884_901,
45
+ files: fields.files ?? 1_000_000,
46
+ ...(fields.error != null ? { error: fields.error } : {}),
47
+ };
48
+ }
49
+
50
+ function snapshot(partial: Partial<MountSnapshot>): MountSnapshot {
51
+ return {
52
+ mounts: partial.mounts ?? [],
53
+ paths: partial.paths ?? [],
54
+ };
55
+ }
56
+
57
+ describe("unescapeMountField", () => {
58
+ test("decodes octal-escaped spaces", () => {
59
+ expect(unescapeMountField("/workspace/my\\040dir")).toBe("/workspace/my dir");
60
+ });
61
+ });
62
+
63
+ describe("parseMountinfo", () => {
64
+ test("reads mount point, fstype, and source", () => {
65
+ const parsed = parseMountinfo(SAMPLE_MOUNTINFO);
66
+ expect(parsed).toEqual([
67
+ entry({
68
+ mountId: "22",
69
+ parentId: "1",
70
+ majorMinor: "0:21",
71
+ mountPoint: "/",
72
+ fsType: "overlay",
73
+ source: "overlay",
74
+ superOptions: "rw,lowerdir=/a,upperdir=/b,workdir=/c",
75
+ }),
76
+ entry({
77
+ mountId: "36",
78
+ parentId: "22",
79
+ majorMinor: "0:26",
80
+ mountPoint: "/workspace",
81
+ fsType: "virtiofs",
82
+ }),
83
+ entry({
84
+ mountId: "48",
85
+ parentId: "22",
86
+ majorMinor: "0:27",
87
+ mountPoint: "/data",
88
+ fsType: "virtiofs",
89
+ }),
90
+ entry({
91
+ mountId: "25",
92
+ parentId: "22",
93
+ majorMinor: "0:23",
94
+ mountPoint: "/proc",
95
+ mountOptions: "rw,nosuid,nodev,noexec,relatime",
96
+ fsType: "proc",
97
+ source: "proc",
98
+ }),
99
+ ]);
100
+ });
101
+
102
+ test("skips malformed lines", () => {
103
+ expect(parseMountinfo("not-a-mount-line\n")).toEqual([]);
104
+ });
105
+ });
106
+
107
+ describe("isWatchedMount", () => {
108
+ test("keeps virtiofs regardless of mount point", () => {
109
+ expect(
110
+ isWatchedMount(entry({ mountPoint: "/secret-bind", fsType: "virtiofs" })),
111
+ ).toBe(true);
112
+ });
113
+
114
+ test("keeps /workspace and /data even when they are overlay", () => {
115
+ expect(
116
+ isWatchedMount(entry({ mountPoint: "/workspace", fsType: "overlay" })),
117
+ ).toBe(true);
118
+ expect(isWatchedMount(entry({ mountPoint: "/data", fsType: "overlay" }))).toBe(
119
+ true,
120
+ );
121
+ });
122
+
123
+ test("drops proc and the root overlay", () => {
124
+ expect(isWatchedMount(entry({ mountPoint: "/proc", fsType: "proc" }))).toBe(
125
+ false,
126
+ );
127
+ expect(isWatchedMount(entry({ mountPoint: "/", fsType: "overlay" }))).toBe(
128
+ false,
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("diffMountSnapshots", () => {
134
+ test("stays empty when the table is unchanged", () => {
135
+ const current = snapshot({
136
+ mounts: [entry({ mountPoint: "/workspace", fsType: "virtiofs" })],
137
+ paths: [pathFs({ path: "/workspace" })],
138
+ });
139
+ expect(mountDiffIsEmpty(diffMountSnapshots(current, current))).toBe(true);
140
+ });
141
+
142
+ test("reports a dropped virtiofs bind as removed", () => {
143
+ const previous = snapshot({
144
+ mounts: [
145
+ entry({ mountPoint: "/workspace", fsType: "virtiofs" }),
146
+ entry({ mountPoint: "/data", fsType: "virtiofs" }),
147
+ ],
148
+ });
149
+ const current = snapshot({
150
+ mounts: [entry({ mountPoint: "/data", fsType: "virtiofs" })],
151
+ });
152
+ const diff = diffMountSnapshots(previous, current);
153
+ expect(diff.removed.map((row) => row.mountPoint)).toEqual(["/workspace"]);
154
+ expect(diff.added).toEqual([]);
155
+ expect(diff.changed).toEqual([]);
156
+ });
157
+
158
+ test("reports a fstype swap at the same mount point as changed", () => {
159
+ const previous = snapshot({
160
+ mounts: [entry({ mountPoint: "/workspace", fsType: "virtiofs" })],
161
+ });
162
+ const current = snapshot({
163
+ mounts: [entry({ mountPoint: "/workspace", fsType: "overlay", source: "overlay" })],
164
+ });
165
+ const diff = diffMountSnapshots(previous, current);
166
+ expect(diff.changed).toHaveLength(1);
167
+ expect(diff.changed[0].previous.fsType).toBe("virtiofs");
168
+ expect(diff.changed[0].current.fsType).toBe("overlay");
169
+ });
170
+
171
+ test("reports a statfs size collapse on a watched path", () => {
172
+ const previous = snapshot({
173
+ paths: [pathFs({ path: "/workspace", blocks: 12_884_901 })],
174
+ });
175
+ const current = snapshot({
176
+ paths: [pathFs({ path: "/workspace", blocks: 25_165_824, type: 0x794c7630 })],
177
+ });
178
+ const diff = diffMountSnapshots(previous, current);
179
+ expect(diff.pathChanges).toHaveLength(1);
180
+ expect(diff.pathChanges[0].previous.blocks).toBe(12_884_901);
181
+ expect(diff.pathChanges[0].current.blocks).toBe(25_165_824);
182
+ });
183
+ });
184
+
185
+ describe("collectMountSnapshot", () => {
186
+ test("returns null on Windows and macOS", () => {
187
+ expect(collectMountSnapshot({ platform: "win32" })).toBeNull();
188
+ expect(collectMountSnapshot({ platform: "darwin" })).toBeNull();
189
+ });
190
+
191
+ test("filters to assistant-container volume mounts", () => {
192
+ const snapshot = collectMountSnapshot({
193
+ platform: "linux",
194
+ readMountinfo: () => SAMPLE_MOUNTINFO,
195
+ statPath: (path) => pathFs({ path }),
196
+ watchedMountPoints: ["/workspace", "/data"],
197
+ });
198
+ expect(snapshot).not.toBeNull();
199
+ expect(snapshot?.mounts.map((row) => row.mountPoint)).toEqual([
200
+ "/data",
201
+ "/workspace",
202
+ ]);
203
+ expect(snapshot?.paths.map((row) => row.path)).toEqual([
204
+ "/workspace",
205
+ "/data",
206
+ ]);
207
+ });
208
+
209
+ test("stats watched paths even when they are missing from the host", () => {
210
+ const snapshot = collectMountSnapshot({
211
+ platform: "linux",
212
+ readMountinfo: () => SAMPLE_MOUNTINFO,
213
+ statPath: (path) =>
214
+ pathFs({ path, type: null, blockSize: null, blocks: null, files: null, error: "statfs-failed" }),
215
+ watchedMountPoints: ["/workspace", "/data"],
216
+ });
217
+ expect(snapshot?.paths).toEqual([
218
+ pathFs({
219
+ path: "/workspace",
220
+ type: null,
221
+ blockSize: null,
222
+ blocks: null,
223
+ files: null,
224
+ error: "statfs-failed",
225
+ }),
226
+ pathFs({
227
+ path: "/data",
228
+ type: null,
229
+ blockSize: null,
230
+ blocks: null,
231
+ files: null,
232
+ error: "statfs-failed",
233
+ }),
234
+ ]);
235
+ });
236
+ });