@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
|
@@ -553,7 +553,7 @@ describe("HostCuProxy", () => {
|
|
|
553
553
|
});
|
|
554
554
|
const result2 = await p2;
|
|
555
555
|
// First unchanged: simple warning
|
|
556
|
-
expect(result2.content).toContain("
|
|
556
|
+
expect(result2.content).toContain("tree did not change");
|
|
557
557
|
expect(result2.content).not.toContain("2 consecutive");
|
|
558
558
|
|
|
559
559
|
// Third request — still same AX tree, no diff (unchanged step 2)
|
|
@@ -571,7 +571,7 @@ describe("HostCuProxy", () => {
|
|
|
571
571
|
const result3 = await p3;
|
|
572
572
|
// Should now have the consecutive warning
|
|
573
573
|
expect(result3.content).toContain(
|
|
574
|
-
"
|
|
574
|
+
"did not change across 2 consecutive actions",
|
|
575
575
|
);
|
|
576
576
|
});
|
|
577
577
|
|
|
@@ -592,7 +592,93 @@ describe("HostCuProxy", () => {
|
|
|
592
592
|
// No axDiff on first observation — this is normal, not unchanged
|
|
593
593
|
});
|
|
594
594
|
const result1 = await p1;
|
|
595
|
-
expect(result1.content).not.toContain("
|
|
595
|
+
expect(result1.content).not.toContain("tree did not change");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
async function stepWithUnchangedTree(
|
|
599
|
+
toolName: string,
|
|
600
|
+
input: Record<string, unknown>,
|
|
601
|
+
step: number,
|
|
602
|
+
) {
|
|
603
|
+
const pending = proxy.request(toolName, input, "session-1", step);
|
|
604
|
+
proxy.recordAction(toolName, input);
|
|
605
|
+
const sent = sentMessages[step - 1] as Record<string, unknown>;
|
|
606
|
+
proxy.processObservation(sent.requestId as string, {
|
|
607
|
+
axTree: "Button [1]",
|
|
608
|
+
});
|
|
609
|
+
return pending;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
test("an AppleScript or an observation neither warns nor moves the streak", async () => {
|
|
613
|
+
for (const [toolName, input] of [
|
|
614
|
+
[
|
|
615
|
+
"computer_use_run_applescript",
|
|
616
|
+
{ script: 'tell application "Finder" to activate' },
|
|
617
|
+
],
|
|
618
|
+
["computer_use_observe", {}],
|
|
619
|
+
] as const) {
|
|
620
|
+
setup();
|
|
621
|
+
await stepWithUnchangedTree("computer_use_click", { element_id: 1 }, 1);
|
|
622
|
+
|
|
623
|
+
const result = await stepWithUnchangedTree(toolName, input, 2);
|
|
624
|
+
|
|
625
|
+
expect(result.content).not.toContain("tree did not change");
|
|
626
|
+
expect(proxy.consecutiveUnchangedSteps).toBe(0);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("judges each concurrent response by the action it carried", async () => {
|
|
631
|
+
setup();
|
|
632
|
+
await stepWithUnchangedTree("computer_use_click", { element_id: 1 }, 1);
|
|
633
|
+
|
|
634
|
+
// A click and an observation dispatched from one model response, with
|
|
635
|
+
// the observation recorded last and the click answered last.
|
|
636
|
+
const clickPromise = proxy.request(
|
|
637
|
+
"computer_use_click",
|
|
638
|
+
{ element_id: 1 },
|
|
639
|
+
"session-1",
|
|
640
|
+
2,
|
|
641
|
+
);
|
|
642
|
+
proxy.recordAction("computer_use_click", { element_id: 1 });
|
|
643
|
+
const observePromise = proxy.request(
|
|
644
|
+
"computer_use_observe",
|
|
645
|
+
{},
|
|
646
|
+
"session-1",
|
|
647
|
+
3,
|
|
648
|
+
);
|
|
649
|
+
proxy.recordAction("computer_use_observe", {});
|
|
650
|
+
const click = sentMessages[1] as Record<string, unknown>;
|
|
651
|
+
const observe = sentMessages[2] as Record<string, unknown>;
|
|
652
|
+
|
|
653
|
+
proxy.processObservation(observe.requestId as string, {
|
|
654
|
+
axTree: "Button [1]",
|
|
655
|
+
});
|
|
656
|
+
proxy.processObservation(click.requestId as string, {
|
|
657
|
+
axTree: "Button [1]",
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
expect((await observePromise).content).not.toContain(
|
|
661
|
+
"tree did not change",
|
|
662
|
+
);
|
|
663
|
+
expect((await clickPromise).content).toContain("tree did not change");
|
|
664
|
+
expect(proxy.consecutiveUnchangedSteps).toBe(1);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("an observation between two unchanged clicks keeps the streak", async () => {
|
|
668
|
+
setup();
|
|
669
|
+
await stepWithUnchangedTree("computer_use_click", { element_id: 1 }, 1);
|
|
670
|
+
await stepWithUnchangedTree("computer_use_click", { element_id: 1 }, 2);
|
|
671
|
+
await stepWithUnchangedTree("computer_use_observe", {}, 3);
|
|
672
|
+
|
|
673
|
+
const result = await stepWithUnchangedTree(
|
|
674
|
+
"computer_use_click",
|
|
675
|
+
{ element_id: 1 },
|
|
676
|
+
4,
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
expect(result.content).toContain(
|
|
680
|
+
"did not change across 2 consecutive actions",
|
|
681
|
+
);
|
|
596
682
|
});
|
|
597
683
|
|
|
598
684
|
test("skips unchanged warning after computer_use_wait", async () => {
|
|
@@ -626,7 +712,7 @@ describe("HostCuProxy", () => {
|
|
|
626
712
|
// No axDiff — screen unchanged, but that's expected after wait
|
|
627
713
|
});
|
|
628
714
|
const result2 = await p2;
|
|
629
|
-
expect(result2.content).not.toContain("
|
|
715
|
+
expect(result2.content).not.toContain("tree did not change");
|
|
630
716
|
});
|
|
631
717
|
|
|
632
718
|
test("skips unchanged warning and counter after a selection-only key (cmd+a)", async () => {
|
|
@@ -661,7 +747,7 @@ describe("HostCuProxy", () => {
|
|
|
661
747
|
// No axDiff — selection change is invisible in the AX tree
|
|
662
748
|
});
|
|
663
749
|
const result2 = await p2;
|
|
664
|
-
expect(result2.content).not.toContain("
|
|
750
|
+
expect(result2.content).not.toContain("tree did not change");
|
|
665
751
|
expect(proxy.consecutiveUnchangedSteps).toBe(0);
|
|
666
752
|
});
|
|
667
753
|
|
|
@@ -707,7 +793,7 @@ describe("HostCuProxy", () => {
|
|
|
707
793
|
// No axDiff — invisible-by-design change
|
|
708
794
|
});
|
|
709
795
|
const result2 = await p2;
|
|
710
|
-
expect(result2.content).not.toContain("
|
|
796
|
+
expect(result2.content).not.toContain("tree did not change");
|
|
711
797
|
expect(proxy.consecutiveUnchangedSteps).toBe(0);
|
|
712
798
|
}
|
|
713
799
|
});
|
|
@@ -807,7 +893,7 @@ describe("HostCuProxy", () => {
|
|
|
807
893
|
axTree: "Button [1]",
|
|
808
894
|
});
|
|
809
895
|
const result2 = await p2;
|
|
810
|
-
expect(result2.content).toContain("
|
|
896
|
+
expect(result2.content).toContain("tree did not change");
|
|
811
897
|
expect(proxy.consecutiveUnchangedSteps).toBe(1);
|
|
812
898
|
});
|
|
813
899
|
|
|
@@ -840,7 +926,7 @@ describe("HostCuProxy", () => {
|
|
|
840
926
|
axTree: "Button [1]",
|
|
841
927
|
});
|
|
842
928
|
const result2 = await p2;
|
|
843
|
-
expect(result2.content).toContain("
|
|
929
|
+
expect(result2.content).toContain("tree did not change");
|
|
844
930
|
expect(proxy.consecutiveUnchangedSteps).toBe(1);
|
|
845
931
|
});
|
|
846
932
|
|
|
@@ -860,7 +946,7 @@ describe("HostCuProxy", () => {
|
|
|
860
946
|
expect(result.content).toContain(
|
|
861
947
|
"WARNING: You've repeated the same action (computer_use_key) 3 times",
|
|
862
948
|
);
|
|
863
|
-
expect(result.content).not.toContain("
|
|
949
|
+
expect(result.content).not.toContain("tree did not change");
|
|
864
950
|
});
|
|
865
951
|
|
|
866
952
|
test("loop detection fires for the same exempt combo spelled differently", () => {
|
|
@@ -1706,9 +1792,21 @@ describe("HostCuProxy", () => {
|
|
|
1706
1792
|
async function finish(
|
|
1707
1793
|
input: Record<string, unknown>,
|
|
1708
1794
|
observation: Record<string, string>,
|
|
1795
|
+
// The step the unchanged streak is judged on. An observation leaves the
|
|
1796
|
+
// streak alone, so a test of how snapshots reset it stands in an action.
|
|
1797
|
+
toolName = "computer_use_observe",
|
|
1709
1798
|
) {
|
|
1710
|
-
proxy.recordAction(
|
|
1711
|
-
const pending =
|
|
1799
|
+
proxy.recordAction(toolName, input);
|
|
1800
|
+
const pending = proxy.request(
|
|
1801
|
+
toolName,
|
|
1802
|
+
input,
|
|
1803
|
+
"session-1",
|
|
1804
|
+
1,
|
|
1805
|
+
undefined,
|
|
1806
|
+
undefined,
|
|
1807
|
+
undefined,
|
|
1808
|
+
"user-1",
|
|
1809
|
+
);
|
|
1712
1810
|
const sent = sentMessages.at(-1) as { requestId: string };
|
|
1713
1811
|
proxy.processObservation(sent.requestId, observation);
|
|
1714
1812
|
return await pending;
|
|
@@ -1777,8 +1875,8 @@ describe("HostCuProxy", () => {
|
|
|
1777
1875
|
test("targeted snapshots reset history without no-effect warnings or cross-window diffs", async () => {
|
|
1778
1876
|
setup();
|
|
1779
1877
|
connect();
|
|
1780
|
-
await finish({}, { axTree: "Private desktop" });
|
|
1781
|
-
await finish({}, { axTree: "Private desktop" });
|
|
1878
|
+
await finish({}, { axTree: "Private desktop" }, "computer_use_click");
|
|
1879
|
+
await finish({}, { axTree: "Private desktop" }, "computer_use_click");
|
|
1782
1880
|
expect(proxy.consecutiveUnchangedSteps).toBe(1);
|
|
1783
1881
|
for (const id of [12, 12, 13]) {
|
|
1784
1882
|
const result = await finish(
|
|
@@ -1789,16 +1887,16 @@ describe("HostCuProxy", () => {
|
|
|
1789
1887
|
secondaryWindows: "Private secondary window",
|
|
1790
1888
|
},
|
|
1791
1889
|
);
|
|
1792
|
-
expect(result.content).not.toContain("
|
|
1890
|
+
expect(result.content).not.toContain("tree did not change");
|
|
1793
1891
|
expect(result.content).not.toContain("Private");
|
|
1794
1892
|
expect(proxy.previousAXTree).toBeUndefined();
|
|
1795
1893
|
expect(proxy.consecutiveUnchangedSteps).toBe(0);
|
|
1796
1894
|
}
|
|
1797
1895
|
const desktop = await finish({}, { axTree: "Returned desktop" });
|
|
1798
|
-
expect(desktop.content).not.toContain("
|
|
1896
|
+
expect(desktop.content).not.toContain("tree did not change");
|
|
1799
1897
|
expect(proxy.previousAXTree).toBe("Returned desktop");
|
|
1800
1898
|
expect(proxy.consecutiveUnchangedSteps).toBe(0);
|
|
1801
|
-
await finish({}, { axTree: "Returned desktop" });
|
|
1899
|
+
await finish({}, { axTree: "Returned desktop" }, "computer_use_click");
|
|
1802
1900
|
expect(proxy.consecutiveUnchangedSteps).toBe(1);
|
|
1803
1901
|
});
|
|
1804
1902
|
test("failed targeted captures also break the desktop comparison baseline", async () => {
|
|
@@ -1815,7 +1913,7 @@ describe("HostCuProxy", () => {
|
|
|
1815
1913
|
).toBe(true);
|
|
1816
1914
|
expect(proxy.previousAXTree).toBeUndefined();
|
|
1817
1915
|
const result = await finish({}, { axTree: "Desktop" });
|
|
1818
|
-
expect(result.content).not.toContain("
|
|
1916
|
+
expect(result.content).not.toContain("tree did not change");
|
|
1819
1917
|
});
|
|
1820
1918
|
test("legacy camel-case companion targets also reset observation history", async () => {
|
|
1821
1919
|
setup();
|
|
@@ -1825,7 +1923,7 @@ describe("HostCuProxy", () => {
|
|
|
1825
1923
|
{ captureDisplayId: 1 },
|
|
1826
1924
|
{ axTree: "Display" },
|
|
1827
1925
|
);
|
|
1828
|
-
expect(result.content).not.toContain("
|
|
1926
|
+
expect(result.content).not.toContain("tree did not change");
|
|
1829
1927
|
expect(proxy.previousAXTree).toBeUndefined();
|
|
1830
1928
|
});
|
|
1831
1929
|
});
|
|
@@ -22,18 +22,29 @@ function freshWorkspace(): string {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Materialize an installed plugin under `<workspace>/plugins/<name>/` with a
|
|
25
|
-
*
|
|
25
|
+
* plugin manifest. Returns the plugin directory path.
|
|
26
26
|
*/
|
|
27
27
|
function installPlugin(
|
|
28
28
|
name: string,
|
|
29
|
-
opts: { disabled?: boolean } = {},
|
|
29
|
+
opts: { disabled?: boolean; standard?: boolean } = {},
|
|
30
30
|
): string {
|
|
31
31
|
const pluginDir = join(getWorkspacePluginsDir(), name);
|
|
32
32
|
mkdirSync(pluginDir, { recursive: true });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
if (opts.standard) {
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(pluginDir, "plugin.json"),
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
38
|
+
name,
|
|
39
|
+
version: "1.0.0",
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
} else {
|
|
43
|
+
writeFileSync(
|
|
44
|
+
join(pluginDir, "package.json"),
|
|
45
|
+
JSON.stringify({ name, version: "1.0.0" }),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
37
48
|
if (opts.disabled) {
|
|
38
49
|
writeFileSync(join(pluginDir, ".disabled"), "");
|
|
39
50
|
}
|
|
@@ -104,7 +115,16 @@ describe("listAllApps", () => {
|
|
|
104
115
|
expect(all.map((a) => a.origin.kind)).toEqual(["workspace", "plugin"]);
|
|
105
116
|
});
|
|
106
117
|
|
|
107
|
-
test("
|
|
118
|
+
test("discovers apps from a standard-only plugin", () => {
|
|
119
|
+
const pluginDir = installPlugin("standard-plugin", { standard: true });
|
|
120
|
+
bundleApp(pluginDir, "dashboard");
|
|
121
|
+
|
|
122
|
+
expect(listPluginApps().map((app) => app.id)).toEqual([
|
|
123
|
+
"plugins~standard-plugin~dashboard",
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("ignores directories under plugins/ without a root manifest", () => {
|
|
108
128
|
// A stray directory that is not an installed plugin, even with an apps/ dir.
|
|
109
129
|
const strayDir = join(getWorkspacePluginsDir(), "not-a-plugin");
|
|
110
130
|
mkdirSync(join(strayDir, "apps", "ghost"), { recursive: true });
|
|
@@ -166,6 +186,18 @@ describe("resolveAppSource", () => {
|
|
|
166
186
|
expect(source!.sourceDir).toBe(join(pluginDir, "apps", "acme-dashboard"));
|
|
167
187
|
});
|
|
168
188
|
|
|
189
|
+
test("resolves an app from a standard-only plugin", () => {
|
|
190
|
+
const pluginDir = installPlugin("standard-plugin", { standard: true });
|
|
191
|
+
bundleApp(pluginDir, "dashboard");
|
|
192
|
+
|
|
193
|
+
const source = resolveAppSource("plugins~standard-plugin~dashboard");
|
|
194
|
+
expect(source?.sourceDir).toBe(join(pluginDir, "apps", "dashboard"));
|
|
195
|
+
expect(source?.origin).toEqual({
|
|
196
|
+
kind: "plugin",
|
|
197
|
+
pluginName: "standard-plugin",
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
169
201
|
test("returns null for an unknown workspace id", () => {
|
|
170
202
|
expect(resolveAppSource("11111111-1111-1111-1111-111111111111")).toBeNull();
|
|
171
203
|
});
|
|
@@ -177,7 +209,7 @@ describe("resolveAppSource", () => {
|
|
|
177
209
|
expect(resolveAppSource("plugins~acme~acme-dashboard")).toBeNull();
|
|
178
210
|
});
|
|
179
211
|
|
|
180
|
-
test("returns null for a plugin dir without a
|
|
212
|
+
test("returns null for a plugin dir without a root manifest", () => {
|
|
181
213
|
// Stray directory shaped like a plugin app but not an installed plugin.
|
|
182
214
|
const strayApp = join(
|
|
183
215
|
getWorkspacePluginsDir(),
|
|
@@ -3,11 +3,29 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
3
3
|
// ── Module mocks (must precede imports) ───────────────────────────────────────
|
|
4
4
|
|
|
5
5
|
const mockReloadMcpServers = mock(async () => {});
|
|
6
|
+
let pluginServers: Array<{
|
|
7
|
+
id: string;
|
|
8
|
+
pluginName: string;
|
|
9
|
+
serverKey: string;
|
|
10
|
+
config: {
|
|
11
|
+
source: "plugin";
|
|
12
|
+
pluginName: string;
|
|
13
|
+
serverKey: string;
|
|
14
|
+
transport: {
|
|
15
|
+
type: "streamable-http";
|
|
16
|
+
url: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
}> = [];
|
|
6
20
|
|
|
7
21
|
mock.module("../daemon/mcp-reload-service.js", () => ({
|
|
8
22
|
reloadMcpServers: () => mockReloadMcpServers(),
|
|
9
23
|
}));
|
|
10
24
|
|
|
25
|
+
mock.module("../plugins/mcp-servers.js", () => ({
|
|
26
|
+
readPluginMcpServers: () => ({ servers: pluginServers, issues: [] }),
|
|
27
|
+
}));
|
|
28
|
+
|
|
11
29
|
const mockOrchestrateConnect = mock(
|
|
12
30
|
async (_args: { serverId: string; transport: unknown }) => ({
|
|
13
31
|
auth_url: "https://provider.example.com/authorize?state=abc",
|
|
@@ -55,6 +73,7 @@ describe("mcp-auth-routes", () => {
|
|
|
55
73
|
mockGetMcpAuthState.mockClear();
|
|
56
74
|
mockGetMcpAuthState.mockImplementation(() => null);
|
|
57
75
|
mockReloadMcpServers.mockClear(); // ← add this line
|
|
76
|
+
pluginServers = [];
|
|
58
77
|
});
|
|
59
78
|
|
|
60
79
|
describe("POST internal/mcp/auth/start", () => {
|
|
@@ -68,6 +87,86 @@ describe("mcp-auth-routes", () => {
|
|
|
68
87
|
auth_url: "https://provider.example.com/authorize?state=abc",
|
|
69
88
|
state: "my-server",
|
|
70
89
|
});
|
|
90
|
+
expect(mockOrchestrateConnect).toHaveBeenCalledWith(
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
serverId: "my-server",
|
|
93
|
+
credentialTarget: {
|
|
94
|
+
source: "workspace",
|
|
95
|
+
serverId: "my-server",
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("resolves an installed plugin server by its public id", async () => {
|
|
102
|
+
pluginServers = [
|
|
103
|
+
{
|
|
104
|
+
id: "plugin-auth__remote",
|
|
105
|
+
pluginName: "plugin-auth",
|
|
106
|
+
serverKey: "remote",
|
|
107
|
+
config: {
|
|
108
|
+
source: "plugin",
|
|
109
|
+
pluginName: "plugin-auth",
|
|
110
|
+
serverKey: "remote",
|
|
111
|
+
transport: {
|
|
112
|
+
type: "streamable-http",
|
|
113
|
+
url: "https://mcp.example.com/plugin",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const startRoute = findRoute("internal_mcp_auth_start");
|
|
120
|
+
await startRoute.handler({
|
|
121
|
+
body: { serverId: "plugin-auth__remote" },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(mockOrchestrateConnect).toHaveBeenCalledWith(
|
|
125
|
+
expect.objectContaining({
|
|
126
|
+
serverId: "plugin-auth__remote",
|
|
127
|
+
credentialTarget: {
|
|
128
|
+
source: "plugin",
|
|
129
|
+
pluginName: "plugin-auth",
|
|
130
|
+
serverKey: "remote",
|
|
131
|
+
transportType: "streamable-http",
|
|
132
|
+
url: "https://mcp.example.com/plugin",
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("keeps workspace precedence when a plugin declares the same id", async () => {
|
|
139
|
+
pluginServers = [
|
|
140
|
+
{
|
|
141
|
+
id: "my-server",
|
|
142
|
+
pluginName: "my-server",
|
|
143
|
+
serverKey: "my-server",
|
|
144
|
+
config: {
|
|
145
|
+
source: "plugin",
|
|
146
|
+
pluginName: "my-server",
|
|
147
|
+
serverKey: "my-server",
|
|
148
|
+
transport: {
|
|
149
|
+
type: "streamable-http",
|
|
150
|
+
url: "https://loses.example.com/mcp",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
const startRoute = findRoute("internal_mcp_auth_start");
|
|
157
|
+
await startRoute.handler({ body: { serverId: "my-server" } });
|
|
158
|
+
|
|
159
|
+
expect(mockOrchestrateConnect).toHaveBeenCalledWith(
|
|
160
|
+
expect.objectContaining({
|
|
161
|
+
transport: expect.objectContaining({
|
|
162
|
+
url: "https://mcp.example.com",
|
|
163
|
+
}),
|
|
164
|
+
credentialTarget: {
|
|
165
|
+
source: "workspace",
|
|
166
|
+
serverId: "my-server",
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
71
170
|
});
|
|
72
171
|
|
|
73
172
|
test("rejects unknown serverId with BadRequestError", async () => {
|
|
@@ -130,6 +130,60 @@ describe("McpClient auth error detection", () => {
|
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
describe("McpClient connection lifecycle", () => {
|
|
134
|
+
test("a close during connect cannot restore a stale connected state", async () => {
|
|
135
|
+
const onUnexpectedClose = jest.fn();
|
|
136
|
+
const client = new McpClient("test-server", "workspace", onUnexpectedClose);
|
|
137
|
+
let finishConnect: (() => void) | undefined;
|
|
138
|
+
(client as any).createTransport = () => ({});
|
|
139
|
+
(client as any).client.connect = () =>
|
|
140
|
+
new Promise<void>((resolve) => {
|
|
141
|
+
finishConnect = resolve;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const connecting = client.connect(httpTransport);
|
|
145
|
+
while (!finishConnect) {
|
|
146
|
+
await Promise.resolve();
|
|
147
|
+
}
|
|
148
|
+
(client as any).client.onclose();
|
|
149
|
+
finishConnect!();
|
|
150
|
+
await connecting;
|
|
151
|
+
|
|
152
|
+
expect(client.isConnected).toBe(false);
|
|
153
|
+
expect(client.lastError?.message).toContain("closed during initialization");
|
|
154
|
+
expect(onUnexpectedClose).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("reports an unexpected close after connecting", async () => {
|
|
158
|
+
const onUnexpectedClose = jest.fn();
|
|
159
|
+
const client = new McpClient("test-server", "workspace", onUnexpectedClose);
|
|
160
|
+
(client as any).createTransport = () => ({});
|
|
161
|
+
(client as any).client.connect = async () => {};
|
|
162
|
+
|
|
163
|
+
await client.connect(httpTransport);
|
|
164
|
+
(client as any).client.onclose();
|
|
165
|
+
|
|
166
|
+
expect(client.isConnected).toBe(false);
|
|
167
|
+
expect(onUnexpectedClose).toHaveBeenCalledTimes(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("does not report an intentional disconnect as a transport failure", async () => {
|
|
171
|
+
const onUnexpectedClose = jest.fn();
|
|
172
|
+
const client = new McpClient("test-server", "workspace", onUnexpectedClose);
|
|
173
|
+
(client as any).createTransport = () => ({});
|
|
174
|
+
(client as any).client.connect = async () => {};
|
|
175
|
+
(client as any).client.close = async () => {
|
|
176
|
+
(client as any).client.onclose();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
await client.connect(httpTransport);
|
|
180
|
+
await client.disconnect();
|
|
181
|
+
|
|
182
|
+
expect(client.isConnected).toBe(false);
|
|
183
|
+
expect(onUnexpectedClose).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
133
187
|
describe("McpOAuthProvider redirectUrl", () => {
|
|
134
188
|
test("redirectUrl is undefined until startCallbackServer() is called", () => {
|
|
135
189
|
const nonInteractive = new McpOAuthProvider(
|
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
const
|
|
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
|
-
|
|
11
|
-
|
|
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("
|
|
55
|
+
describe("internal_mcp_list runtime status", () => {
|
|
60
56
|
beforeEach(() => {
|
|
61
|
-
|
|
62
|
-
mockDisconnect.mockReset();
|
|
63
|
-
mockIsConnected = true;
|
|
64
|
-
mockLastError = null;
|
|
57
|
+
getServerState.mockReset();
|
|
65
58
|
});
|
|
66
59
|
|
|
67
|
-
test("returns connected
|
|
68
|
-
|
|
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
|
|
79
|
-
|
|
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("
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
});
|