@vellumai/assistant 0.12.2-staging.2 → 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.
- package/docs/desktop-browser-cli.md +33 -0
- package/openapi.yaml +17 -5
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +279 -0
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/config-sounds-sync.test.ts +23 -0
- package/src/__tests__/conversation-error.test.ts +4 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
- package/src/__tests__/external-plugin-loader.test.ts +52 -0
- package/src/__tests__/headless-browser-mode.test.ts +48 -0
- package/src/__tests__/host-cu-proxy.test.ts +116 -18
- package/src/__tests__/list-all-apps.test.ts +40 -8
- package/src/__tests__/mcp-auth-routes.test.ts +99 -0
- package/src/__tests__/mcp-client-auth.test.ts +54 -0
- package/src/__tests__/mcp-health-check.test.ts +32 -32
- package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
- package/src/__tests__/skills.test.ts +10 -0
- package/src/__tests__/user-plugin-loader.test.ts +21 -0
- package/src/apps/app-store.ts +6 -5
- package/src/browser/operations.ts +2 -1
- package/src/browser/types.ts +7 -0
- package/src/browser/virtual-desktop-target.ts +34 -0
- package/src/calls/__tests__/progress-narration.test.ts +23 -0
- package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
- package/src/calls/progress-narration.ts +15 -2
- package/src/calls/voice-control-protocol.ts +34 -4
- package/src/calls/voice-session-bridge.ts +23 -0
- package/src/cli/commands/__tests__/browser.test.ts +84 -3
- package/src/cli/commands/browser.help.ts +43 -0
- package/src/cli/commands/browser.ts +52 -8
- package/src/cli/commands/plugins.ts +16 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
- package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
- package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
- package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/install-from-github.ts +17 -18
- package/src/cli/lib/install-from-platform.ts +14 -9
- package/src/cli/lib/list-installed-plugins.ts +21 -35
- package/src/cli/lib/plugin-details.ts +21 -8
- package/src/cli/lib/uninstall-plugin.ts +59 -1
- package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
- package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
- package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
- package/src/config/feature-flag-registry.json +2 -2
- package/src/config/schemas/__tests__/voice.test.ts +2 -2
- package/src/config/schemas/mcp.ts +9 -7
- package/src/config/schemas/monitoring.ts +9 -0
- package/src/config/schemas/voice.ts +2 -2
- package/src/config/skills.ts +13 -21
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
- package/src/daemon/conversation-client-surface.ts +26 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -1
- package/src/daemon/conversation-tool-setup.ts +31 -29
- package/src/daemon/host-cu-proxy.ts +51 -26
- package/src/daemon/host-proxy-preactivation.ts +2 -0
- package/src/daemon/mcp-reload-service.ts +7 -0
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/providers-setup.ts +4 -0
- package/src/desktop/__tests__/fake-desktop.ts +1 -0
- package/src/desktop/desktop-automation-lease.test.ts +208 -0
- package/src/desktop/desktop-automation-lease.ts +270 -0
- package/src/desktop/desktop-browser-client.test.ts +413 -0
- package/src/desktop/desktop-browser-client.ts +430 -0
- package/src/desktop/desktop-browser-cursor.ts +40 -0
- package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
- package/src/desktop/desktop-browser-endpoint.ts +145 -0
- package/src/desktop/desktop-browser-operations.ts +130 -0
- package/src/desktop/desktop-chrome-session.test.ts +48 -0
- package/src/desktop/desktop-chrome-session.ts +58 -8
- package/src/desktop/desktop-dependencies.test.ts +29 -0
- package/src/desktop/desktop-dependencies.ts +26 -5
- package/src/desktop/desktop-display.ts +9 -0
- package/src/desktop/desktop-panel-config.ts +14 -1
- package/src/desktop/desktop-session-manager.test.ts +86 -3
- package/src/desktop/desktop-session-manager.ts +144 -24
- package/src/desktop/desktop-stream-bridge.ts +1 -1
- package/src/desktop/virtual-desktop-feature.ts +18 -0
- package/src/desktop/virtual-desktop-platform.test.ts +61 -0
- package/src/i18n/__tests__/i18n.test.ts +9 -5
- package/src/i18n/messages.ts +13 -4
- package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
- package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
- package/src/ipc/assistant-server.ts +15 -1
- package/src/ipc/cli-client.ts +13 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
- package/src/live-voice/__tests__/protocol.test.ts +11 -3
- package/src/live-voice/__tests__/session-controls.test.ts +34 -0
- package/src/live-voice/live-voice-metrics.ts +9 -0
- package/src/live-voice/live-voice-session.ts +3 -0
- package/src/live-voice/protocol.ts +10 -1
- package/src/live-voice/session-controls.ts +28 -0
- package/src/mcp/__tests__/credential-target.test.ts +59 -0
- package/src/mcp/__tests__/effective-config.test.ts +11 -0
- package/src/mcp/__tests__/manager-state.test.ts +128 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
- package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
- package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
- package/src/mcp/client.ts +64 -16
- package/src/mcp/credential-target.ts +105 -0
- package/src/mcp/effective-config.ts +42 -15
- package/src/mcp/manager.ts +61 -8
- package/src/mcp/mcp-auth-orchestrator.ts +17 -4
- package/src/mcp/mcp-auth-state.ts +38 -6
- package/src/mcp/mcp-oauth-provider.ts +101 -33
- package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
- package/src/monitoring/mount-watch.ts +348 -0
- package/src/monitoring/worker.ts +9 -0
- package/src/oauth/seed-providers.ts +11 -0
- package/src/onboarding/checkin-event.ts +2 -1
- package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
- package/src/plugins/external-plugin-loader.ts +49 -86
- package/src/plugins/installed-plugin-dirs.ts +6 -4
- package/src/plugins/mcp-servers.ts +9 -15
- package/src/plugins/mtime-cache.ts +2 -1
- package/src/plugins/user-loader.ts +2 -1
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
- package/src/providers/inference/endpoint-probe.ts +27 -12
- package/src/providers/openai/responses-provider.ts +9 -2
- package/src/providers/opencode/client.test.ts +167 -0
- package/src/providers/opencode/client.ts +73 -4
- package/src/runtime/AGENTS.md +4 -0
- package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
- package/src/runtime/http-server.ts +6 -6
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
- package/src/runtime/routes/browser-context.ts +39 -0
- package/src/runtime/routes/browser-routes.ts +16 -47
- package/src/runtime/routes/browser-tabs-routes.ts +72 -29
- package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
- package/src/runtime/routes/desktop-setup-routes.ts +7 -5
- package/src/runtime/routes/mcp-auth-routes.ts +106 -94
- package/src/runtime/routes/plugins-routes.ts +14 -1
- package/src/runtime/sync/resource-sync-events.ts +4 -0
- package/src/tools/browser/browser-execution.ts +44 -13
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
- package/src/tools/computer-use/definitions.ts +2 -3
- package/src/util/abort-reasons.ts +12 -1
- package/src/util/plugin-manifest.ts +203 -0
- package/src/desktop/desktop-feature.test.ts +0 -18
- package/src/desktop/desktop-feature.ts +0 -19
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
mcpOAuthCredentialKey,
|
|
6
|
+
pluginMcpOAuthCredentialPrefix,
|
|
7
|
+
pluginMcpOAuthCredentialTarget,
|
|
8
|
+
workspaceMcpOAuthCredentialTarget,
|
|
9
|
+
} from "../credential-target.js";
|
|
10
|
+
|
|
11
|
+
describe("MCP OAuth credential identity", () => {
|
|
12
|
+
test("keeps workspace credential keys unchanged", () => {
|
|
13
|
+
expect(
|
|
14
|
+
mcpOAuthCredentialKey(
|
|
15
|
+
workspaceMcpOAuthCredentialTarget("server-1"),
|
|
16
|
+
"tokens",
|
|
17
|
+
),
|
|
18
|
+
).toBe("mcp:server-1:tokens");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("isolates plugin credentials by owner, original key, transport, and URL", () => {
|
|
22
|
+
const target = pluginMcpOAuthCredentialTarget("plugin/name", "server:key", {
|
|
23
|
+
type: "streamable-http",
|
|
24
|
+
url: "https://example.com/mcp?b=2&a=1#ignored",
|
|
25
|
+
});
|
|
26
|
+
const digest = createHash("sha256")
|
|
27
|
+
.update("streamable-http\nhttps://example.com/mcp?b=2&a=1")
|
|
28
|
+
.digest("hex");
|
|
29
|
+
|
|
30
|
+
expect(mcpOAuthCredentialKey(target, "tokens")).toBe(
|
|
31
|
+
`mcp-plugin/v1/cGx1Z2luL25hbWU/c2VydmVyOmtleQ/${digest}/tokens`,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("keeps query order and path changes isolated while ignoring fragments", () => {
|
|
36
|
+
const key = (url: string) =>
|
|
37
|
+
mcpOAuthCredentialKey(
|
|
38
|
+
pluginMcpOAuthCredentialTarget("plugin", "server", {
|
|
39
|
+
type: "sse",
|
|
40
|
+
url,
|
|
41
|
+
}),
|
|
42
|
+
"tokens",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(key("https://example.com/a?b=2&a=1#one")).toBe(
|
|
46
|
+
key("https://example.com/a?b=2&a=1#two"),
|
|
47
|
+
);
|
|
48
|
+
expect(key("https://example.com/a?b=2&a=1")).not.toBe(
|
|
49
|
+
key("https://example.com/a?a=1&b=2"),
|
|
50
|
+
);
|
|
51
|
+
expect(key("https://example.com/a")).not.toBe(key("https://example.com/b"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("builds a slash-delimited whole-plugin cleanup prefix", () => {
|
|
55
|
+
expect(pluginMcpOAuthCredentialPrefix("plugin/name")).toBe(
|
|
56
|
+
"mcp-plugin/v1/cGx1Z2luL25hbWU/",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -24,6 +24,7 @@ import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
|
24
24
|
import {
|
|
25
25
|
buildEffectiveMcpConfig,
|
|
26
26
|
pluginMcpServersChangedSinceLastBuild,
|
|
27
|
+
readEffectiveMcpConfig,
|
|
27
28
|
resetEffectiveMcpConfigForTests,
|
|
28
29
|
} from "../effective-config.js";
|
|
29
30
|
|
|
@@ -182,6 +183,16 @@ describe("pluginMcpServersChangedSinceLastBuild", () => {
|
|
|
182
183
|
expect(pluginMcpServersChangedSinceLastBuild()).toBe(true);
|
|
183
184
|
});
|
|
184
185
|
|
|
186
|
+
test("a read-only resolution does not record the changed plugin set as applied", () => {
|
|
187
|
+
buildEffectiveMcpConfig(workspaceConfig({}));
|
|
188
|
+
writePlugin("unabyss", UNABYSS);
|
|
189
|
+
|
|
190
|
+
expect(
|
|
191
|
+
readEffectiveMcpConfig(workspaceConfig({})).servers.unabyss,
|
|
192
|
+
).toBeDefined();
|
|
193
|
+
expect(pluginMcpServersChangedSinceLastBuild()).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
185
196
|
test("true once a plugin is uninstalled", () => {
|
|
186
197
|
writePlugin("unabyss", UNABYSS);
|
|
187
198
|
buildEffectiveMcpConfig(workspaceConfig({}));
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
let completeConnect: (() => void) | undefined;
|
|
4
|
+
let completeToolDiscovery: (() => void) | undefined;
|
|
5
|
+
let connected = true;
|
|
6
|
+
let lastError: Error | null = null;
|
|
7
|
+
let waitForConnect = false;
|
|
8
|
+
let waitForToolDiscovery = false;
|
|
9
|
+
const unexpectedCloseHandlers = new Map<string, () => void>();
|
|
10
|
+
|
|
11
|
+
mock.module("../client.js", () => ({
|
|
12
|
+
McpClient: class {
|
|
13
|
+
constructor(
|
|
14
|
+
serverId: string,
|
|
15
|
+
_source: string,
|
|
16
|
+
onUnexpectedClose: () => void,
|
|
17
|
+
) {
|
|
18
|
+
unexpectedCloseHandlers.set(serverId, onUnexpectedClose);
|
|
19
|
+
}
|
|
20
|
+
get isConnected() {
|
|
21
|
+
return connected;
|
|
22
|
+
}
|
|
23
|
+
get lastError() {
|
|
24
|
+
return lastError;
|
|
25
|
+
}
|
|
26
|
+
async connect() {
|
|
27
|
+
if (waitForConnect) {
|
|
28
|
+
await new Promise<void>((resolve) => {
|
|
29
|
+
completeConnect = resolve;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async listTools() {
|
|
34
|
+
if (waitForToolDiscovery) {
|
|
35
|
+
await new Promise<void>((resolve) => {
|
|
36
|
+
completeToolDiscovery = resolve;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
async disconnect() {
|
|
42
|
+
connected = false;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const { McpServerManager } = await import("../manager.js");
|
|
48
|
+
const config = {
|
|
49
|
+
servers: {
|
|
50
|
+
example: {
|
|
51
|
+
source: "workspace" as const,
|
|
52
|
+
transport: {
|
|
53
|
+
type: "streamable-http" as const,
|
|
54
|
+
url: "https://example.com/mcp",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe("MCP manager runtime state", () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
connected = true;
|
|
63
|
+
lastError = null;
|
|
64
|
+
waitForConnect = false;
|
|
65
|
+
waitForToolDiscovery = false;
|
|
66
|
+
completeConnect = undefined;
|
|
67
|
+
completeToolDiscovery = undefined;
|
|
68
|
+
unexpectedCloseHandlers.clear();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("retains connecting state until initialization and discovery finish", async () => {
|
|
72
|
+
waitForConnect = true;
|
|
73
|
+
const manager = new McpServerManager();
|
|
74
|
+
const starting = manager.start(config);
|
|
75
|
+
expect(manager.getServerState("example", "workspace")).toBe("connecting");
|
|
76
|
+
completeConnect!();
|
|
77
|
+
await starting;
|
|
78
|
+
expect(manager.getServerState("example", "workspace")).toBe("connected");
|
|
79
|
+
expect(manager.getServerState("example", "plugin")).toBeUndefined();
|
|
80
|
+
await manager.stop();
|
|
81
|
+
expect(manager.getServerState("example", "workspace")).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("retains authentication failures without reporting a live client", async () => {
|
|
85
|
+
connected = false;
|
|
86
|
+
const manager = new McpServerManager();
|
|
87
|
+
await manager.start(config);
|
|
88
|
+
expect(manager.getServerState("example", "workspace")).toBe("needs-auth");
|
|
89
|
+
expect(manager.getClient("example")).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("keeps transport failure distinct from required authentication", async () => {
|
|
93
|
+
connected = false;
|
|
94
|
+
lastError = new Error("Connection refused");
|
|
95
|
+
const manager = new McpServerManager();
|
|
96
|
+
await manager.start(config);
|
|
97
|
+
expect(manager.getServerState("example", "workspace")).toBe("error");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("records and reports an unexpected close", async () => {
|
|
101
|
+
const onUnexpectedClose = jest.fn();
|
|
102
|
+
const manager = new McpServerManager(onUnexpectedClose);
|
|
103
|
+
await manager.start(config);
|
|
104
|
+
|
|
105
|
+
connected = false;
|
|
106
|
+
unexpectedCloseHandlers.get("example")!();
|
|
107
|
+
|
|
108
|
+
expect(manager.getServerState("example", "workspace")).toBe("error");
|
|
109
|
+
expect(manager.getClient("example")).toBeDefined();
|
|
110
|
+
expect(onUnexpectedClose).toHaveBeenCalledWith();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("does not restore connected state when the server closes during discovery", async () => {
|
|
114
|
+
waitForToolDiscovery = true;
|
|
115
|
+
const manager = new McpServerManager();
|
|
116
|
+
const starting = manager.start(config);
|
|
117
|
+
while (!completeToolDiscovery) {
|
|
118
|
+
await Promise.resolve();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
connected = false;
|
|
122
|
+
unexpectedCloseHandlers.get("example")!();
|
|
123
|
+
completeToolDiscovery!();
|
|
124
|
+
await starting;
|
|
125
|
+
|
|
126
|
+
expect(manager.getServerState("example", "workspace")).toBe("error");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -35,24 +35,34 @@ mock.module("../mcp-oauth-provider.js", () => ({
|
|
|
35
35
|
}));
|
|
36
36
|
|
|
37
37
|
const mockSetMcpAuthPending = mock(
|
|
38
|
-
(
|
|
38
|
+
(
|
|
39
|
+
_serverId: string,
|
|
40
|
+
_authUrl: string,
|
|
41
|
+
_attemptId: string,
|
|
42
|
+
_targetKey: string,
|
|
43
|
+
) => {},
|
|
39
44
|
);
|
|
40
45
|
// Default behavior: pretend the attempt still owns the slot (return true),
|
|
41
46
|
// so completion writes are applied unless a test overrides this.
|
|
42
47
|
const mockSetMcpAuthComplete = mock(
|
|
43
|
-
(_serverId: string, _attemptId: string): boolean => true,
|
|
48
|
+
(_serverId: string, _attemptId: string, _targetKey: string): boolean => true,
|
|
44
49
|
);
|
|
45
50
|
const mockSetMcpAuthError = mock(
|
|
46
|
-
(
|
|
51
|
+
(
|
|
52
|
+
_serverId: string,
|
|
53
|
+
_error: string,
|
|
54
|
+
_attemptId: string,
|
|
55
|
+
_targetKey: string,
|
|
56
|
+
): boolean => true,
|
|
47
57
|
);
|
|
48
58
|
|
|
49
59
|
mock.module("../mcp-auth-state.js", () => ({
|
|
50
60
|
setMcpAuthPending: (...args: unknown[]) =>
|
|
51
|
-
mockSetMcpAuthPending(...(args as [string, string, string])),
|
|
61
|
+
mockSetMcpAuthPending(...(args as [string, string, string, string])),
|
|
52
62
|
setMcpAuthComplete: (...args: unknown[]) =>
|
|
53
|
-
mockSetMcpAuthComplete(...(args as [string, string])),
|
|
63
|
+
mockSetMcpAuthComplete(...(args as [string, string, string])),
|
|
54
64
|
setMcpAuthError: (...args: unknown[]) =>
|
|
55
|
-
mockSetMcpAuthError(...(args as [string, string, string])),
|
|
65
|
+
mockSetMcpAuthError(...(args as [string, string, string, string])),
|
|
56
66
|
}));
|
|
57
67
|
|
|
58
68
|
const mockReloadMcpServers = mock(async () => ({
|
|
@@ -167,6 +177,7 @@ describe("orchestrateMcpOAuthConnect", () => {
|
|
|
167
177
|
"test-server",
|
|
168
178
|
"https://auth.example.com/oauth",
|
|
169
179
|
expect.any(String) as unknown as string, // attemptId UUID
|
|
180
|
+
"mcp:test-server:tokens",
|
|
170
181
|
]);
|
|
171
182
|
// Sanity-check the attemptId looks UUID-shaped
|
|
172
183
|
expect(mockSetMcpAuthPending.mock.calls[0][2]).toMatch(
|
|
@@ -195,6 +206,7 @@ describe("orchestrateMcpOAuthConnect", () => {
|
|
|
195
206
|
expect(mockSetMcpAuthComplete).toHaveBeenCalledWith(
|
|
196
207
|
"test-server",
|
|
197
208
|
expect.any(String) as unknown as string,
|
|
209
|
+
"mcp:test-server:tokens",
|
|
198
210
|
);
|
|
199
211
|
// Daemon-side reload should be triggered after a successful completion.
|
|
200
212
|
expect(mockReloadMcpServers).toHaveBeenCalled();
|
|
@@ -218,6 +230,7 @@ describe("orchestrateMcpOAuthConnect", () => {
|
|
|
218
230
|
"test-server",
|
|
219
231
|
"exchange failed",
|
|
220
232
|
expect.any(String) as unknown as string,
|
|
233
|
+
"mcp:test-server:tokens",
|
|
221
234
|
);
|
|
222
235
|
expect(mockSetMcpAuthComplete).not.toHaveBeenCalled();
|
|
223
236
|
});
|
|
@@ -236,6 +249,7 @@ describe("orchestrateMcpOAuthConnect", () => {
|
|
|
236
249
|
"test-server",
|
|
237
250
|
"MCP OAuth callback timed out",
|
|
238
251
|
expect.any(String) as unknown as string,
|
|
252
|
+
"mcp:test-server:tokens",
|
|
239
253
|
);
|
|
240
254
|
expect(mockSetMcpAuthComplete).not.toHaveBeenCalled();
|
|
241
255
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getMcpAuthState,
|
|
5
|
+
setMcpAuthComplete,
|
|
6
|
+
setMcpAuthError,
|
|
7
|
+
setMcpAuthPending,
|
|
8
|
+
} from "../mcp-auth-state.js";
|
|
9
|
+
|
|
10
|
+
describe("MCP auth state target identity", () => {
|
|
11
|
+
test("does not return state for a different endpoint target", () => {
|
|
12
|
+
const serverId = "target-filter-server";
|
|
13
|
+
setMcpAuthPending(
|
|
14
|
+
serverId,
|
|
15
|
+
"https://auth.example.com",
|
|
16
|
+
"attempt-a",
|
|
17
|
+
"key-a",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(getMcpAuthState(serverId, "key-b")).toBeNull();
|
|
21
|
+
expect(getMcpAuthState(serverId, "key-a")?.status).toBe("pending");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a stale endpoint tail cannot overwrite a newer target", () => {
|
|
25
|
+
const serverId = "target-supersede-server";
|
|
26
|
+
setMcpAuthPending(
|
|
27
|
+
serverId,
|
|
28
|
+
"https://auth-a.example.com",
|
|
29
|
+
"attempt-a",
|
|
30
|
+
"key-a",
|
|
31
|
+
);
|
|
32
|
+
setMcpAuthPending(
|
|
33
|
+
serverId,
|
|
34
|
+
"https://auth-b.example.com",
|
|
35
|
+
"attempt-b",
|
|
36
|
+
"key-b",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(setMcpAuthComplete(serverId, "attempt-a", "key-a")).toBe(false);
|
|
40
|
+
expect(
|
|
41
|
+
setMcpAuthError(serverId, "stale failure", "attempt-a", "key-a"),
|
|
42
|
+
).toBe(false);
|
|
43
|
+
expect(getMcpAuthState(serverId, "key-b")).toMatchObject({
|
|
44
|
+
status: "pending",
|
|
45
|
+
attemptId: "attempt-b",
|
|
46
|
+
targetKey: "key-b",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const listSecureKeysAsync = jest.fn(async () => ({
|
|
4
|
+
accounts: [] as string[],
|
|
5
|
+
unreachable: false,
|
|
6
|
+
}));
|
|
7
|
+
const deleteSecureKeyAsync = jest.fn(
|
|
8
|
+
async (_key: string): Promise<"deleted" | "not-found" | "error"> => "deleted",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
mock.module("../../security/secure-keys.js", () => ({
|
|
12
|
+
getSecureKeyAsync: jest.fn(async () => null),
|
|
13
|
+
setSecureKeyAsync: jest.fn(async () => true),
|
|
14
|
+
deleteSecureKeyAsync,
|
|
15
|
+
listSecureKeysAsync,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const { deletePluginMcpOAuthCredentials } =
|
|
19
|
+
await import("../mcp-oauth-provider.js");
|
|
20
|
+
|
|
21
|
+
describe("plugin MCP OAuth credential cleanup", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
listSecureKeysAsync.mockReset();
|
|
24
|
+
listSecureKeysAsync.mockImplementation(async () => ({
|
|
25
|
+
accounts: [],
|
|
26
|
+
unreachable: false,
|
|
27
|
+
}));
|
|
28
|
+
deleteSecureKeyAsync.mockReset();
|
|
29
|
+
deleteSecureKeyAsync.mockImplementation(async () => "deleted" as const);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("deletes only the exact plugin owner prefix", async () => {
|
|
33
|
+
listSecureKeysAsync.mockImplementationOnce(async () => ({
|
|
34
|
+
accounts: [
|
|
35
|
+
"mcp-plugin/v1/cGx1Z2lu/client/digest/tokens",
|
|
36
|
+
"mcp-plugin/v1/cGx1Z2lu/client/digest/discovery",
|
|
37
|
+
"mcp-plugin/v1/cGx1Z2luMg/client/digest/tokens",
|
|
38
|
+
"mcp:plugin:tokens",
|
|
39
|
+
],
|
|
40
|
+
unreachable: false,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const result = await deletePluginMcpOAuthCredentials("plugin");
|
|
44
|
+
|
|
45
|
+
expect(deleteSecureKeyAsync.mock.calls.map(([key]) => key)).toEqual([
|
|
46
|
+
"mcp-plugin/v1/cGx1Z2lu/client/digest/tokens",
|
|
47
|
+
"mcp-plugin/v1/cGx1Z2lu/client/digest/discovery",
|
|
48
|
+
]);
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
ok: true,
|
|
51
|
+
unreachable: false,
|
|
52
|
+
matchedKeys: [
|
|
53
|
+
"mcp-plugin/v1/cGx1Z2lu/client/digest/tokens",
|
|
54
|
+
"mcp-plugin/v1/cGx1Z2lu/client/digest/discovery",
|
|
55
|
+
],
|
|
56
|
+
failedKeys: [],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("does not attempt deletion when credential storage is unreachable", async () => {
|
|
61
|
+
listSecureKeysAsync.mockImplementationOnce(async () => ({
|
|
62
|
+
accounts: [],
|
|
63
|
+
unreachable: true,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
const result = await deletePluginMcpOAuthCredentials("plugin");
|
|
67
|
+
|
|
68
|
+
expect(deleteSecureKeyAsync).not.toHaveBeenCalled();
|
|
69
|
+
expect(result).toEqual({
|
|
70
|
+
ok: false,
|
|
71
|
+
unreachable: true,
|
|
72
|
+
matchedKeys: [],
|
|
73
|
+
failedKeys: [],
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("reports any matched key that could not be deleted", async () => {
|
|
78
|
+
const key = "mcp-plugin/v1/cGx1Z2lu/client/digest/tokens";
|
|
79
|
+
listSecureKeysAsync.mockImplementationOnce(async () => ({
|
|
80
|
+
accounts: [key],
|
|
81
|
+
unreachable: false,
|
|
82
|
+
}));
|
|
83
|
+
deleteSecureKeyAsync.mockImplementationOnce(async () => "error" as const);
|
|
84
|
+
|
|
85
|
+
const result = await deletePluginMcpOAuthCredentials("plugin");
|
|
86
|
+
|
|
87
|
+
expect(result.ok).toBe(false);
|
|
88
|
+
expect(result.failedKeys).toEqual([key]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("reports a rejected matched-key deletion as a failure", async () => {
|
|
92
|
+
const key = "mcp-plugin/v1/cGx1Z2lu/client/digest/tokens";
|
|
93
|
+
listSecureKeysAsync.mockImplementationOnce(async () => ({
|
|
94
|
+
accounts: [key],
|
|
95
|
+
unreachable: false,
|
|
96
|
+
}));
|
|
97
|
+
deleteSecureKeyAsync.mockImplementationOnce(async () => {
|
|
98
|
+
throw new Error("backend failed");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = await deletePluginMcpOAuthCredentials("plugin");
|
|
102
|
+
|
|
103
|
+
expect(result.ok).toBe(false);
|
|
104
|
+
expect(result.unreachable).toBe(false);
|
|
105
|
+
expect(result.failedKeys).toEqual([key]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { McpOAuthCredentialTarget } from "../credential-target.js";
|
|
4
|
+
|
|
5
|
+
const store = new Map<string, string>();
|
|
6
|
+
|
|
7
|
+
mock.module("../../security/secure-keys.js", () => ({
|
|
8
|
+
getSecureKeyAsync: jest.fn(async (key: string) => store.get(key)),
|
|
9
|
+
setSecureKeyAsync: jest.fn(async (key: string, value: string) => {
|
|
10
|
+
store.set(key, value);
|
|
11
|
+
return true;
|
|
12
|
+
}),
|
|
13
|
+
deleteSecureKeyAsync: jest.fn(async (key: string) =>
|
|
14
|
+
store.delete(key) ? "deleted" : "not-found",
|
|
15
|
+
),
|
|
16
|
+
listSecureKeysAsync: jest.fn(async () => ({
|
|
17
|
+
accounts: [...store.keys()],
|
|
18
|
+
unreachable: false,
|
|
19
|
+
})),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { McpOAuthProvider } = await import("../mcp-oauth-provider.js");
|
|
23
|
+
const { pluginMcpOAuthCredentialTarget, workspaceMcpOAuthCredentialTarget } =
|
|
24
|
+
await import("../credential-target.js");
|
|
25
|
+
|
|
26
|
+
function provider(
|
|
27
|
+
serverId: string,
|
|
28
|
+
url: string,
|
|
29
|
+
credentialTarget: McpOAuthCredentialTarget,
|
|
30
|
+
) {
|
|
31
|
+
return new McpOAuthProvider(serverId, url, false, { credentialTarget });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("McpOAuthProvider plugin credential isolation", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
store.clear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("round-trips across provider instances without crossing owners or workspace", async () => {
|
|
40
|
+
const url = "https://mcp.example.com/a";
|
|
41
|
+
const pluginA = pluginMcpOAuthCredentialTarget("plugin-a", "server", {
|
|
42
|
+
type: "streamable-http",
|
|
43
|
+
url,
|
|
44
|
+
});
|
|
45
|
+
const pluginB = pluginMcpOAuthCredentialTarget("plugin-b", "server", {
|
|
46
|
+
type: "streamable-http",
|
|
47
|
+
url,
|
|
48
|
+
});
|
|
49
|
+
const workspace = workspaceMcpOAuthCredentialTarget("plugin-a");
|
|
50
|
+
|
|
51
|
+
await provider("plugin-a", url, pluginA).saveTokens({
|
|
52
|
+
access_token: "plugin-a-token",
|
|
53
|
+
token_type: "bearer",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(await provider("plugin-a", url, pluginA).tokens()).toMatchObject({
|
|
57
|
+
access_token: "plugin-a-token",
|
|
58
|
+
});
|
|
59
|
+
expect(await provider("plugin-b", url, pluginB).tokens()).toBeUndefined();
|
|
60
|
+
expect(await provider("plugin-a", url, workspace).tokens()).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("an endpoint change cannot read or invalidate the old endpoint tokens", async () => {
|
|
64
|
+
const oldUrl = "https://mcp.example.com/a";
|
|
65
|
+
const newUrl = "https://mcp.example.com/b";
|
|
66
|
+
const oldTarget = pluginMcpOAuthCredentialTarget("plugin-a", "server", {
|
|
67
|
+
type: "streamable-http",
|
|
68
|
+
url: oldUrl,
|
|
69
|
+
});
|
|
70
|
+
const newTarget = pluginMcpOAuthCredentialTarget("plugin-a", "server", {
|
|
71
|
+
type: "streamable-http",
|
|
72
|
+
url: newUrl,
|
|
73
|
+
});
|
|
74
|
+
await provider("plugin-a", oldUrl, oldTarget).saveTokens({
|
|
75
|
+
access_token: "old-token",
|
|
76
|
+
token_type: "bearer",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(
|
|
80
|
+
await provider("plugin-a", newUrl, newTarget).tokens(),
|
|
81
|
+
).toBeUndefined();
|
|
82
|
+
await provider("plugin-a", newUrl, newTarget).invalidateCredentials(
|
|
83
|
+
"tokens",
|
|
84
|
+
);
|
|
85
|
+
expect(
|
|
86
|
+
await provider("plugin-a", oldUrl, oldTarget).tokens(),
|
|
87
|
+
).toMatchObject({ access_token: "old-token" });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A plugin-declared server must connect
|
|
3
|
-
* `mcp:<serverId>:*` from the workspace
|
|
2
|
+
* A plugin-declared server must connect with its plugin and endpoint-scoped
|
|
3
|
+
* OAuth credentials without resolving `mcp:<serverId>:*` from the workspace
|
|
4
|
+
* credential store.
|
|
4
5
|
*
|
|
5
6
|
* A plugin controls both its server key and its URL, so a stored
|
|
6
7
|
* credential whose id happens to match would otherwise be sent to an
|
|
@@ -12,7 +13,9 @@
|
|
|
12
13
|
|
|
13
14
|
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
|
14
15
|
|
|
15
|
-
const getSecureKeyAsync = jest.fn(
|
|
16
|
+
const getSecureKeyAsync = jest.fn(
|
|
17
|
+
async (_key: string): Promise<string | null> => "stored-oauth-token",
|
|
18
|
+
);
|
|
16
19
|
const getMcpHeaders = jest.fn(async (_serverId: string) => ({
|
|
17
20
|
Authorization: "Bearer workspace-secret",
|
|
18
21
|
}));
|
|
@@ -36,10 +39,19 @@ const { McpServerManager } = await import("../manager.js");
|
|
|
36
39
|
/** Refused immediately, so `connect` fails after the credential lookups. */
|
|
37
40
|
const UNREACHABLE = "http://127.0.0.1:1/mcp";
|
|
38
41
|
|
|
39
|
-
function
|
|
42
|
+
function workspaceHttpServer() {
|
|
40
43
|
return {
|
|
41
44
|
transport: { type: "streamable-http" as const, url: UNREACHABLE },
|
|
42
|
-
source,
|
|
45
|
+
source: "workspace" as const,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pluginHttpServer(url = UNREACHABLE) {
|
|
50
|
+
return {
|
|
51
|
+
transport: { type: "streamable-http" as const, url },
|
|
52
|
+
source: "plugin" as const,
|
|
53
|
+
pluginName: "unabyss",
|
|
54
|
+
serverKey: "unabyss",
|
|
43
55
|
};
|
|
44
56
|
}
|
|
45
57
|
|
|
@@ -49,22 +61,34 @@ describe("plugin-declared MCP servers", () => {
|
|
|
49
61
|
getMcpHeaders.mockClear();
|
|
50
62
|
});
|
|
51
63
|
|
|
52
|
-
test("connect
|
|
64
|
+
test("connect with plugin-scoped OAuth credentials", async () => {
|
|
53
65
|
const manager = new McpServerManager();
|
|
54
66
|
|
|
55
67
|
await manager.start({
|
|
56
|
-
servers: { unabyss:
|
|
68
|
+
servers: { unabyss: pluginHttpServer() },
|
|
57
69
|
});
|
|
58
70
|
|
|
59
71
|
expect(getMcpHeaders).not.toHaveBeenCalled();
|
|
60
|
-
expect(getSecureKeyAsync).
|
|
72
|
+
expect(getSecureKeyAsync.mock.calls.length).toBeGreaterThan(0);
|
|
73
|
+
expect(getSecureKeyAsync.mock.calls.map(([key]) => key)).toEqual(
|
|
74
|
+
expect.arrayContaining([
|
|
75
|
+
expect.stringMatching(
|
|
76
|
+
/^mcp-plugin\/v1\/dW5hYnlzcw\/dW5hYnlzcw\/[a-f0-9]{64}\/tokens$/,
|
|
77
|
+
),
|
|
78
|
+
]),
|
|
79
|
+
);
|
|
80
|
+
expect(
|
|
81
|
+
getSecureKeyAsync.mock.calls.some(
|
|
82
|
+
([key]) => key === "mcp:unabyss:tokens",
|
|
83
|
+
),
|
|
84
|
+
).toBe(false);
|
|
61
85
|
});
|
|
62
86
|
|
|
63
87
|
test("workspace servers keep resolving theirs", async () => {
|
|
64
88
|
const manager = new McpServerManager();
|
|
65
89
|
|
|
66
90
|
await manager.start({
|
|
67
|
-
servers: { "from-workspace":
|
|
91
|
+
servers: { "from-workspace": workspaceHttpServer() },
|
|
68
92
|
});
|
|
69
93
|
|
|
70
94
|
expect(getMcpHeaders).toHaveBeenCalledWith("from-workspace");
|
|
@@ -78,12 +102,40 @@ describe("plugin-declared MCP servers", () => {
|
|
|
78
102
|
|
|
79
103
|
await manager.start({
|
|
80
104
|
servers: {
|
|
81
|
-
unabyss:
|
|
82
|
-
"from-workspace":
|
|
105
|
+
unabyss: pluginHttpServer(),
|
|
106
|
+
"from-workspace": workspaceHttpServer(),
|
|
83
107
|
},
|
|
84
108
|
});
|
|
85
109
|
|
|
86
110
|
const lookedUpIds = getMcpHeaders.mock.calls.map(([id]) => id);
|
|
87
111
|
expect(lookedUpIds).toEqual(["from-workspace"]);
|
|
88
112
|
});
|
|
113
|
+
|
|
114
|
+
test("does not reuse an OAuth provider after the endpoint changes", async () => {
|
|
115
|
+
const { McpClient } = await import("../client.js");
|
|
116
|
+
const endpointA = "https://mcp.example.com/a";
|
|
117
|
+
const endpointB = "https://mcp.example.com/b";
|
|
118
|
+
getSecureKeyAsync.mockImplementation(async (key: string) =>
|
|
119
|
+
key.endsWith("/tokens") &&
|
|
120
|
+
key.includes("mcp-plugin/") &&
|
|
121
|
+
getSecureKeyAsync.mock.calls.length === 1
|
|
122
|
+
? "stored-oauth-token"
|
|
123
|
+
: null,
|
|
124
|
+
);
|
|
125
|
+
const client = new McpClient("unabyss", pluginHttpServer(endpointA));
|
|
126
|
+
const providers: unknown[] = [];
|
|
127
|
+
(client as any).createTransport = () => {
|
|
128
|
+
providers.push((client as any).oauthProvider);
|
|
129
|
+
return {};
|
|
130
|
+
};
|
|
131
|
+
(client as any).client.connect = async () => {};
|
|
132
|
+
(client as any).client.close = async () => {};
|
|
133
|
+
|
|
134
|
+
await client.connect({ type: "streamable-http", url: endpointA });
|
|
135
|
+
await client.disconnect();
|
|
136
|
+
await client.connect({ type: "streamable-http", url: endpointB });
|
|
137
|
+
|
|
138
|
+
expect(providers[0]).not.toBeNull();
|
|
139
|
+
expect(providers[1]).toBeNull();
|
|
140
|
+
});
|
|
89
141
|
});
|