@minicor/mcp-server 4.7.0 → 4.8.0
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/README.md +18 -7
- package/dist/__tests__/config-stores.test.js +8 -7
- package/dist/__tests__/config-stores.test.js.map +1 -1
- package/dist/__tests__/dev-mode-draft.test.js +9 -10
- package/dist/__tests__/dev-mode-draft.test.js.map +1 -1
- package/dist/__tests__/discovery.test.js +6 -5
- package/dist/__tests__/discovery.test.js.map +1 -1
- package/dist/__tests__/helpers.test.js +20 -0
- package/dist/__tests__/helpers.test.js.map +1 -1
- package/dist/__tests__/http-server.test.js +118 -0
- package/dist/__tests__/http-server.test.js.map +1 -1
- package/dist/__tests__/mds-errors.test.d.ts +2 -0
- package/dist/__tests__/mds-errors.test.d.ts.map +1 -0
- package/dist/__tests__/mds-errors.test.js +91 -0
- package/dist/__tests__/mds-errors.test.js.map +1 -0
- package/dist/__tests__/state.test.js +104 -44
- package/dist/__tests__/state.test.js.map +1 -1
- package/dist/__tests__/vm-broker.test.d.ts +2 -0
- package/dist/__tests__/vm-broker.test.d.ts.map +1 -0
- package/dist/__tests__/vm-broker.test.js +812 -0
- package/dist/__tests__/vm-broker.test.js.map +1 -0
- package/dist/__tests__/vm-management.test.d.ts +2 -0
- package/dist/__tests__/vm-management.test.d.ts.map +1 -0
- package/dist/__tests__/vm-management.test.js +507 -0
- package/dist/__tests__/vm-management.test.js.map +1 -0
- package/dist/agent-service-client.d.ts +12 -0
- package/dist/agent-service-client.d.ts.map +1 -1
- package/dist/agent-service-client.js +74 -0
- package/dist/agent-service-client.js.map +1 -1
- package/dist/helpers.d.ts +14 -2
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +12 -3
- package/dist/helpers.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lds-client.d.ts +59 -10
- package/dist/lds-client.d.ts.map +1 -1
- package/dist/lds-client.js +53 -9
- package/dist/lds-client.js.map +1 -1
- package/dist/lib.d.ts +6 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +5 -1
- package/dist/lib.js.map +1 -1
- package/dist/mds-errors.d.ts +32 -0
- package/dist/mds-errors.d.ts.map +1 -0
- package/dist/mds-errors.js +61 -0
- package/dist/mds-errors.js.map +1 -0
- package/dist/server-surface.d.ts +2 -0
- package/dist/server-surface.d.ts.map +1 -1
- package/dist/server-surface.js +5 -2
- package/dist/server-surface.js.map +1 -1
- package/dist/state.d.ts +28 -14
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +58 -40
- package/dist/state.js.map +1 -1
- package/dist/tools/agents.d.ts.map +1 -1
- package/dist/tools/agents.js +2 -3
- package/dist/tools/agents.js.map +1 -1
- package/dist/tools/config-stores.d.ts.map +1 -1
- package/dist/tools/config-stores.js +11 -12
- package/dist/tools/config-stores.js.map +1 -1
- package/dist/tools/context.d.ts.map +1 -1
- package/dist/tools/context.js +1 -2
- package/dist/tools/context.js.map +1 -1
- package/dist/tools/core.d.ts.map +1 -1
- package/dist/tools/core.js +2 -3
- package/dist/tools/core.js.map +1 -1
- package/dist/tools/dev-mode-draft.d.ts.map +1 -1
- package/dist/tools/dev-mode-draft.js +16 -17
- package/dist/tools/dev-mode-draft.js.map +1 -1
- package/dist/tools/discovery.d.ts.map +1 -1
- package/dist/tools/discovery.js +7 -8
- package/dist/tools/discovery.js.map +1 -1
- package/dist/tools/issues.d.ts.map +1 -1
- package/dist/tools/issues.js +7 -8
- package/dist/tools/issues.js.map +1 -1
- package/dist/tools/jobs.d.ts.map +1 -1
- package/dist/tools/jobs.js +1 -2
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/session.d.ts.map +1 -1
- package/dist/tools/session.js +9 -10
- package/dist/tools/session.js.map +1 -1
- package/dist/tools/skills.d.ts.map +1 -1
- package/dist/tools/skills.js +2 -3
- package/dist/tools/skills.js.map +1 -1
- package/dist/tools/sync-tools.d.ts.map +1 -1
- package/dist/tools/sync-tools.js +2 -3
- package/dist/tools/sync-tools.js.map +1 -1
- package/dist/tools/tasks.d.ts +3 -0
- package/dist/tools/tasks.d.ts.map +1 -0
- package/dist/tools/tasks.js +51 -0
- package/dist/tools/tasks.js.map +1 -0
- package/dist/tools/tfa.d.ts.map +1 -1
- package/dist/tools/tfa.js +14 -5
- package/dist/tools/tfa.js.map +1 -1
- package/dist/tools/vm-rpa.d.ts +2 -1
- package/dist/tools/vm-rpa.d.ts.map +1 -1
- package/dist/tools/vm-rpa.js +82 -38
- package/dist/tools/vm-rpa.js.map +1 -1
- package/dist/tools/vm.d.ts +2 -1
- package/dist/tools/vm.d.ts.map +1 -1
- package/dist/tools/vm.js +511 -114
- package/dist/tools/vm.js.map +1 -1
- package/dist/tools/workflow-ops.d.ts.map +1 -1
- package/dist/tools/workflow-ops.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vm-service-client.d.ts +8 -2
- package/dist/vm-service-client.d.ts.map +1 -1
- package/dist/vm-service-client.js.map +1 -1
- package/dist/vm-target.d.ts +55 -0
- package/dist/vm-target.d.ts.map +1 -0
- package/dist/vm-target.js +134 -0
- package/dist/vm-target.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-session (broker) coverage for the direct VM tools (plan §4.5):
|
|
3
|
+
* two-server isolation, standalone compatibility, broker targetUser routing,
|
|
4
|
+
* session tools, hosted gating, and mode-aware install/start refusals.
|
|
5
|
+
*/
|
|
6
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { createMinicorServer } from "../lib.js";
|
|
10
|
+
import * as lds from "../lds-client.js";
|
|
11
|
+
import { createSessionState } from "../state.js";
|
|
12
|
+
import { register as registerVmTools } from "../tools/vm.js";
|
|
13
|
+
// ── Fixtures ──────────────────────────────────────────────────
|
|
14
|
+
const STANDALONE_HEALTH = { status: "healthy", version: "1.0.0", uptime: 5 };
|
|
15
|
+
const BROKER_HEALTH = { ...STANDALONE_HEALTH, mode: "broker" };
|
|
16
|
+
const BROKER_STATUS = {
|
|
17
|
+
broker: { version: "0.4.0", uptimeS: 123 },
|
|
18
|
+
users: [
|
|
19
|
+
{
|
|
20
|
+
user: "robot1",
|
|
21
|
+
state: "active",
|
|
22
|
+
sessionId: 1,
|
|
23
|
+
agent: { state: "idle", executionId: null, version: "0.4.0", lastBeatMs: 1200, pendingJobs: 0 },
|
|
24
|
+
},
|
|
25
|
+
{ user: "robot2", state: "stopped", sessionId: null, agent: null },
|
|
26
|
+
],
|
|
27
|
+
queues: { robot1: 0, robot2: 2 },
|
|
28
|
+
};
|
|
29
|
+
const EXEC_OK = {
|
|
30
|
+
success: true,
|
|
31
|
+
skipped: false,
|
|
32
|
+
stopped: false,
|
|
33
|
+
exitCode: 0,
|
|
34
|
+
stdout: "hi",
|
|
35
|
+
stderr: "",
|
|
36
|
+
executionTimeMs: 12,
|
|
37
|
+
};
|
|
38
|
+
const SCREENSHOT_OK = {
|
|
39
|
+
success: true,
|
|
40
|
+
image: "aW1hZ2U=",
|
|
41
|
+
metadata: {
|
|
42
|
+
width: 10,
|
|
43
|
+
height: 10,
|
|
44
|
+
format: "png",
|
|
45
|
+
mime_type: "image/png",
|
|
46
|
+
size_bytes: 6,
|
|
47
|
+
timestamp: "2026-07-25T00:00:00Z",
|
|
48
|
+
capture_duration_ms: 3,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
// ── Harness ───────────────────────────────────────────────────
|
|
52
|
+
function jsonRes(body, status = 200) {
|
|
53
|
+
return new Response(JSON.stringify(body), {
|
|
54
|
+
status,
|
|
55
|
+
headers: { "Content-Type": "application/json" },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Stub global fetch with a URL router; unexpected requests fail the test. */
|
|
59
|
+
function stubFetch(route) {
|
|
60
|
+
const mock = vi.fn(async (input, init) => {
|
|
61
|
+
const url = new URL(String(input));
|
|
62
|
+
const res = route(url, init);
|
|
63
|
+
if (!res) {
|
|
64
|
+
throw new Error(`Unexpected fetch in test: ${init?.method ?? "GET"} ${url}`);
|
|
65
|
+
}
|
|
66
|
+
return res;
|
|
67
|
+
});
|
|
68
|
+
vi.stubGlobal("fetch", mock);
|
|
69
|
+
return mock;
|
|
70
|
+
}
|
|
71
|
+
async function startServer(opts = {}) {
|
|
72
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
73
|
+
const embedded = await createMinicorServer(serverTransport, {
|
|
74
|
+
localTools: false,
|
|
75
|
+
codeMode: false,
|
|
76
|
+
directVmTools: opts.directVmTools,
|
|
77
|
+
credentials: {
|
|
78
|
+
access_token: "test-token",
|
|
79
|
+
refresh_token: "test-refresh-token",
|
|
80
|
+
expires_at: Date.now() + 3_600_000,
|
|
81
|
+
},
|
|
82
|
+
baseUrl: "https://api.test.com",
|
|
83
|
+
// stubFetch throws on unmatched hosts, so pin the VM service.
|
|
84
|
+
vmServiceBaseUrl: "https://vms.test.com",
|
|
85
|
+
});
|
|
86
|
+
const client = new Client({ name: "test-client", version: "1.0.0" });
|
|
87
|
+
await client.connect(clientTransport);
|
|
88
|
+
return {
|
|
89
|
+
client,
|
|
90
|
+
close: async () => {
|
|
91
|
+
await client.close();
|
|
92
|
+
await embedded.close();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function textOf(result) {
|
|
97
|
+
return result.content
|
|
98
|
+
.filter((c) => c.type === "text")
|
|
99
|
+
.map((c) => c.text ?? "")
|
|
100
|
+
.join("\n");
|
|
101
|
+
}
|
|
102
|
+
function callsTo(mock, pathname) {
|
|
103
|
+
return mock.mock.calls.filter(([input]) => new URL(String(input)).pathname === pathname);
|
|
104
|
+
}
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.unstubAllGlobals();
|
|
107
|
+
});
|
|
108
|
+
// ── Two-server isolation (plan §4.5.1 / §4.5.10) ──────────────
|
|
109
|
+
describe("two-server isolation", () => {
|
|
110
|
+
it("keeps each server's VM connection private to that instance", async () => {
|
|
111
|
+
stubFetch((url) => url.pathname === "/health" ? jsonRes(STANDALONE_HEALTH) : undefined);
|
|
112
|
+
const a = await startServer();
|
|
113
|
+
const b = await startServer();
|
|
114
|
+
try {
|
|
115
|
+
const connected = await a.client.callTool({
|
|
116
|
+
name: "vm_connect",
|
|
117
|
+
arguments: { url: "https://vm-standalone.test" },
|
|
118
|
+
});
|
|
119
|
+
expect(textOf(connected)).toContain('"connected": true');
|
|
120
|
+
// Server B never connected — its tools must not see A's connection.
|
|
121
|
+
const statusB = await b.client.callTool({ name: "vm_status", arguments: {} });
|
|
122
|
+
expect(textOf(statusB)).toContain("No VM target");
|
|
123
|
+
const execB = await b.client.callTool({
|
|
124
|
+
name: "vm_execute_script",
|
|
125
|
+
arguments: { script: "print(1)" },
|
|
126
|
+
});
|
|
127
|
+
expect(textOf(execB)).toContain("No VM target");
|
|
128
|
+
expect(textOf(execB)).not.toContain("vm-standalone.test");
|
|
129
|
+
// A stays connected, and B disconnecting must not touch A.
|
|
130
|
+
await b.client.callTool({ name: "vm_disconnect", arguments: {} });
|
|
131
|
+
const statusA = await a.client.callTool({ name: "vm_status", arguments: {} });
|
|
132
|
+
expect(textOf(statusA)).toContain('"connected": true');
|
|
133
|
+
expect(textOf(statusA)).toContain("vm-standalone.test");
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await a.close();
|
|
137
|
+
await b.close();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
// ── Per-call VM targeting (vmId + workspaceId, no vm_connect) ──
|
|
142
|
+
const CREDENTIALS_PATH = "/workspaces/42/vms/7/credentials";
|
|
143
|
+
function routeResolvedVm(opts = {}) {
|
|
144
|
+
return (url) => {
|
|
145
|
+
if (url.hostname === "vms.test.com" && url.pathname === CREDENTIALS_PATH) {
|
|
146
|
+
return jsonRes(opts.credentials ?? {
|
|
147
|
+
id: 7,
|
|
148
|
+
vm_name: "vm-seven",
|
|
149
|
+
tunnel_url: "https://vm-seven.test",
|
|
150
|
+
api_key: "key-7",
|
|
151
|
+
service_id: "svc-7",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (url.hostname !== "vm-seven.test")
|
|
155
|
+
return undefined;
|
|
156
|
+
if (url.pathname === "/health")
|
|
157
|
+
return jsonRes(opts.health ?? STANDALONE_HEALTH);
|
|
158
|
+
if (url.pathname === "/screenshot")
|
|
159
|
+
return jsonRes(SCREENSHOT_OK);
|
|
160
|
+
if (url.pathname === "/execute")
|
|
161
|
+
return jsonRes(EXEC_OK);
|
|
162
|
+
if (url.pathname === "/status")
|
|
163
|
+
return jsonRes(BROKER_STATUS);
|
|
164
|
+
return undefined;
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
describe("per-call VM targeting", () => {
|
|
168
|
+
it("resolves the VM on every call without a prior vm_connect", async () => {
|
|
169
|
+
const fetchMock = stubFetch(routeResolvedVm());
|
|
170
|
+
const s = await startServer();
|
|
171
|
+
try {
|
|
172
|
+
const shot = await s.client.callTool({
|
|
173
|
+
name: "vm_screenshot",
|
|
174
|
+
arguments: { vmId: 7, workspaceId: 42 },
|
|
175
|
+
});
|
|
176
|
+
expect(shot.content.some((c) => c.type === "image")).toBe(true);
|
|
177
|
+
const exec = await s.client.callTool({
|
|
178
|
+
name: "vm_execute_script",
|
|
179
|
+
arguments: { vmId: 7, workspaceId: 42, script: "print(1)", targetUser: "robot2" },
|
|
180
|
+
});
|
|
181
|
+
expect(textOf(exec)).toContain('"success": true');
|
|
182
|
+
expect(callsTo(fetchMock, CREDENTIALS_PATH)).toHaveLength(2);
|
|
183
|
+
for (const [, init] of callsTo(fetchMock, CREDENTIALS_PATH)) {
|
|
184
|
+
expect(init?.headers?.["Authorization"]).toBe("Bearer test-token");
|
|
185
|
+
}
|
|
186
|
+
const [, execInit] = fetchMock.mock.calls.find(([input]) => new URL(String(input)).pathname === "/execute");
|
|
187
|
+
const execHeaders = execInit?.headers;
|
|
188
|
+
expect(execHeaders["X-API-Key"]).toBe("key-7");
|
|
189
|
+
expect(execHeaders["X-Service-ID"]).toBe("svc-7");
|
|
190
|
+
expect(JSON.parse(String(execInit?.body)).targetUser).toBe("robot2");
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
await s.close();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
it("routes vm-rpa tools (debug_rpa_step) the same way", async () => {
|
|
197
|
+
stubFetch(routeResolvedVm());
|
|
198
|
+
const s = await startServer();
|
|
199
|
+
try {
|
|
200
|
+
const result = await s.client.callTool({
|
|
201
|
+
name: "debug_rpa_step",
|
|
202
|
+
arguments: { vmId: 7, workspaceId: 42, script: "print(1)" },
|
|
203
|
+
});
|
|
204
|
+
expect(result.content.filter((c) => c.type === "image")).toHaveLength(2);
|
|
205
|
+
expect(textOf(result)).toContain('"success": true');
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
await s.close();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
it("demands both vmId and workspaceId, never half a target", async () => {
|
|
212
|
+
stubFetch(() => undefined); // any network call fails the test
|
|
213
|
+
const s = await startServer();
|
|
214
|
+
try {
|
|
215
|
+
const half = await s.client.callTool({
|
|
216
|
+
name: "vm_execute_script",
|
|
217
|
+
arguments: { vmId: 7, script: "print(1)" },
|
|
218
|
+
});
|
|
219
|
+
expect(textOf(half)).toContain("BOTH vmId and workspaceId");
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
await s.close();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
it("points an untargeted call at both ways to name a VM", async () => {
|
|
226
|
+
stubFetch(() => undefined);
|
|
227
|
+
const s = await startServer();
|
|
228
|
+
try {
|
|
229
|
+
const none = await s.client.callTool({
|
|
230
|
+
name: "vm_execute_script",
|
|
231
|
+
arguments: { script: "print(1)" },
|
|
232
|
+
});
|
|
233
|
+
const msg = textOf(none);
|
|
234
|
+
expect(msg).toContain("vmId");
|
|
235
|
+
expect(msg).toContain("workspaceId");
|
|
236
|
+
expect(msg).toContain("vm_connect");
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
await s.close();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
it("surfaces a VM that has no tunnel URL instead of fetching undefined", async () => {
|
|
243
|
+
stubFetch(routeResolvedVm({ credentials: { id: 7, vm_name: "vm-seven", tunnel_url: null } }));
|
|
244
|
+
const s = await startServer();
|
|
245
|
+
try {
|
|
246
|
+
const result = await s.client.callTool({
|
|
247
|
+
name: "vm_screenshot",
|
|
248
|
+
arguments: { vmId: 7, workspaceId: 42 },
|
|
249
|
+
});
|
|
250
|
+
expect(textOf(result)).toContain("no tunnel URL");
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
await s.close();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
it("records the resolved VM so a later untargeted call reuses it", async () => {
|
|
257
|
+
stubFetch(routeResolvedVm());
|
|
258
|
+
const s = await startServer();
|
|
259
|
+
try {
|
|
260
|
+
await s.client.callTool({
|
|
261
|
+
name: "vm_execute_script",
|
|
262
|
+
arguments: { vmId: 7, workspaceId: 42, script: "print(1)" },
|
|
263
|
+
});
|
|
264
|
+
const status = await s.client.callTool({ name: "vm_status", arguments: {} });
|
|
265
|
+
expect(textOf(status)).toContain("vm-seven.test");
|
|
266
|
+
expect(textOf(status)).toContain('"connected": true');
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
await s.close();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
it("reports the VM service's refusal rather than a bare failure", async () => {
|
|
273
|
+
stubFetch((url) => url.hostname === "vms.test.com" && url.pathname === CREDENTIALS_PATH
|
|
274
|
+
? jsonRes({ detail: "forbidden" }, 403)
|
|
275
|
+
: undefined);
|
|
276
|
+
const s = await startServer();
|
|
277
|
+
try {
|
|
278
|
+
const result = await s.client.callTool({
|
|
279
|
+
name: "vm_screenshot",
|
|
280
|
+
arguments: { vmId: 7, workspaceId: 42 },
|
|
281
|
+
});
|
|
282
|
+
expect(textOf(result)).toContain("HTTP 403");
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
await s.close();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
it("connects by vmId + workspaceId with a cold cache (no vm_list first)", async () => {
|
|
289
|
+
stubFetch(routeResolvedVm({ health: BROKER_HEALTH }));
|
|
290
|
+
const s = await startServer();
|
|
291
|
+
try {
|
|
292
|
+
const result = await s.client.callTool({
|
|
293
|
+
name: "vm_connect",
|
|
294
|
+
arguments: { vmId: 7, workspaceId: 42 },
|
|
295
|
+
});
|
|
296
|
+
const msg = textOf(result);
|
|
297
|
+
expect(msg).toContain('"connected": true');
|
|
298
|
+
expect(msg).toContain('"mode": "broker"');
|
|
299
|
+
expect(msg).toContain("robot1");
|
|
300
|
+
expect(msg).toContain("without depending on the connection persisting");
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
await s.close();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
// ── Standalone compatibility (no behavior change) ─────────────
|
|
308
|
+
describe("standalone compatibility", () => {
|
|
309
|
+
it("reports standalone mode even for pre-multi-session MDS without health.mode", async () => {
|
|
310
|
+
const fetchMock = stubFetch((url) => url.pathname === "/health" ? jsonRes(STANDALONE_HEALTH) : undefined);
|
|
311
|
+
const server = await startServer();
|
|
312
|
+
try {
|
|
313
|
+
const connected = await server.client.callTool({
|
|
314
|
+
name: "vm_connect",
|
|
315
|
+
arguments: { url: "https://vm-standalone.test" },
|
|
316
|
+
});
|
|
317
|
+
expect(textOf(connected)).toContain('"mode": "standalone"');
|
|
318
|
+
// No broker /status probe on standalone connects.
|
|
319
|
+
expect(callsTo(fetchMock, "/status")).toHaveLength(0);
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
await server.close();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
it("sends no targetUser field and no screenshot query on standalone VMs", async () => {
|
|
326
|
+
const fetchMock = stubFetch((url) => {
|
|
327
|
+
if (url.pathname === "/health")
|
|
328
|
+
return jsonRes(STANDALONE_HEALTH);
|
|
329
|
+
if (url.pathname === "/execute")
|
|
330
|
+
return jsonRes(EXEC_OK);
|
|
331
|
+
if (url.pathname === "/screenshot")
|
|
332
|
+
return jsonRes(SCREENSHOT_OK);
|
|
333
|
+
return undefined;
|
|
334
|
+
});
|
|
335
|
+
const server = await startServer();
|
|
336
|
+
try {
|
|
337
|
+
await server.client.callTool({
|
|
338
|
+
name: "vm_connect",
|
|
339
|
+
arguments: { url: "https://vm-standalone.test" },
|
|
340
|
+
});
|
|
341
|
+
const exec = await server.client.callTool({
|
|
342
|
+
name: "vm_execute_script",
|
|
343
|
+
arguments: { script: "print(1)" },
|
|
344
|
+
});
|
|
345
|
+
expect(textOf(exec)).toContain('"success": true');
|
|
346
|
+
const [, execInit] = callsTo(fetchMock, "/execute")[0];
|
|
347
|
+
const body = JSON.parse(String(execInit?.body));
|
|
348
|
+
expect(body.script).toBe("print(1)");
|
|
349
|
+
// The key must be ABSENT — standalone MDS rejects targetUser-bearing
|
|
350
|
+
// requests with typed not_supported_on_standalone.
|
|
351
|
+
expect("targetUser" in body).toBe(false);
|
|
352
|
+
await server.client.callTool({ name: "vm_screenshot", arguments: {} });
|
|
353
|
+
const [screenshotUrl] = callsTo(fetchMock, "/screenshot")[0];
|
|
354
|
+
expect(String(screenshotUrl)).toBe("https://vm-standalone.test/screenshot");
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
await server.close();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
// ── Broker routing ────────────────────────────────────────────
|
|
362
|
+
function brokerRoutes(overrides = () => undefined) {
|
|
363
|
+
return (url, init) => {
|
|
364
|
+
const custom = overrides(url, init);
|
|
365
|
+
if (custom)
|
|
366
|
+
return custom;
|
|
367
|
+
if (url.pathname === "/health")
|
|
368
|
+
return jsonRes(BROKER_HEALTH);
|
|
369
|
+
if (url.pathname === "/status")
|
|
370
|
+
return jsonRes(BROKER_STATUS);
|
|
371
|
+
return undefined;
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
async function connectBroker(server) {
|
|
375
|
+
return server.client.callTool({
|
|
376
|
+
name: "vm_connect",
|
|
377
|
+
arguments: {
|
|
378
|
+
url: "https://vm-broker.test",
|
|
379
|
+
apiKey: "key-1",
|
|
380
|
+
serviceId: "svc-1",
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
describe("broker routing", () => {
|
|
385
|
+
it("discovers broker mode on connect and lists the registered users", async () => {
|
|
386
|
+
stubFetch(brokerRoutes());
|
|
387
|
+
const server = await startServer();
|
|
388
|
+
try {
|
|
389
|
+
const connected = await connectBroker(server);
|
|
390
|
+
const out = textOf(connected);
|
|
391
|
+
expect(out).toContain('"mode": "broker"');
|
|
392
|
+
expect(out).toContain("robot1");
|
|
393
|
+
expect(out).toContain("robot2");
|
|
394
|
+
expect(out).toContain("targetUser");
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
await server.close();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
it("passes targetUser through to POST /execute and surfaces attribution", async () => {
|
|
401
|
+
const fetchMock = stubFetch(brokerRoutes((url) => url.pathname === "/execute"
|
|
402
|
+
? jsonRes({ ...EXEC_OK, targetUser: "robot2", sessionId: 2, queueWaitMs: 340 })
|
|
403
|
+
: undefined));
|
|
404
|
+
const server = await startServer();
|
|
405
|
+
try {
|
|
406
|
+
await connectBroker(server);
|
|
407
|
+
const exec = await server.client.callTool({
|
|
408
|
+
name: "vm_execute_script",
|
|
409
|
+
arguments: { script: "print(1)", targetUser: "robot2" },
|
|
410
|
+
});
|
|
411
|
+
const [, execInit] = callsTo(fetchMock, "/execute")[0];
|
|
412
|
+
const body = JSON.parse(String(execInit?.body));
|
|
413
|
+
expect(body.targetUser).toBe("robot2");
|
|
414
|
+
// Auth headers ride along.
|
|
415
|
+
const headers = execInit?.headers;
|
|
416
|
+
expect(headers["X-API-Key"]).toBe("key-1");
|
|
417
|
+
expect(headers["X-Service-ID"]).toBe("svc-1");
|
|
418
|
+
const out = textOf(exec);
|
|
419
|
+
expect(out).toContain('"targetUser": "robot2"');
|
|
420
|
+
expect(out).not.toContain("queueWaitMs");
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
await server.close();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
it("surfaces the typed target_user_required error with the registered users", async () => {
|
|
427
|
+
stubFetch(brokerRoutes((url) => url.pathname === "/execute"
|
|
428
|
+
? jsonRes({
|
|
429
|
+
success: false,
|
|
430
|
+
error: "target_user_required",
|
|
431
|
+
detail: "multi-session host: set targetUser",
|
|
432
|
+
knownUsers: ["robot1", "robot2"],
|
|
433
|
+
}, 400)
|
|
434
|
+
: undefined));
|
|
435
|
+
const server = await startServer();
|
|
436
|
+
try {
|
|
437
|
+
await connectBroker(server);
|
|
438
|
+
const exec = await server.client.callTool({
|
|
439
|
+
name: "vm_execute_script",
|
|
440
|
+
arguments: { script: "print(1)" },
|
|
441
|
+
});
|
|
442
|
+
const out = textOf(exec);
|
|
443
|
+
expect(out).toContain("target_user_required");
|
|
444
|
+
expect(out).toContain("Registered users on this VM: robot1, robot2");
|
|
445
|
+
expect(out).toContain("vm_list_sessions");
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
await server.close();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
it("routes screenshots per user via the targetUser query parameter", async () => {
|
|
452
|
+
const fetchMock = stubFetch(brokerRoutes((url) => url.pathname === "/screenshot" ? jsonRes(SCREENSHOT_OK) : undefined));
|
|
453
|
+
const server = await startServer();
|
|
454
|
+
try {
|
|
455
|
+
await connectBroker(server);
|
|
456
|
+
await server.client.callTool({
|
|
457
|
+
name: "vm_screenshot",
|
|
458
|
+
arguments: { targetUser: "robot2" },
|
|
459
|
+
});
|
|
460
|
+
const [screenshotUrl] = callsTo(fetchMock, "/screenshot")[0];
|
|
461
|
+
expect(String(screenshotUrl)).toBe("https://vm-broker.test/screenshot?targetUser=robot2");
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
await server.close();
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
it("vm_list_sessions reads broker /status with API-key auth", async () => {
|
|
468
|
+
const fetchMock = stubFetch(brokerRoutes());
|
|
469
|
+
const server = await startServer();
|
|
470
|
+
try {
|
|
471
|
+
await connectBroker(server);
|
|
472
|
+
const sessions = await server.client.callTool({
|
|
473
|
+
name: "vm_list_sessions",
|
|
474
|
+
arguments: {},
|
|
475
|
+
});
|
|
476
|
+
const out = textOf(sessions);
|
|
477
|
+
expect(out).toContain('"user": "robot1"');
|
|
478
|
+
expect(out).toContain('"state": "stopped"');
|
|
479
|
+
expect(out).toContain('"robot2": 2');
|
|
480
|
+
const statusCalls = callsTo(fetchMock, "/status");
|
|
481
|
+
expect(statusCalls.length).toBeGreaterThan(0);
|
|
482
|
+
const [, statusInit] = statusCalls[statusCalls.length - 1];
|
|
483
|
+
const headers = statusInit?.headers;
|
|
484
|
+
expect(headers["X-API-Key"]).toBe("key-1");
|
|
485
|
+
expect(headers["X-Service-ID"]).toBe("svc-1");
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
await server.close();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
it("vm_list_sessions explains itself on a standalone VM instead of failing", async () => {
|
|
492
|
+
const fetchMock = stubFetch((url) => url.pathname === "/health" ? jsonRes(STANDALONE_HEALTH) : undefined);
|
|
493
|
+
const server = await startServer();
|
|
494
|
+
try {
|
|
495
|
+
await server.client.callTool({
|
|
496
|
+
name: "vm_connect",
|
|
497
|
+
arguments: { url: "https://vm-standalone.test" },
|
|
498
|
+
});
|
|
499
|
+
const sessions = await server.client.callTool({
|
|
500
|
+
name: "vm_list_sessions",
|
|
501
|
+
arguments: {},
|
|
502
|
+
});
|
|
503
|
+
expect(textOf(sessions)).toContain("'standalone' mode");
|
|
504
|
+
expect(callsTo(fetchMock, "/status")).toHaveLength(0);
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
await server.close();
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
it("vm_start_session and vm_stop_session hit the /sessions/{user} endpoints", async () => {
|
|
511
|
+
const fetchMock = stubFetch(brokerRoutes((url, init) => {
|
|
512
|
+
if (url.pathname === "/sessions/robot1/start" && init?.method === "POST") {
|
|
513
|
+
return jsonRes({ success: true, user: "robot1", state: "active", sessionId: 1 });
|
|
514
|
+
}
|
|
515
|
+
if (url.pathname === "/sessions/robot2/stop" && init?.method === "POST") {
|
|
516
|
+
return jsonRes({ success: true, user: "robot2", state: "stopped" });
|
|
517
|
+
}
|
|
518
|
+
return undefined;
|
|
519
|
+
}));
|
|
520
|
+
const server = await startServer();
|
|
521
|
+
try {
|
|
522
|
+
await connectBroker(server);
|
|
523
|
+
const started = await server.client.callTool({
|
|
524
|
+
name: "vm_start_session",
|
|
525
|
+
arguments: { user: "robot1" },
|
|
526
|
+
});
|
|
527
|
+
expect(textOf(started)).toContain('"state": "active"');
|
|
528
|
+
expect(callsTo(fetchMock, "/sessions/robot1/start")).toHaveLength(1);
|
|
529
|
+
const stopped = await server.client.callTool({
|
|
530
|
+
name: "vm_stop_session",
|
|
531
|
+
arguments: { user: "robot2" },
|
|
532
|
+
});
|
|
533
|
+
expect(textOf(stopped)).toContain('"state": "stopped"');
|
|
534
|
+
expect(callsTo(fetchMock, "/sessions/robot2/stop")).toHaveLength(1);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
await server.close();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
it("renders the typed session_start_disabled refusal readably", async () => {
|
|
541
|
+
stubFetch(brokerRoutes((url, init) => url.pathname === "/sessions/robot1/start" && init?.method === "POST"
|
|
542
|
+
? jsonRes({
|
|
543
|
+
success: false,
|
|
544
|
+
error: "session_start_disabled",
|
|
545
|
+
detail: "keeper_enabled=false",
|
|
546
|
+
}, 409)
|
|
547
|
+
: undefined));
|
|
548
|
+
const server = await startServer();
|
|
549
|
+
try {
|
|
550
|
+
await connectBroker(server);
|
|
551
|
+
const started = await server.client.callTool({
|
|
552
|
+
name: "vm_start_session",
|
|
553
|
+
arguments: { user: "robot1" },
|
|
554
|
+
});
|
|
555
|
+
const out = textOf(started);
|
|
556
|
+
expect(out).toContain("session_start_disabled");
|
|
557
|
+
expect(out).toContain("keeper_enabled=false");
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
await server.close();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
// ── Hosted gating (plan §4.5.3) ───────────────────────────────
|
|
565
|
+
describe("hosted stateless server (directVmTools=false)", () => {
|
|
566
|
+
it("keeps the tool surface identical but answers direct VM tools with a local-install pointer", async () => {
|
|
567
|
+
stubFetch(() => undefined); // no network traffic may happen
|
|
568
|
+
const hosted = await startServer({ directVmTools: false });
|
|
569
|
+
const local = await startServer({ directVmTools: true });
|
|
570
|
+
try {
|
|
571
|
+
const [hostedTools, localTools] = await Promise.all([
|
|
572
|
+
hosted.client.listTools(),
|
|
573
|
+
local.client.listTools(),
|
|
574
|
+
]);
|
|
575
|
+
expect(hostedTools.tools.map((t) => t.name).sort()).toEqual(localTools.tools.map((t) => t.name).sort());
|
|
576
|
+
for (const name of [
|
|
577
|
+
"vm_connect",
|
|
578
|
+
"vm_execute_script",
|
|
579
|
+
"vm_screenshot",
|
|
580
|
+
"vm_list_sessions",
|
|
581
|
+
"vm_start_session",
|
|
582
|
+
"debug_rpa_step",
|
|
583
|
+
]) {
|
|
584
|
+
const result = await hosted.client.callTool({
|
|
585
|
+
name,
|
|
586
|
+
arguments: name === "vm_execute_script"
|
|
587
|
+
? { script: "print(1)" }
|
|
588
|
+
: name === "vm_start_session"
|
|
589
|
+
? { user: "robot1" }
|
|
590
|
+
: name === "debug_rpa_step"
|
|
591
|
+
? { script: "print(1)" }
|
|
592
|
+
: {},
|
|
593
|
+
});
|
|
594
|
+
expect(textOf(result), name).toContain("hosted Minicor MCP server");
|
|
595
|
+
expect(textOf(result), name).toContain("local MCP install");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
await hosted.close();
|
|
600
|
+
await local.close();
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
// ── create_rpa_flow broker placeholder (plan §4.5.7) ──────────
|
|
605
|
+
describe("create_rpa_flow target-user placeholder", () => {
|
|
606
|
+
async function createFlowProgram(server, extraArgs = {}) {
|
|
607
|
+
const result = await server.client.callTool({
|
|
608
|
+
name: "create_rpa_flow",
|
|
609
|
+
arguments: {
|
|
610
|
+
workflowId: 42,
|
|
611
|
+
name: "Step",
|
|
612
|
+
description: "Desc",
|
|
613
|
+
pythonScript: "print(1)",
|
|
614
|
+
executionOrder: 1,
|
|
615
|
+
flowId: "step-1",
|
|
616
|
+
...extraArgs,
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
expect(textOf(result)).toContain('"flow"');
|
|
620
|
+
return textOf(result);
|
|
621
|
+
}
|
|
622
|
+
function withFlowsEndpoint(base) {
|
|
623
|
+
const capturedPrograms = [];
|
|
624
|
+
const route = (url, init) => {
|
|
625
|
+
if (url.pathname === "/flows" && init?.method === "POST") {
|
|
626
|
+
const body = JSON.parse(String(init.body));
|
|
627
|
+
capturedPrograms.push(body.program);
|
|
628
|
+
return jsonRes({ id: 7, ...body });
|
|
629
|
+
}
|
|
630
|
+
return base(url, init);
|
|
631
|
+
};
|
|
632
|
+
return { route, capturedPrograms };
|
|
633
|
+
}
|
|
634
|
+
it("includes the config placeholder when connected to a broker (inferred)", async () => {
|
|
635
|
+
const { route, capturedPrograms } = withFlowsEndpoint(brokerRoutes());
|
|
636
|
+
stubFetch(route);
|
|
637
|
+
const server = await startServer();
|
|
638
|
+
try {
|
|
639
|
+
await connectBroker(server);
|
|
640
|
+
const out = await createFlowProgram(server);
|
|
641
|
+
expect(capturedPrograms[0]).toContain('"targetUser": "{{config.minicor_desktop_service_target_user}}"');
|
|
642
|
+
expect(out).toContain("minicor_desktop_service_target_user");
|
|
643
|
+
}
|
|
644
|
+
finally {
|
|
645
|
+
await server.close();
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
it("keeps standalone-generated programs free of targetUser", async () => {
|
|
649
|
+
const { route, capturedPrograms } = withFlowsEndpoint((url) => url.pathname === "/health" ? jsonRes(STANDALONE_HEALTH) : undefined);
|
|
650
|
+
stubFetch(route);
|
|
651
|
+
const server = await startServer();
|
|
652
|
+
try {
|
|
653
|
+
await server.client.callTool({
|
|
654
|
+
name: "vm_connect",
|
|
655
|
+
arguments: { url: "https://vm-standalone.test" },
|
|
656
|
+
});
|
|
657
|
+
await createFlowProgram(server);
|
|
658
|
+
expect(capturedPrograms[0]).not.toContain("targetUser");
|
|
659
|
+
}
|
|
660
|
+
finally {
|
|
661
|
+
await server.close();
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
it("honors the explicit forBroker flag without any VM connection", async () => {
|
|
665
|
+
const { route, capturedPrograms } = withFlowsEndpoint(() => undefined);
|
|
666
|
+
stubFetch(route);
|
|
667
|
+
const server = await startServer();
|
|
668
|
+
try {
|
|
669
|
+
await createFlowProgram(server, { forBroker: true });
|
|
670
|
+
expect(capturedPrograms[0]).toContain('"targetUser": "{{config.minicor_desktop_service_target_user}}"');
|
|
671
|
+
await createFlowProgram(server, { forBroker: false });
|
|
672
|
+
expect(capturedPrograms[1]).not.toContain("targetUser");
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
await server.close();
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
// ── vm_execution_control stays removed (plan §4.5.9) ──────────
|
|
680
|
+
describe("execution-control removal", () => {
|
|
681
|
+
it("exposes the session tools but never vm_execution_control", async () => {
|
|
682
|
+
stubFetch(() => undefined);
|
|
683
|
+
const server = await startServer();
|
|
684
|
+
try {
|
|
685
|
+
const tools = (await server.client.listTools()).tools.map((t) => t.name);
|
|
686
|
+
expect(tools).toContain("vm_list_sessions");
|
|
687
|
+
expect(tools).toContain("vm_start_session");
|
|
688
|
+
expect(tools).toContain("vm_stop_session");
|
|
689
|
+
expect(tools).not.toContain("vm_execution_control");
|
|
690
|
+
}
|
|
691
|
+
finally {
|
|
692
|
+
await server.close();
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
it("dropped the LDS client's executionControl entry point", () => {
|
|
696
|
+
expect(lds.executionControl).toBeUndefined();
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
function unitSetup() {
|
|
700
|
+
const handlers = new Map();
|
|
701
|
+
const server = {
|
|
702
|
+
tool: vi.fn((name, ...args) => {
|
|
703
|
+
handlers.set(name, args.at(-1));
|
|
704
|
+
}),
|
|
705
|
+
};
|
|
706
|
+
const state = createSessionState();
|
|
707
|
+
const deps = {
|
|
708
|
+
server,
|
|
709
|
+
state,
|
|
710
|
+
client: vi.fn(() => {
|
|
711
|
+
throw new Error("core API client not used in this test");
|
|
712
|
+
}),
|
|
713
|
+
getApiBase: () => "https://api.test.com",
|
|
714
|
+
getVmServiceBase: () => "https://vms.test.com",
|
|
715
|
+
getAuthToken: () => "token-1",
|
|
716
|
+
availableRegions: () => ["us"],
|
|
717
|
+
};
|
|
718
|
+
registerVmTools(deps);
|
|
719
|
+
state.setVmCache({
|
|
720
|
+
vms: [
|
|
721
|
+
{
|
|
722
|
+
id: 5,
|
|
723
|
+
vm_name: "vm-five",
|
|
724
|
+
tunnel_url: "https://vm-five.test",
|
|
725
|
+
api_key: "key-5",
|
|
726
|
+
service_id: "svc-5",
|
|
727
|
+
},
|
|
728
|
+
],
|
|
729
|
+
fetchedAt: Date.now(),
|
|
730
|
+
});
|
|
731
|
+
return { deps, state, handlers };
|
|
732
|
+
}
|
|
733
|
+
async function callUnit(handlers, name, args) {
|
|
734
|
+
const handler = handlers.get(name);
|
|
735
|
+
if (!handler)
|
|
736
|
+
throw new Error(`Tool not registered: ${name}`);
|
|
737
|
+
return handler(args);
|
|
738
|
+
}
|
|
739
|
+
describe("mode-aware install/start tools", () => {
|
|
740
|
+
it("refuses start_mds_on_vm against a live broker and points at the mode-aware restart", async () => {
|
|
741
|
+
const { handlers } = unitSetup();
|
|
742
|
+
const fetchMock = stubFetch((url) => url.hostname === "vm-five.test" && url.pathname === "/health"
|
|
743
|
+
? jsonRes(BROKER_HEALTH)
|
|
744
|
+
: undefined);
|
|
745
|
+
const result = await callUnit(handlers, "start_mds_on_vm", {
|
|
746
|
+
workspaceId: 1,
|
|
747
|
+
vmId: 5,
|
|
748
|
+
});
|
|
749
|
+
expect(textOf(result)).toContain("Refusing start_mds_on_vm");
|
|
750
|
+
expect(textOf(result)).toContain("broker (multi-session) mode");
|
|
751
|
+
expect(textOf(result)).toContain("MinicorBroker");
|
|
752
|
+
// Probed health only — the legacy run-script never fired.
|
|
753
|
+
expect(fetchMock.mock.calls).toHaveLength(1);
|
|
754
|
+
expect(String(fetchMock.mock.calls[0][0])).toBe("https://vm-five.test/health");
|
|
755
|
+
});
|
|
756
|
+
it("refuses install_mds_on_vm against a live broker", async () => {
|
|
757
|
+
const { handlers } = unitSetup();
|
|
758
|
+
const fetchMock = stubFetch((url) => url.hostname === "vm-five.test" && url.pathname === "/health"
|
|
759
|
+
? jsonRes(BROKER_HEALTH)
|
|
760
|
+
: undefined);
|
|
761
|
+
const result = await callUnit(handlers, "install_mds_on_vm", {
|
|
762
|
+
workspaceId: 1,
|
|
763
|
+
vmId: 5,
|
|
764
|
+
enableRecording: true,
|
|
765
|
+
resolution: "1280x720",
|
|
766
|
+
});
|
|
767
|
+
expect(textOf(result)).toContain("Refusing install_mds_on_vm");
|
|
768
|
+
expect(textOf(result)).toContain("/MULTISESSION=1");
|
|
769
|
+
expect(fetchMock.mock.calls).toHaveLength(1);
|
|
770
|
+
});
|
|
771
|
+
it("still starts MDS on a standalone VM", async () => {
|
|
772
|
+
const { handlers } = unitSetup();
|
|
773
|
+
const fetchMock = stubFetch((url, init) => {
|
|
774
|
+
if (url.hostname === "vm-five.test" && url.pathname === "/health") {
|
|
775
|
+
return jsonRes(STANDALONE_HEALTH);
|
|
776
|
+
}
|
|
777
|
+
if (url.hostname === "vms.test.com" &&
|
|
778
|
+
url.pathname === "/workspaces/1/vms/5/run-script" &&
|
|
779
|
+
init?.method === "POST") {
|
|
780
|
+
return jsonRes({ stdout: "MDS running (PID 4242)", stderr: "" });
|
|
781
|
+
}
|
|
782
|
+
return undefined;
|
|
783
|
+
});
|
|
784
|
+
const result = await callUnit(handlers, "start_mds_on_vm", {
|
|
785
|
+
workspaceId: 1,
|
|
786
|
+
vmId: 5,
|
|
787
|
+
});
|
|
788
|
+
expect(textOf(result)).toContain("MDS start command executed");
|
|
789
|
+
expect(callsTo(fetchMock, "/workspaces/1/vms/5/run-script")).toHaveLength(1);
|
|
790
|
+
});
|
|
791
|
+
it("still starts MDS when the VM's health endpoint is unreachable (MDS down)", async () => {
|
|
792
|
+
const { handlers } = unitSetup();
|
|
793
|
+
const fetchMock = vi.fn(async (input, init) => {
|
|
794
|
+
const url = new URL(String(input));
|
|
795
|
+
if (url.pathname === "/health") {
|
|
796
|
+
throw new Error("ECONNREFUSED"); // MDS is down — the reason to start it
|
|
797
|
+
}
|
|
798
|
+
if (url.pathname === "/workspaces/1/vms/5/run-script" && init?.method === "POST") {
|
|
799
|
+
return jsonRes({ stdout: "started", stderr: "" });
|
|
800
|
+
}
|
|
801
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
802
|
+
});
|
|
803
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
804
|
+
const result = await callUnit(handlers, "start_mds_on_vm", {
|
|
805
|
+
workspaceId: 1,
|
|
806
|
+
vmId: 5,
|
|
807
|
+
});
|
|
808
|
+
expect(textOf(result)).toContain("MDS start command executed");
|
|
809
|
+
expect(callsTo(fetchMock, "/workspaces/1/vms/5/run-script")).toHaveLength(1);
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
//# sourceMappingURL=vm-broker.test.js.map
|