@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
@@ -1,23 +1,19 @@
1
1
  import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
2
2
 
3
- const mockConnect = jest.fn();
4
- const mockDisconnect = jest.fn();
5
- let mockIsConnected = true;
6
- let mockLastError: Error | null = null;
3
+ const getServerState = jest.fn();
7
4
 
8
5
  mock.module("../mcp/client.js", () => ({
9
6
  McpClient: class {
10
- get isConnected() {
11
- return mockIsConnected;
7
+ constructor() {
8
+ throw new Error("list route must not construct an MCP client");
12
9
  }
13
- get lastError() {
14
- return mockLastError;
15
- }
16
- connect = mockConnect;
17
- disconnect = mockDisconnect;
18
10
  },
19
11
  }));
20
12
 
13
+ mock.module("../mcp/manager.js", () => ({
14
+ getMcpServerManager: () => ({ getServerState }),
15
+ }));
16
+
21
17
  import { setWorkspaceMcp } from "./helpers/set-workspace-mcp.js";
22
18
 
23
19
  // Seed the MCP server the list route reads from workspace mcp.json.
@@ -56,28 +52,22 @@ const listHandler = ROUTES.find(
56
52
  (r: { operationId: string }) => r.operationId === "internal_mcp_list",
57
53
  )!.handler;
58
54
 
59
- describe("checkServerHealth (via internal_mcp_list route)", () => {
55
+ describe("internal_mcp_list runtime status", () => {
60
56
  beforeEach(() => {
61
- mockConnect.mockReset();
62
- mockDisconnect.mockReset();
63
- mockIsConnected = true;
64
- mockLastError = null;
57
+ getServerState.mockReset();
65
58
  });
66
59
 
67
- test("returns connected when server connects successfully", async () => {
68
- mockConnect.mockResolvedValue(undefined);
69
- mockDisconnect.mockResolvedValue(undefined);
60
+ test("returns connected from the manager's recorded state", async () => {
61
+ getServerState.mockReturnValue("connected");
70
62
 
71
63
  const result = (await listHandler({})) as {
72
64
  servers: { status: string }[];
73
65
  };
74
66
  expect(result.servers[0].status).toBe("connected");
75
- expect(mockDisconnect).toHaveBeenCalled();
76
67
  });
77
68
 
78
- test("returns needs-auth when isConnected is false and no lastError", async () => {
79
- mockConnect.mockResolvedValue(undefined);
80
- mockIsConnected = false;
69
+ test("returns needs-auth from the manager's recorded state", async () => {
70
+ getServerState.mockReturnValue("needs-auth");
81
71
 
82
72
  const result = (await listHandler({})) as {
83
73
  servers: { status: string }[];
@@ -85,15 +75,25 @@ describe("checkServerHealth (via internal_mcp_list route)", () => {
85
75
  expect(result.servers[0].status).toBe("needs-auth");
86
76
  });
87
77
 
88
- test("returns error when connect fails with lastError", async () => {
89
- mockConnect.mockResolvedValue(undefined);
90
- mockIsConnected = false;
91
- mockLastError = new Error("Connection refused");
92
- mockDisconnect.mockResolvedValue(undefined);
78
+ test.each([undefined, "connecting", "error"])(
79
+ "returns the legacy error status for runtime state %s",
80
+ async (runtimeState) => {
81
+ getServerState.mockReturnValue(runtimeState);
93
82
 
94
- const result = (await listHandler({})) as {
95
- servers: { status: string }[];
96
- };
97
- expect(result.servers[0].status).toBe("error");
83
+ const result = (await listHandler({})) as {
84
+ servers: { status: string }[];
85
+ };
86
+ expect(result.servers[0].status).toBe("error");
87
+ },
88
+ );
89
+
90
+ test("repeated reads do not connect or spawn a configured server", async () => {
91
+ getServerState.mockReturnValue("connected");
92
+
93
+ await listHandler({});
94
+ await listHandler({});
95
+
96
+ expect(getServerState).toHaveBeenCalledTimes(2);
97
+ expect(getServerState).toHaveBeenCalledWith("test", "workspace");
98
98
  });
99
99
  });
@@ -8,38 +8,35 @@
8
8
  *
9
9
  * 1. A workspace entry of the same id wins. Getting precedence backwards
10
10
  * would let a plugin redirect a server the user configured by hand.
11
- * 2. A plugin server is never health-checked. `McpClient.connect` resolves
12
- * `mcp:<serverId>:headers` and `mcp:<serverId>:tokens` from the
13
- * credential store, and a plugin controls both its server key and its
14
- * URL, so probing one would send a workspace credential to an endpoint
15
- * the plugin chose whenever an id happens to match a stored key.
11
+ * 2. A plugin server is never health-checked as a side effect of listing.
12
+ * OAuth status is read from the plugin and endpoint-scoped credential
13
+ * identity, while workspace static headers remain isolated.
16
14
  */
17
15
 
18
16
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
19
17
  import { join } from "node:path";
20
18
  import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
21
19
 
22
- const mockConnect = jest.fn();
23
- const mockDisconnect = jest.fn();
24
- /** Server ids the route actually constructed an MCP client for. */
25
- const connectedServerIds: string[] = [];
20
+ const getServerState = jest.fn();
21
+ const hasMcpOAuthTokens = jest.fn(async (target: any) => {
22
+ if (target.source === "plugin" && target.url === "not a url") {
23
+ throw new TypeError("invalid URL");
24
+ }
25
+ return true;
26
+ });
26
27
 
27
28
  mock.module("../mcp/client.js", () => ({
28
29
  McpClient: class {
29
- constructor(serverId: string) {
30
- connectedServerIds.push(serverId);
31
- }
32
- get isConnected() {
33
- return true;
30
+ constructor() {
31
+ throw new Error("list route must not construct an MCP client");
34
32
  }
35
- get lastError() {
36
- return null;
37
- }
38
- connect = mockConnect;
39
- disconnect = mockDisconnect;
40
33
  },
41
34
  }));
42
35
 
36
+ mock.module("../mcp/manager.js", () => ({
37
+ getMcpServerManager: () => ({ getServerState }),
38
+ }));
39
+
43
40
  mock.module("../mcp/mcp-auth-orchestrator.js", () => ({
44
41
  orchestrateMcpOAuthConnect: async () => ({
45
42
  auth_url: "",
@@ -52,9 +49,7 @@ mock.module("../mcp/mcp-auth-state.js", () => ({
52
49
  }));
53
50
 
54
51
  mock.module("../mcp/mcp-oauth-provider.js", () => ({
55
- // Stand in for a credential store that holds tokens for every id, which
56
- // is the condition under which a leak would be observable.
57
- hasMcpOAuthTokens: async () => true,
52
+ hasMcpOAuthTokens,
58
53
  deleteMcpOAuthCredentials: async () => ({ ok: true, failedKeys: [] }),
59
54
  }));
60
55
 
@@ -108,6 +103,19 @@ function writePlugin(name: string, mcpJson: unknown): void {
108
103
  writeFileSync(join(dir, "mcp.json"), JSON.stringify(mcpJson));
109
104
  }
110
105
 
106
+ function writeStandardPlugin(name: string, mcpJson: unknown): void {
107
+ const dir = join(getWorkspacePluginsDir(), name);
108
+ mkdirSync(dir, { recursive: true });
109
+ writeFileSync(
110
+ join(dir, "plugin.json"),
111
+ JSON.stringify({
112
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
113
+ name,
114
+ }),
115
+ );
116
+ writeFileSync(join(dir, "mcp.json"), JSON.stringify(mcpJson));
117
+ }
118
+
111
119
  function unabyssManifest(): unknown {
112
120
  return {
113
121
  mcpServers: {
@@ -123,9 +131,8 @@ async function listServers(): Promise<ListedServer[]> {
123
131
 
124
132
  describe("internal_mcp_list, plugin-declared servers", () => {
125
133
  beforeEach(() => {
126
- mockConnect.mockReset();
127
- mockDisconnect.mockReset();
128
- connectedServerIds.length = 0;
134
+ getServerState.mockReset();
135
+ hasMcpOAuthTokens.mockClear();
129
136
  rmSync(getWorkspacePluginsDir(), { recursive: true, force: true });
130
137
  mkdirSync(getWorkspacePluginsDir(), { recursive: true });
131
138
  });
@@ -138,6 +145,14 @@ describe("internal_mcp_list, plugin-declared servers", () => {
138
145
  expect(ids).toContain("unabyss");
139
146
  });
140
147
 
148
+ test("a standard-only plugin keeps its server identity", async () => {
149
+ writeStandardPlugin("unabyss", unabyssManifest());
150
+
151
+ const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
152
+ expect(plugin.source).toBe("plugin");
153
+ expect(plugin.pluginName).toBe("unabyss");
154
+ });
155
+
141
156
  test("plugin servers are labelled with their origin, workspace servers are not", async () => {
142
157
  writePlugin("unabyss", unabyssManifest());
143
158
 
@@ -171,20 +186,36 @@ describe("internal_mcp_list, plugin-declared servers", () => {
171
186
  const servers = await listServers();
172
187
 
173
188
  expect(servers.find((s) => s.id === "unabyss")!.status).toEqual("declared");
174
- // The credential mocks above return a token and an Authorization header
175
- // for every id. Constructing a client for the plugin server is what
176
- // would ship them to the plugin-declared URL.
177
- expect(connectedServerIds).toContain("from-workspace");
178
- expect(connectedServerIds).not.toContain("unabyss");
189
+ expect(getServerState).toHaveBeenCalledWith("from-workspace", "workspace");
190
+ expect(getServerState).toHaveBeenCalledWith("unabyss", "plugin");
179
191
  });
180
192
 
181
- test("plugin servers report no assistant-owned auth even when the store has some", async () => {
193
+ test("plugin status uses only state recorded for the plugin source", async () => {
194
+ writePlugin("unabyss", unabyssManifest());
195
+ getServerState.mockImplementation(
196
+ (serverId: string, source: "workspace" | "plugin") =>
197
+ serverId === "unabyss" && source === "plugin" ? "connected" : undefined,
198
+ );
199
+
200
+ const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
201
+ expect(plugin.status).toBe("connected");
202
+ });
203
+
204
+ test("plugin servers report endpoint-scoped OAuth without static auth", async () => {
182
205
  writePlugin("unabyss", unabyssManifest());
183
206
 
184
207
  const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
185
- expect(plugin.hasOAuth).toBe(false);
208
+ expect(plugin.hasOAuth).toBe(true);
186
209
  expect(plugin.hasStaticAuth).toBe(false);
187
210
  expect(plugin.authType).toEqual("none");
211
+ expect(hasMcpOAuthTokens).toHaveBeenCalledWith(
212
+ expect.objectContaining({
213
+ source: "plugin",
214
+ pluginName: "unabyss",
215
+ serverKey: "unabyss",
216
+ url: "https://mcp.unabyss.com",
217
+ }),
218
+ );
188
219
  });
189
220
 
190
221
  test("workspace servers keep reporting their auth state", async () => {
@@ -225,6 +256,20 @@ describe("internal_mcp_list, plugin-declared servers", () => {
225
256
  expect(ids).toContain("from-workspace");
226
257
  });
227
258
 
259
+ test("an invalid plugin transport URL does not break the listing", async () => {
260
+ writePlugin("bad-url", {
261
+ mcpServers: {
262
+ remote: { type: "streamable-http", url: "not a url" },
263
+ },
264
+ });
265
+
266
+ const servers = await listServers();
267
+ expect(servers.find((s) => s.id === "bad-url__remote")?.hasOAuth).toBe(
268
+ false,
269
+ );
270
+ expect(servers.some((s) => s.id === "from-workspace")).toBe(true);
271
+ });
272
+
228
273
  test("no plugins installed leaves the listing unchanged", async () => {
229
274
  const servers = await listServers();
230
275
  expect(servers.every((s) => s.source === "workspace")).toBe(true);
@@ -32,10 +32,12 @@ const { RiskLevel } = await import("../permissions/types.js");
32
32
  type ServerSource = "workspace" | "plugin";
33
33
 
34
34
  function serverConfig(source: ServerSource) {
35
- return {
35
+ const base = {
36
36
  transport: { type: "stdio" as const, command: "echo", args: [] },
37
- source,
38
37
  };
38
+ return source === "workspace"
39
+ ? { ...base, source }
40
+ : { ...base, source, pluginName: "plugin", serverKey: "server" };
39
41
  }
40
42
 
41
43
  interface RiskAnnotations {
@@ -1165,6 +1165,33 @@ describe("POST oauth/managed-connect/start", () => {
1165
1165
  expect(result.connect_url).toBe("https://app.vellum.ai/connect/abc");
1166
1166
  });
1167
1167
 
1168
+ test("forwards tenant_host to the platform only when one is supplied", async () => {
1169
+ // Shopify's endpoints live on the merchant's host, which the platform
1170
+ // substitutes into its templates; other providers must not see the key.
1171
+ const bodies: Array<Record<string, unknown>> = [];
1172
+ mockFetchImpl = async (_path, init) => {
1173
+ bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
1174
+ return {
1175
+ ok: true,
1176
+ status: 200,
1177
+ json: async () => ({
1178
+ connect_url: "https://app.vellum.ai/connect/abc",
1179
+ }),
1180
+ text: async () => "",
1181
+ };
1182
+ };
1183
+ await getRoute("POST", "oauth/managed-connect/start").handler(
1184
+ makeArgs({
1185
+ body: { provider: "shopify", tenant_host: " my-store.myshopify.com " },
1186
+ }),
1187
+ );
1188
+ await getRoute("POST", "oauth/managed-connect/start").handler(
1189
+ makeArgs({ body: { provider: "google", tenant_host: " " } }),
1190
+ );
1191
+ expect(bodies[0]?.tenant_host).toBe("my-store.myshopify.com");
1192
+ expect(bodies[1]).not.toHaveProperty("tenant_host");
1193
+ });
1194
+
1168
1195
  test("raises InternalError when platform returns 401", async () => {
1169
1196
  mockFetchImpl = async () => ({
1170
1197
  ok: false,
@@ -1228,6 +1255,7 @@ describe("GET oauth/managed-connect/poll", () => {
1228
1255
  id: string;
1229
1256
  account_label: string | null;
1230
1257
  scopes_granted: string[];
1258
+ provider_params: Record<string, string>;
1231
1259
  }>;
1232
1260
  };
1233
1261
  expect(result.ok).toBe(true);
@@ -1236,6 +1264,7 @@ describe("GET oauth/managed-connect/poll", () => {
1236
1264
  id: "conn-1",
1237
1265
  account_label: "alice@example.com",
1238
1266
  scopes_granted: ["email"],
1267
+ provider_params: {},
1239
1268
  },
1240
1269
  ]);
1241
1270
  });
@@ -1248,4 +1277,30 @@ describe("GET oauth/managed-connect/poll", () => {
1248
1277
  ),
1249
1278
  ).rejects.toBeInstanceOf(BadRequestError);
1250
1279
  });
1280
+
1281
+ test("passes through the provider params a connection is scoped by", async () => {
1282
+ // QuickBooks pins the company (realm) the user picked to the connection;
1283
+ // a caller addressing /companyinfo/<realmId> needs it back.
1284
+ mockFetchImpl = async () => ({
1285
+ ok: true,
1286
+ status: 200,
1287
+ json: async () => [
1288
+ {
1289
+ id: "conn-qb",
1290
+ account_label: "Acme Widgets",
1291
+ scopes_granted: ["com.intuit.quickbooks.accounting"],
1292
+ provider_params: { realm_id: "9130357849012345" },
1293
+ },
1294
+ ],
1295
+ text: async () => "",
1296
+ });
1297
+ const result = (await getRoute("GET", "oauth/managed-connect/poll").handler(
1298
+ makeArgs({ queryParams: { provider: "quickbooks" } }),
1299
+ )) as {
1300
+ connections: Array<{ provider_params: Record<string, string> }>;
1301
+ };
1302
+ expect(result.connections[0]?.provider_params).toEqual({
1303
+ realm_id: "9130357849012345",
1304
+ });
1305
+ });
1251
1306
  });
@@ -291,10 +291,32 @@ describe("serializeProviderSummary", () => {
291
291
  supports_managed_mode: true,
292
292
  managed_service_is_paid: false,
293
293
  feature_flag: null,
294
+ tenant_host: null,
294
295
  acts_as: "user",
295
296
  });
296
297
  });
297
298
 
299
+ test("exposes the tenant host a per-tenant provider needs at connect", () => {
300
+ // Shopify's OAuth endpoints live on the merchant's own host, so a client
301
+ // has to collect it before the managed flow can start. The summary is the
302
+ // only provider metadata the web client reads, so it carries the ask.
303
+ const shopify = serializeProviderSummary(makeRow({ provider: "shopify" }))!;
304
+ expect(shopify.tenant_host).toEqual({
305
+ pattern: "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$",
306
+ label: "Shop domain",
307
+ placeholder: "your-store.myshopify.com",
308
+ });
309
+ expect(
310
+ new RegExp(shopify.tenant_host!.pattern).test("my-store.myshopify.com"),
311
+ ).toBe(true);
312
+ expect(new RegExp(shopify.tenant_host!.pattern).test("evil.com")).toBe(
313
+ false,
314
+ );
315
+ expect(
316
+ serializeProviderSummary(makeRow({ provider: "github" }))!.tenant_host,
317
+ ).toBeNull();
318
+ });
319
+
298
320
  test("names which sense of connected a provider represents", () => {
299
321
  // The provider key does not say, and the naming actively misleads:
300
322
  // `slack` is the user integration while its bot is `slack_channel`, but
@@ -178,6 +178,7 @@ describe("GET /v1/oauth/providers", () => {
178
178
  "supports_managed_mode",
179
179
  "managed_service_is_paid",
180
180
  "feature_flag",
181
+ "tenant_host",
181
182
  "acts_as",
182
183
  ];
183
184
 
@@ -1,6 +1,11 @@
1
1
  import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import { ACP_OAUTH_TOKEN_FIELD, ACP_SERVICE } from "../acp/acp-credentials.js";
3
+ import {
4
+ ACP_OAUTH_EXPIRES_AT_FIELD,
5
+ ACP_OAUTH_REFRESH_TOKEN_FIELD,
6
+ ACP_OAUTH_TOKEN_FIELD,
7
+ ACP_SERVICE,
8
+ } from "../acp/acp-credentials.js";
4
9
  import { credentialKey } from "../security/credential-key.js";
5
10
 
6
11
  let secureKeyStore: Record<string, string | undefined> = {};
@@ -32,6 +37,11 @@ mock.module("../oauth/manual-token-connection.js", () => ({
32
37
  syncManualTokenConnection: async () => {},
33
38
  }));
34
39
 
40
+ mock.module("../runtime/routes/credential-in-use.js", () => ({
41
+ assertCredentialNotInUse: () => [],
42
+ invalidateConnectionsAfterCredentialDelete: () => {},
43
+ }));
44
+
35
45
  afterAll(() => {
36
46
  mock.restore();
37
47
  });
@@ -42,6 +52,9 @@ import { ROUTES } from "../runtime/routes/secret-routes.js";
42
52
  const addRoute = ROUTES.find(
43
53
  (r) => r.method === "POST" && r.endpoint === "secrets",
44
54
  )!;
55
+ const deleteRoute = ROUTES.find(
56
+ (r) => r.method === "DELETE" && r.endpoint === "secrets",
57
+ )!;
45
58
 
46
59
  function addAcpOauthToken(value: string) {
47
60
  return addRoute.handler({
@@ -74,4 +87,49 @@ describe("secret routes ACP OAuth-token format guard", () => {
74
87
  secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD)],
75
88
  ).toBe("sk-ant-oat01-valid-oauth-token");
76
89
  });
90
+
91
+ test("stores a pasted Claude token without touching leftover refresh material", async () => {
92
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_REFRESH_TOKEN_FIELD)] =
93
+ "stale-refresh";
94
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_EXPIRES_AT_FIELD)] =
95
+ "111";
96
+
97
+ await addAcpOauthToken("sk-ant-oat01-pasted-token");
98
+
99
+ expect(
100
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD)],
101
+ ).toBe("sk-ant-oat01-pasted-token");
102
+ expect(
103
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_REFRESH_TOKEN_FIELD)],
104
+ ).toBe("stale-refresh");
105
+ expect(
106
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_EXPIRES_AT_FIELD)],
107
+ ).toBe("111");
108
+ });
109
+
110
+ test("deletes only the requested Claude access-token field", async () => {
111
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD)] =
112
+ "sk-ant-oat01-connected";
113
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_REFRESH_TOKEN_FIELD)] =
114
+ "refresh-leftover";
115
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_EXPIRES_AT_FIELD)] =
116
+ "111";
117
+
118
+ await deleteRoute.handler({
119
+ body: {
120
+ type: "credential",
121
+ name: `${ACP_SERVICE}:${ACP_OAUTH_TOKEN_FIELD}`,
122
+ },
123
+ });
124
+
125
+ expect(
126
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD)],
127
+ ).toBeUndefined();
128
+ expect(
129
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_REFRESH_TOKEN_FIELD)],
130
+ ).toBe("refresh-leftover");
131
+ expect(
132
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_EXPIRES_AT_FIELD)],
133
+ ).toBe("111");
134
+ });
77
135
  });
@@ -1054,6 +1054,16 @@ describe("ingress-dependent setup skills declare public-ingress intentionally",
1054
1054
  expect(includes ?? []).not.toContain("public-ingress");
1055
1055
  });
1056
1056
 
1057
+ test("telegram-setup documents the private-chat limitation", () => {
1058
+ const content = readFileSync(
1059
+ join(FIRST_PARTY_SKILLS_DIR, "telegram-setup", "SKILL.md"),
1060
+ "utf-8",
1061
+ );
1062
+ expect(content).toMatch(/private-chat only/i);
1063
+ expect(content).toMatch(/group, supergroup, and channel/i);
1064
+ expect(content).toMatch(/button taps/i);
1065
+ });
1066
+
1057
1067
  test("twilio-setup includes public-ingress", () => {
1058
1068
  const includes = readSkillIncludes(FIRST_PARTY_SKILLS_DIR, "twilio-setup");
1059
1069
  expect(includes).toBeDefined();
@@ -88,6 +88,7 @@ import {
88
88
  } from "../tools/registry.js";
89
89
  import { RiskLevel } from "../tools/tool-types.js";
90
90
  import type { Tool } from "../tools/types.js";
91
+ import { uiShowTool } from "../tools/ui-surface/definitions.js";
91
92
  import { asConversation } from "./helpers/mock-conversation.js";
92
93
 
93
94
  // ---------------------------------------------------------------------------
@@ -249,6 +250,56 @@ describe("createResolveToolsCallback — toolContextPin", () => {
249
250
  });
250
251
  }
251
252
 
253
+ test("Slack and desktop turns keep the same full ui_show schema while surfaces persist", () => {
254
+ projectedSkillToolNames = [];
255
+ const slackCaps = {
256
+ channel: "slack" as const,
257
+ dashboardCapable: false,
258
+ supportsDynamicUi: false,
259
+ supportsVoiceInput: false,
260
+ };
261
+ const desktopCaps = {
262
+ channel: "macos" as const,
263
+ dashboardCapable: true,
264
+ supportsDynamicUi: true,
265
+ supportsVoiceInput: true,
266
+ };
267
+ const liveSlackResolve = createResolveToolsCallback(
268
+ [uiShowTool],
269
+ makeProjectionCtx({
270
+ hasNoClient: false,
271
+ channelCapabilities: slackCaps,
272
+ }),
273
+ )!;
274
+ const clientlessSlackResolve = createResolveToolsCallback(
275
+ [uiShowTool],
276
+ clientlessExecutionCtx({
277
+ channelCapabilities: slackCaps,
278
+ }),
279
+ )!;
280
+ const liveDesktopResolve = createResolveToolsCallback(
281
+ [uiShowTool],
282
+ makeProjectionCtx({
283
+ hasNoClient: false,
284
+ channelCapabilities: desktopCaps,
285
+ }),
286
+ )!;
287
+
288
+ const [liveSlackUiShow] = liveSlackResolve(EMPTY_HISTORY);
289
+ const [clientlessSlackUiShow] = clientlessSlackResolve(EMPTY_HISTORY);
290
+ const [liveDesktopUiShow] = liveDesktopResolve(EMPTY_HISTORY);
291
+ expect(liveSlackUiShow).toEqual(clientlessSlackUiShow);
292
+ expect(liveSlackUiShow).toEqual(liveDesktopUiShow);
293
+ expect(
294
+ (
295
+ liveSlackUiShow.input_schema as {
296
+ properties: { surface_type: { enum: string[] } };
297
+ }
298
+ ).properties.surface_type.enum,
299
+ ).toContain("choice");
300
+ expect(liveSlackUiShow.description).toContain("dynamic_page");
301
+ });
302
+
252
303
  test("control: without a pin, a clientless fork drops client-gated tools but keeps host and UI tools", () => {
253
304
  projectedSkillToolNames = [];
254
305
  const resolve = createResolveToolsCallback(
@@ -2,10 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import type { ToolDefinition } from "../providers/types.js";
4
4
  import { ACTIVATION_MOMENT_PARAMS } from "../telemetry/activation-funnel.js";
5
- import {
6
- injectActivationMomentParam,
7
- projectUiToolsForChannel,
8
- } from "../tools/ui-surface/channel-variants.js";
5
+ import { injectActivationMomentParam } from "../tools/ui-surface/channel-variants.js";
9
6
  import {
10
7
  uiDismissTool,
11
8
  uiShowTool,
@@ -23,58 +20,6 @@ const defs: ToolDefinition[] = [
23
20
  },
24
21
  ];
25
22
 
26
- function surfaceTypeEnum(def: ToolDefinition): string[] {
27
- return (
28
- def.input_schema as { properties: { surface_type: { enum: string[] } } }
29
- ).properties.surface_type.enum;
30
- }
31
-
32
- describe("projectUiToolsForChannel", () => {
33
- test("returns the same array for channels without a variant", () => {
34
- for (const channel of [undefined, "macos", "telegram", "phone"]) {
35
- expect(projectUiToolsForChannel(defs, channel)).toBe(defs);
36
- }
37
- });
38
-
39
- test("slack gets a task_progress-only ui_show", () => {
40
- const projected = projectUiToolsForChannel(defs, "slack");
41
- const uiShow = projected.find((d) => d.name === "ui_show")!;
42
-
43
- expect(surfaceTypeEnum(uiShow)).toEqual(["card"]);
44
- expect(uiShow.description).toContain("task_progress");
45
- expect(uiShow.description).toContain(
46
- "the only surface this channel renders",
47
- );
48
- expect(uiShow.description).not.toContain("oauth_connect");
49
- expect(uiShow.description).not.toContain("dynamic_page");
50
- expect(uiShow.description!.length).toBeLessThan(
51
- uiShowTool.description.length / 2,
52
- );
53
- });
54
-
55
- test("slack projection keeps execute and leaves other tools untouched", () => {
56
- const projected = projectUiToolsForChannel(defs, "slack");
57
- const uiShow = projected.find((d) => d.name === "ui_show") as {
58
- execute?: unknown;
59
- };
60
-
61
- expect(uiShow.execute).toBe(uiShowTool.execute);
62
- expect(projected.find((d) => d.name === "ui_update")).toBe(uiUpdateTool);
63
- expect(projected.find((d) => d.name === "ui_dismiss")).toBe(uiDismissTool);
64
- expect(projected.find((d) => d.name === "bash")).toBe(defs[3]);
65
- });
66
-
67
- test("projection never mutates the shared definitions", () => {
68
- const descriptionBefore = uiShowTool.description;
69
- const schemaBefore = JSON.stringify(uiShowTool.input_schema);
70
-
71
- projectUiToolsForChannel(defs, "slack");
72
-
73
- expect(uiShowTool.description).toBe(descriptionBefore);
74
- expect(JSON.stringify(uiShowTool.input_schema)).toBe(schemaBefore);
75
- });
76
- });
77
-
78
23
  describe("injectActivationMomentParam", () => {
79
24
  function properties(def: ToolDefinition): Record<string, unknown> {
80
25
  return (def.input_schema as { properties: Record<string, unknown> })