@easynet-run/node 0.27.14 → 0.39.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +39 -1
  2. package/native/dendrite-bridge-manifest.json +5 -4
  3. package/native/dendrite-bridge.json +1 -1
  4. package/native/include/axon_dendrite_bridge.h +18 -4
  5. package/native/libaxon_dendrite_bridge.so +0 -0
  6. package/package.json +9 -5
  7. package/runtime/easynet-runtime-rs-0.39.29-x86_64-unknown-linux-gnu.tar.gz +0 -0
  8. package/runtime/runtime-bridge-manifest.json +4 -4
  9. package/runtime/runtime-bridge.json +3 -3
  10. package/src/ability_lifecycle.d.ts +12 -1
  11. package/src/ability_lifecycle.js +117 -31
  12. package/src/capability_request.js +3 -1
  13. package/src/dendrite_bridge/bridge.d.ts +10 -2
  14. package/src/dendrite_bridge/bridge.js +75 -14
  15. package/src/dendrite_bridge/ffi.d.ts +4 -0
  16. package/src/dendrite_bridge/ffi.js +194 -18
  17. package/src/dendrite_bridge/types.d.ts +4 -0
  18. package/src/errors.js +9 -3
  19. package/src/index.d.ts +3 -3
  20. package/src/index.js +9 -10
  21. package/src/mcp/server.d.ts +24 -2
  22. package/src/mcp/server.js +218 -18
  23. package/src/mcp/server.test.js +100 -0
  24. package/src/presets/ability_dispatch/workflow.js +8 -30
  25. package/src/presets/remote_control/config.d.ts +3 -0
  26. package/src/presets/remote_control/config.js +22 -24
  27. package/src/presets/remote_control/descriptor.d.ts +36 -0
  28. package/src/presets/remote_control/descriptor.js +267 -11
  29. package/src/presets/remote_control/handlers.d.ts +8 -0
  30. package/src/presets/remote_control/handlers.js +230 -26
  31. package/src/presets/remote_control/kit.d.ts +4 -2
  32. package/src/presets/remote_control/kit.js +106 -1
  33. package/src/presets/remote_control/kit.test.js +994 -0
  34. package/src/presets/remote_control/orchestrator.d.ts +6 -0
  35. package/src/presets/remote_control/orchestrator.js +36 -1
  36. package/src/presets/remote_control/specs.js +217 -61
  37. package/src/receipt.js +6 -3
  38. package/runtime/easynet-runtime-rs-0.27.14-x86_64-unknown-linux-gnu.tar.gz +0 -0
@@ -1,15 +1,50 @@
1
+ // EasyNet Axon for AgentNet
2
+ // =========================
3
+ //
4
+ // File: sdk/node/src/presets/remote_control/kit.test.js
5
+ // Description: Node remote-control preset regression tests for tool dispatch, streaming cleanup, and lifecycle behavior.
6
+ //
7
+ // Protocol Responsibility:
8
+ // - Exercises public runtime behavior for the corresponding service surface under success and failure scenarios.
9
+ // - Guards regressions in tenant isolation, terminal states, and typed error shaping.
10
+ //
11
+ // Implementation Approach:
12
+ // - Builds in-memory runtimes and drives tonic service methods directly for deterministic assertions.
13
+ // - Uses focused fixtures instead of full external environments so protocol invariants stay easy to localize.
14
+ //
15
+ // Usage Contract:
16
+ // - Add new assertions here before changing runtime behavior for the covered service area.
17
+ // - Prefer explicit value checks over timing-sensitive or order-fragile expectations.
18
+ //
19
+ // Architectural Position:
20
+ // - Runtime verification boundary protecting public contract stability.
21
+ //
22
+ // Author: Silan.Hu
23
+ // Email: silan.hu@u.nus.edu
24
+ // Copyright (c) 2026-2027 easynet. All rights reserved.
25
+
1
26
  import { describe, it } from "node:test";
2
27
  import assert from "node:assert/strict";
3
28
 
4
29
  import { RemoteControlCaseKit } from "./kit.js";
30
+ import { buildDescriptor, sanitizeId } from "./descriptor.js";
5
31
  import { remoteControlToolSpecs } from "./specs.js";
32
+ import { consumeStream } from "../../mcp/server.js";
33
+ import { DEFAULT_MCP_TOOL_STREAM_TIMEOUT_MS } from "./config.js";
6
34
 
7
35
  function makeOrchestrator() {
8
36
  return {
9
37
  listNodes: () => [],
10
38
  listMcpTools: () => [],
11
39
  callMcpTool: () => ({ ok: true, state: 5, is_error: false }),
40
+ callMcpToolStream: () => ({
41
+ close: () => {},
42
+ async *[Symbol.asyncIterator]() {
43
+ yield Buffer.from("chunk");
44
+ },
45
+ }),
12
46
  disconnectDevice: (nodeId, reason) => ({ node_id: nodeId, reason }),
47
+ drainNode: (nodeId, reason) => ({ node_id: nodeId, status: "drained", reason }),
13
48
  uninstallAbility: (nodeId, installId, reason) => ({
14
49
  node_id: nodeId,
15
50
  install_id: installId,
@@ -26,6 +61,9 @@ describe("RemoteControlCaseKit", () => {
26
61
  const names = remoteControlToolSpecs().map((spec) => String(spec.name));
27
62
  assert.ok(names.includes("disconnect_device"));
28
63
  assert.ok(names.includes("uninstall_ability"));
64
+ assert.ok(names.includes("call_remote_tool_stream"));
65
+ assert.ok(names.includes("build_ability_descriptor"));
66
+ assert.ok(names.includes("redeploy_ability"));
29
67
  });
30
68
 
31
69
  it("dispatches disconnect_device", () => {
@@ -84,4 +122,960 @@ describe("RemoteControlCaseKit", () => {
84
122
  },
85
123
  });
86
124
  });
125
+
126
+ it("rejects forget_all without confirm when dry_run is false", () => {
127
+ const kit = new RemoteControlCaseKit(
128
+ {
129
+ endpoint: "http://127.0.0.1:50051",
130
+ tenant: "tenant-a",
131
+ connectTimeoutMs: 5000,
132
+ signatureBase64: "sig",
133
+ },
134
+ () => makeOrchestrator(),
135
+ );
136
+
137
+ const result = kit.handleToolCall("forget_all", { node_id: "node-a" });
138
+
139
+ assert.equal(result.isError, true);
140
+ assert.equal(result.payload.error, "forget_all requires confirm: true (destructive operation)");
141
+ });
142
+
143
+ it("rejects build_ability_descriptor when name is empty", () => {
144
+ const kit = new RemoteControlCaseKit(
145
+ {
146
+ endpoint: "http://127.0.0.1:50051",
147
+ tenant: "tenant-a",
148
+ connectTimeoutMs: 5000,
149
+ signatureBase64: "sig",
150
+ },
151
+ () => makeOrchestrator(),
152
+ );
153
+
154
+ const result = kit.handleToolCall("build_ability_descriptor", {
155
+ command_template: "echo hi",
156
+ });
157
+
158
+ assert.equal(result.isError, true);
159
+ assert.equal(result.payload.error, "name is required");
160
+ });
161
+
162
+ it("rejects redeploy_ability when tool_name is missing", () => {
163
+ const kit = new RemoteControlCaseKit(
164
+ {
165
+ endpoint: "http://127.0.0.1:50051",
166
+ tenant: "tenant-a",
167
+ connectTimeoutMs: 5000,
168
+ signatureBase64: "sig",
169
+ },
170
+ () => makeOrchestrator(),
171
+ );
172
+
173
+ const result = kit.handleToolCall("redeploy_ability", {
174
+ node_id: "node-a",
175
+ command_template: "echo hi",
176
+ });
177
+
178
+ assert.equal(result.isError, true);
179
+ assert.equal(result.payload.error, "tool_name is required");
180
+ });
181
+
182
+ it("filters list_abilities to entries with install_id", () => {
183
+ const kit = new RemoteControlCaseKit(
184
+ {
185
+ endpoint: "http://127.0.0.1:50051",
186
+ tenant: "tenant-a",
187
+ connectTimeoutMs: 5000,
188
+ signatureBase64: "sig",
189
+ },
190
+ () => ({
191
+ ...makeOrchestrator(),
192
+ listMcpTools: () => [
193
+ { tool_name: "keep-me", description: "ok", capability_name: "cap.keep", install_id: "install-1" },
194
+ { tool_name: "skip-me", description: "synthetic", capability_name: "cap.skip", install_id: "" },
195
+ ],
196
+ }),
197
+ );
198
+
199
+ const result = kit.handleToolCall("list_abilities", { node_id: "node-a" });
200
+
201
+ assert.equal(result.isError, false);
202
+ assert.equal(result.payload.count, 1);
203
+ assert.deepEqual(result.payload.abilities, [
204
+ {
205
+ tool_name: "keep-me",
206
+ description: "ok",
207
+ capability_name: "cap.keep",
208
+ install_id: "install-1",
209
+ },
210
+ ]);
211
+ });
212
+
213
+ it("closes the orchestrator when a streaming handle is closed", () => {
214
+ let streamClosed = 0;
215
+ let orchestratorClosed = 0;
216
+ const stream = {
217
+ close: () => {
218
+ streamClosed += 1;
219
+ },
220
+ async *[Symbol.asyncIterator]() {
221
+ yield Buffer.from("chunk");
222
+ },
223
+ };
224
+ const kit = new RemoteControlCaseKit(
225
+ {
226
+ endpoint: "http://127.0.0.1:50051",
227
+ tenant: "tenant-a",
228
+ connectTimeoutMs: 5000,
229
+ signatureBase64: "sig",
230
+ },
231
+ () => ({
232
+ ...makeOrchestrator(),
233
+ callMcpToolStream: () => stream,
234
+ close: () => {
235
+ orchestratorClosed += 1;
236
+ },
237
+ }),
238
+ );
239
+
240
+ const handle = kit.handleToolCallStream("call_remote_tool_stream", {
241
+ node_id: "node-a",
242
+ tool_name: "tool-a",
243
+ });
244
+
245
+ assert.ok(handle);
246
+ handle.close();
247
+ handle.close();
248
+ assert.equal(streamClosed, 1);
249
+ assert.equal(orchestratorClosed, 1);
250
+ });
251
+
252
+ it("closes the orchestrator after streaming iteration completes", async () => {
253
+ let streamClosed = 0;
254
+ let orchestratorClosed = 0;
255
+ const stream = {
256
+ close: () => {
257
+ streamClosed += 1;
258
+ },
259
+ async *[Symbol.asyncIterator]() {
260
+ yield Buffer.from("chunk-1");
261
+ yield Buffer.from("chunk-2");
262
+ },
263
+ };
264
+ const kit = new RemoteControlCaseKit(
265
+ {
266
+ endpoint: "http://127.0.0.1:50051",
267
+ tenant: "tenant-a",
268
+ connectTimeoutMs: 5000,
269
+ signatureBase64: "sig",
270
+ },
271
+ () => ({
272
+ ...makeOrchestrator(),
273
+ callMcpToolStream: () => stream,
274
+ close: () => {
275
+ orchestratorClosed += 1;
276
+ },
277
+ }),
278
+ );
279
+
280
+ const handle = kit.handleToolCallStream("call_remote_tool_stream", {
281
+ node_id: "node-a",
282
+ tool_name: "tool-a",
283
+ });
284
+
285
+ assert.ok(handle);
286
+ const chunks = [];
287
+ for await (const chunk of handle.stream) {
288
+ chunks.push(Buffer.from(chunk).toString("utf8"));
289
+ }
290
+
291
+ assert.deepEqual(chunks, ["chunk-1", "chunk-2"]);
292
+ assert.equal(streamClosed, 1);
293
+ assert.equal(orchestratorClosed, 1);
294
+ });
295
+
296
+ it("closes the orchestrator after an empty stream completes", async () => {
297
+ let streamClosed = 0;
298
+ let orchestratorClosed = 0;
299
+ const stream = {
300
+ close: () => {
301
+ streamClosed += 1;
302
+ },
303
+ async *[Symbol.asyncIterator]() {
304
+ // empty stream
305
+ },
306
+ };
307
+ const kit = new RemoteControlCaseKit(
308
+ {
309
+ endpoint: "http://127.0.0.1:50051",
310
+ tenant: "tenant-a",
311
+ connectTimeoutMs: 5000,
312
+ signatureBase64: "sig",
313
+ },
314
+ () => ({
315
+ ...makeOrchestrator(),
316
+ callMcpToolStream: () => stream,
317
+ close: () => {
318
+ orchestratorClosed += 1;
319
+ },
320
+ }),
321
+ );
322
+
323
+ const handle = kit.handleToolCallStream("call_remote_tool_stream", {
324
+ node_id: "node-a",
325
+ tool_name: "tool-a",
326
+ });
327
+
328
+ assert.ok(handle);
329
+ const chunks = [];
330
+ for await (const chunk of handle.stream) {
331
+ chunks.push(Buffer.from(chunk).toString("utf8"));
332
+ }
333
+
334
+ assert.deepEqual(chunks, []);
335
+ assert.equal(streamClosed, 1);
336
+ assert.equal(orchestratorClosed, 1);
337
+ });
338
+
339
+ it("closes the orchestrator when streaming iteration fails", async () => {
340
+ let streamClosed = 0;
341
+ let orchestratorClosed = 0;
342
+ const stream = {
343
+ close: () => {
344
+ streamClosed += 1;
345
+ },
346
+ async *[Symbol.asyncIterator]() {
347
+ yield Buffer.from("chunk-1");
348
+ throw new Error("stream chunk timeout");
349
+ },
350
+ };
351
+ const kit = new RemoteControlCaseKit(
352
+ {
353
+ endpoint: "http://127.0.0.1:50051",
354
+ tenant: "tenant-a",
355
+ connectTimeoutMs: 5000,
356
+ signatureBase64: "sig",
357
+ },
358
+ () => ({
359
+ ...makeOrchestrator(),
360
+ callMcpToolStream: () => stream,
361
+ close: () => {
362
+ orchestratorClosed += 1;
363
+ },
364
+ }),
365
+ );
366
+
367
+ const handle = kit.handleToolCallStream("call_remote_tool_stream", {
368
+ node_id: "node-a",
369
+ tool_name: "tool-a",
370
+ });
371
+
372
+ assert.ok(handle);
373
+ const chunks = [];
374
+ await assert.rejects(async () => {
375
+ for await (const chunk of handle.stream) {
376
+ chunks.push(Buffer.from(chunk).toString("utf8"));
377
+ }
378
+ }, /stream chunk timeout/);
379
+
380
+ assert.deepEqual(chunks, ["chunk-1"]);
381
+ assert.equal(streamClosed, 1);
382
+ assert.equal(orchestratorClosed, 1);
383
+ });
384
+
385
+ it("passes timeout_ms through to the orchestrator", () => {
386
+ let capturedOptions = null;
387
+ const kit = new RemoteControlCaseKit(
388
+ {
389
+ endpoint: "http://127.0.0.1:50051",
390
+ tenant: "tenant-a",
391
+ connectTimeoutMs: 5000,
392
+ signatureBase64: "sig",
393
+ },
394
+ () => ({
395
+ ...makeOrchestrator(),
396
+ callMcpToolStream: (_toolName, _nodeId, _args, opts) => {
397
+ capturedOptions = opts;
398
+ return {
399
+ close: () => {},
400
+ async *[Symbol.asyncIterator]() {},
401
+ };
402
+ },
403
+ close: () => {},
404
+ }),
405
+ );
406
+
407
+ const handle = kit.handleToolCallStream("call_remote_tool_stream", {
408
+ node_id: "node-a",
409
+ tool_name: "tool-a",
410
+ timeout_ms: 42000,
411
+ });
412
+ assert.ok(handle);
413
+ handle.close();
414
+ assert.ok(capturedOptions);
415
+ assert.equal(capturedOptions.timeoutMs, 42000);
416
+ });
417
+
418
+ it("returns null for unknown tool in handleToolCallStream", () => {
419
+ const kit = new RemoteControlCaseKit(
420
+ {
421
+ endpoint: "http://127.0.0.1:50051",
422
+ tenant: "tenant-a",
423
+ connectTimeoutMs: 5000,
424
+ signatureBase64: "sig",
425
+ },
426
+ () => makeOrchestrator(),
427
+ );
428
+
429
+ const handle = kit.handleToolCallStream("nonexistent_tool", {
430
+ node_id: "node-a",
431
+ tool_name: "tool-a",
432
+ });
433
+ assert.equal(handle, null);
434
+ });
435
+
436
+ it("buffers call_remote_tool_stream via unary handleToolCall", async () => {
437
+ const kit = new RemoteControlCaseKit(
438
+ {
439
+ endpoint: "http://127.0.0.1:50051",
440
+ tenant: "tenant-a",
441
+ connectTimeoutMs: 5000,
442
+ signatureBase64: "sig",
443
+ },
444
+ () => makeOrchestrator(),
445
+ );
446
+
447
+ const result = await kit.handleToolCall("call_remote_tool_stream", {
448
+ node_id: "node-a",
449
+ tool_name: "tool-a",
450
+ });
451
+ assert.equal(result.isError, false);
452
+ assert.equal(result.payload.ok, true);
453
+ assert.equal(result.payload.chunk_count, 1);
454
+ assert.deepEqual(result.payload.chunks, ["chunk"]);
455
+ });
456
+
457
+ it("keeps unary and buffered-stream payload shapes distinct", async () => {
458
+ const kit = new RemoteControlCaseKit(
459
+ {
460
+ endpoint: "http://127.0.0.1:50051",
461
+ tenant: "tenant-a",
462
+ connectTimeoutMs: 5000,
463
+ signatureBase64: "sig",
464
+ },
465
+ () => makeOrchestrator(),
466
+ );
467
+
468
+ const unaryResult = await kit.handleToolCall("call_remote_tool", {
469
+ node_id: "node-a",
470
+ tool_name: "tool-a",
471
+ });
472
+ const bufferedStreamResult = await kit.handleToolCall("call_remote_tool_stream", {
473
+ node_id: "node-a",
474
+ tool_name: "tool-a",
475
+ });
476
+
477
+ assert.equal(unaryResult.isError, false);
478
+ assert.equal(unaryResult.payload.ok, true);
479
+ assert.ok("call" in unaryResult.payload);
480
+ assert.equal("chunks" in unaryResult.payload, false);
481
+
482
+ assert.equal(bufferedStreamResult.isError, false);
483
+ assert.equal(bufferedStreamResult.payload.ok, true);
484
+ assert.equal(bufferedStreamResult.payload.chunk_count, 1);
485
+ assert.deepEqual(bufferedStreamResult.payload.chunks, ["chunk"]);
486
+ assert.equal("call" in bufferedStreamResult.payload, false);
487
+ });
488
+
489
+ it("returns an error payload when unary stream buffering cannot open the stream", async () => {
490
+ const kit = new RemoteControlCaseKit(
491
+ {
492
+ endpoint: "http://127.0.0.1:50051",
493
+ tenant: "tenant-a",
494
+ connectTimeoutMs: 5000,
495
+ signatureBase64: "sig",
496
+ },
497
+ () => ({
498
+ ...makeOrchestrator(),
499
+ callMcpToolStream: () => {
500
+ throw new Error("stream open failed");
501
+ },
502
+ close: () => {},
503
+ }),
504
+ );
505
+
506
+ const result = await kit.handleToolCall("call_remote_tool_stream", {
507
+ node_id: "node-a",
508
+ tool_name: "tool-a",
509
+ });
510
+
511
+ assert.equal(result.isError, true);
512
+ assert.equal(result.payload.ok, false);
513
+ assert.match(String(result.payload.error), /stream open failed/);
514
+ });
515
+
516
+ it("consumeStream enforces 64 MiB buffer limit", async () => {
517
+ // Each chunk is 1 MiB; yield 65 chunks to exceed the 64 MiB limit.
518
+ const oneMiB = Buffer.alloc(1024 * 1024, 0x41); // 'A'
519
+ let closeCalled = 0;
520
+ const handle = {
521
+ close: () => {
522
+ closeCalled += 1;
523
+ },
524
+ stream: {
525
+ async *[Symbol.asyncIterator]() {
526
+ for (let i = 0; i < 65; i++) {
527
+ yield oneMiB;
528
+ }
529
+ },
530
+ },
531
+ };
532
+
533
+ const result = await consumeStream(handle);
534
+
535
+ assert.equal(result.isError, true);
536
+ assert.equal(result.payload.ok, false);
537
+ assert.match(String(result.payload.error), /64 MiB/);
538
+ assert.equal(closeCalled, 1);
539
+ });
540
+
541
+ it("consumeStream detects lossy UTF-8 in binary chunks", async () => {
542
+ let closeCalled = 0;
543
+ const handle = {
544
+ close: () => {
545
+ closeCalled += 1;
546
+ },
547
+ stream: {
548
+ async *[Symbol.asyncIterator]() {
549
+ yield Buffer.from([0xff, 0xfe, 0x41]);
550
+ },
551
+ },
552
+ };
553
+
554
+ const result = await consumeStream(handle);
555
+
556
+ assert.equal(result.isError, false);
557
+ assert.equal(result.payload.ok, true);
558
+ assert.equal(result.payload.contains_invalid_utf8, true);
559
+ assert.equal(closeCalled, 1);
560
+ });
561
+
562
+ it("StdioMcpServer tries stream handler before unary", async () => {
563
+ const { StdioMcpServer } = await import("../../mcp/server.js");
564
+
565
+ let streamCalled = false;
566
+ let unaryCalled = false;
567
+
568
+ const provider = {
569
+ toolSpecs() {
570
+ return [
571
+ {
572
+ name: "my_tool",
573
+ description: "A test tool",
574
+ inputSchema: { type: "object", properties: {} },
575
+ },
576
+ ];
577
+ },
578
+ handleToolCall(_name, _args) {
579
+ unaryCalled = true;
580
+ return { payload: { ok: true, source: "unary" }, isError: false };
581
+ },
582
+ handleToolCallStream(_name, _args) {
583
+ streamCalled = true;
584
+ return {
585
+ stream: {
586
+ async *[Symbol.asyncIterator]() {
587
+ yield Buffer.from("stream-chunk-1");
588
+ yield Buffer.from("stream-chunk-2");
589
+ },
590
+ },
591
+ close() {},
592
+ };
593
+ },
594
+ };
595
+
596
+ const server = new StdioMcpServer(provider);
597
+ const request = JSON.stringify({
598
+ jsonrpc: "2.0",
599
+ id: 1,
600
+ method: "tools/call",
601
+ params: { name: "my_tool", arguments: { foo: "bar" } },
602
+ });
603
+
604
+ const response = await server.handleRawLine(request);
605
+
606
+ assert.equal(streamCalled, true, "handleToolCallStream should have been called");
607
+ assert.equal(unaryCalled, false, "handleToolCall should NOT have been called");
608
+
609
+ // The server buffers the stream into a JSON-RPC success response
610
+ assert.equal(response.jsonrpc, "2.0");
611
+ assert.equal(response.id, 1);
612
+ assert.ok(response.result);
613
+ assert.ok(response.result.content);
614
+ const text = response.result.content[0].text;
615
+ const payload = JSON.parse(text);
616
+ assert.equal(payload.ok, true);
617
+ assert.equal(payload.chunk_count, 2);
618
+ assert.deepEqual(payload.chunks, ["stream-chunk-1", "stream-chunk-2"]);
619
+ });
620
+
621
+ // -----------------------------------------------------------------
622
+ // drain_device tests
623
+ // -----------------------------------------------------------------
624
+ it("dispatches drain_device happy path", () => {
625
+ const kit = new RemoteControlCaseKit(
626
+ {
627
+ endpoint: "http://127.0.0.1:50051",
628
+ tenant: "tenant-a",
629
+ connectTimeoutMs: 5000,
630
+ signatureBase64: "sig",
631
+ },
632
+ () => makeOrchestrator(),
633
+ );
634
+
635
+ const result = kit.handleToolCall("drain_device", {
636
+ node_id: "node-a",
637
+ reason: "maintenance window",
638
+ });
639
+
640
+ assert.equal(result.isError, false);
641
+ assert.equal(result.payload.ok, true);
642
+ assert.equal(result.payload.tenant_id, "tenant-a");
643
+ assert.equal(result.payload.node_id, "node-a");
644
+ assert.equal(result.payload.status, "draining");
645
+ assert.equal(result.payload.response.node_id, "node-a");
646
+ assert.equal(result.payload.response.reason, "maintenance window");
647
+ });
648
+
649
+ it("rejects drain_device with missing node_id", () => {
650
+ const kit = new RemoteControlCaseKit(
651
+ {
652
+ endpoint: "http://127.0.0.1:50051",
653
+ tenant: "tenant-a",
654
+ connectTimeoutMs: 5000,
655
+ signatureBase64: "sig",
656
+ },
657
+ () => makeOrchestrator(),
658
+ );
659
+
660
+ const result = kit.handleToolCall("drain_device", { node_id: "" });
661
+
662
+ assert.equal(result.isError, true);
663
+ assert.equal(result.payload.error, "node_id is required");
664
+ });
665
+
666
+ // -----------------------------------------------------------------
667
+ // build_ability_descriptor tests
668
+ // -----------------------------------------------------------------
669
+ it("dispatches build_ability_descriptor happy path", () => {
670
+ const kit = new RemoteControlCaseKit(
671
+ {
672
+ endpoint: "http://127.0.0.1:50051",
673
+ tenant: "tenant-a",
674
+ connectTimeoutMs: 5000,
675
+ signatureBase64: "sig",
676
+ },
677
+ () => makeOrchestrator(),
678
+ );
679
+
680
+ const result = kit.handleToolCall("build_ability_descriptor", {
681
+ name: "screenshot",
682
+ command_template: "screencapture -x /tmp/shot.png",
683
+ description: "Capture a screenshot",
684
+ });
685
+
686
+ assert.equal(result.isError, false);
687
+ assert.equal(result.payload.ok, true);
688
+ const descriptor = result.payload.descriptor;
689
+ assert.ok(descriptor);
690
+ assert.equal(descriptor.name, "screenshot");
691
+ assert.match(descriptor.toolName, /^ability_screenshot$/);
692
+ assert.equal(descriptor.description, "Capture a screenshot");
693
+ assert.equal(descriptor.commandTemplate, "screencapture -x /tmp/shot.png");
694
+ });
695
+
696
+ it("dispatches build_ability_descriptor with agent extensions", () => {
697
+ const kit = new RemoteControlCaseKit(
698
+ {
699
+ endpoint: "http://127.0.0.1:50051",
700
+ tenant: "tenant-a",
701
+ connectTimeoutMs: 5000,
702
+ signatureBase64: "sig",
703
+ },
704
+ () => makeOrchestrator(),
705
+ );
706
+
707
+ const result = kit.handleToolCall("build_ability_descriptor", {
708
+ name: "gpu-info",
709
+ command_template: "nvidia-smi --query-gpu=name --format=csv",
710
+ instructions: "Use this to query GPU status on the device.",
711
+ input_examples: [{ query: "temperature" }],
712
+ prerequisites: ["GPU driver installed"],
713
+ context_bindings: { "env.CUDA_HOME": "/usr/local/cuda" },
714
+ category: "system",
715
+ });
716
+
717
+ assert.equal(result.isError, false);
718
+ const d = result.payload.descriptor;
719
+ assert.equal(d.instructions, "Use this to query GPU status on the device.");
720
+ assert.deepEqual(d.inputExamples, [{ query: "temperature" }]);
721
+ assert.deepEqual(d.prerequisites, ["GPU driver installed"]);
722
+ assert.deepEqual(d.contextBindings, { "env.CUDA_HOME": "/usr/local/cuda" });
723
+ assert.equal(d.category, "system");
724
+ });
725
+
726
+ it("buildDescriptor stores prerequisites as a JSON array", () => {
727
+ const descriptor = buildDescriptor({
728
+ ability_name: "gpu-info",
729
+ command_template: "nvidia-smi --query-gpu=name --format=csv",
730
+ prerequisites: ["GPU, driver installed", "session_start"],
731
+ }, "sig");
732
+
733
+ assert.equal(
734
+ descriptor.metadata["mcp.prerequisites"],
735
+ JSON.stringify(["GPU, driver installed", "session_start"]),
736
+ );
737
+ });
738
+
739
+ it("sanitizeId rejects names that normalize to empty", () => {
740
+ assert.throws(() => sanitizeId("数据分析"), /identifier contains no valid characters/);
741
+ });
742
+
743
+ // -----------------------------------------------------------------
744
+ // export_ability_skill tests
745
+ // -----------------------------------------------------------------
746
+ it("dispatches export_ability_skill and generates SKILL.md", () => {
747
+ const kit = new RemoteControlCaseKit(
748
+ {
749
+ endpoint: "http://127.0.0.1:50051",
750
+ tenant: "tenant-a",
751
+ connectTimeoutMs: 5000,
752
+ signatureBase64: "sig",
753
+ },
754
+ () => makeOrchestrator(),
755
+ );
756
+
757
+ const result = kit.handleToolCall("export_ability_skill", {
758
+ name: "disk-usage",
759
+ command_template: "df -h",
760
+ target: "claude",
761
+ });
762
+
763
+ assert.equal(result.isError, false);
764
+ assert.equal(result.payload.ok, true);
765
+ assert.ok(typeof result.payload.ability_md === "string");
766
+ assert.ok(typeof result.payload.invoke_script === "string");
767
+ assert.ok(result.payload.ability_md.length > 0);
768
+ assert.ok(result.payload.invoke_script.length > 0);
769
+ // Claude target should include allowed-tools
770
+ assert.ok(result.payload.ability_md.includes("allowed-tools"));
771
+ // Invoke script should be a bash script
772
+ assert.ok(result.payload.invoke_script.includes("#!/usr/bin/env bash"));
773
+ });
774
+
775
+ it("rejects export_ability_skill with shell-unsafe endpoint", () => {
776
+ const kit = new RemoteControlCaseKit(
777
+ {
778
+ endpoint: "http://127.0.0.1:50051",
779
+ tenant: "tenant-a",
780
+ connectTimeoutMs: 5000,
781
+ signatureBase64: "sig",
782
+ },
783
+ () => makeOrchestrator(),
784
+ );
785
+
786
+ const result = kit.handleToolCall("export_ability_skill", {
787
+ name: "probe",
788
+ command_template: "echo hello",
789
+ axon_endpoint: "http://evil.com`rm -rf /`",
790
+ });
791
+
792
+ assert.equal(result.isError, true);
793
+ assert.ok(result.payload.error);
794
+ assert.match(result.payload.error, /disallowed shell characters/);
795
+ });
796
+
797
+ it("rejects export_ability_skill with carriage-return endpoint", () => {
798
+ const kit = new RemoteControlCaseKit(
799
+ {
800
+ endpoint: "http://127.0.0.1:50051",
801
+ tenant: "tenant-a",
802
+ connectTimeoutMs: 5000,
803
+ signatureBase64: "sig",
804
+ },
805
+ () => makeOrchestrator(),
806
+ );
807
+
808
+ const result = kit.handleToolCall("export_ability_skill", {
809
+ name: "probe",
810
+ command_template: "echo hello",
811
+ axon_endpoint: "http://evil.com/\rprobe",
812
+ });
813
+
814
+ assert.equal(result.isError, true);
815
+ assert.ok(result.payload.error);
816
+ assert.match(result.payload.error, /disallowed shell characters/);
817
+ });
818
+
819
+ // -----------------------------------------------------------------
820
+ // forget_all tests
821
+ // -----------------------------------------------------------------
822
+ it("dispatches forget_all dry_run and returns would-remove list", () => {
823
+ const kit = new RemoteControlCaseKit(
824
+ {
825
+ endpoint: "http://127.0.0.1:50051",
826
+ tenant: "tenant-a",
827
+ connectTimeoutMs: 5000,
828
+ signatureBase64: "sig",
829
+ },
830
+ () => ({
831
+ ...makeOrchestrator(),
832
+ listMcpTools: () => [
833
+ { tool_name: "tool-with-id", description: "ok", capability_name: "cap.a", install_id: "inst-1" },
834
+ { tool_name: "tool-without-id", description: "synthetic", capability_name: "cap.b", install_id: "" },
835
+ ],
836
+ }),
837
+ );
838
+
839
+ const result = kit.handleToolCall("forget_all", {
840
+ node_id: "node-a",
841
+ dry_run: true,
842
+ });
843
+
844
+ assert.equal(result.isError, false);
845
+ assert.equal(result.payload.ok, true);
846
+ assert.equal(result.payload.dry_run, true);
847
+ assert.deepEqual(result.payload.removed, ["tool-with-id"]);
848
+ assert.equal(result.payload.removed_count, 1);
849
+ assert.equal(result.payload.failed_count, 1);
850
+ assert.equal(result.payload.failed[0].tool_name, "tool-without-id");
851
+ });
852
+
853
+ it("dispatches forget_all with confirm and removes abilities", () => {
854
+ let uninstallCalls = [];
855
+ const kit = new RemoteControlCaseKit(
856
+ {
857
+ endpoint: "http://127.0.0.1:50051",
858
+ tenant: "tenant-a",
859
+ connectTimeoutMs: 5000,
860
+ signatureBase64: "sig",
861
+ },
862
+ () => ({
863
+ ...makeOrchestrator(),
864
+ listMcpTools: () => [
865
+ { tool_name: "my-tool", description: "test", capability_name: "cap.x", install_id: "inst-7" },
866
+ ],
867
+ uninstallAbility: (nodeId, installId, reason) => {
868
+ uninstallCalls.push({ nodeId, installId, reason });
869
+ return { node_id: nodeId, install_id: installId, reason };
870
+ },
871
+ }),
872
+ );
873
+
874
+ const result = kit.handleToolCall("forget_all", {
875
+ node_id: "node-a",
876
+ confirm: true,
877
+ });
878
+
879
+ assert.equal(result.isError, false);
880
+ assert.equal(result.payload.ok, true);
881
+ assert.equal(result.payload.dry_run, false);
882
+ assert.deepEqual(result.payload.removed, ["my-tool"]);
883
+ assert.equal(result.payload.removed_count, 1);
884
+ assert.equal(result.payload.failed_count, 0);
885
+ assert.equal(uninstallCalls.length, 1);
886
+ assert.equal(uninstallCalls[0].installId, "inst-7");
887
+ });
888
+
889
+ // -----------------------------------------------------------------
890
+ // redeploy_ability tests
891
+ // -----------------------------------------------------------------
892
+ it("dispatches redeploy_ability happy path", () => {
893
+ const kit = new RemoteControlCaseKit(
894
+ {
895
+ endpoint: "http://127.0.0.1:50051",
896
+ tenant: "tenant-a",
897
+ connectTimeoutMs: 5000,
898
+ signatureBase64: "sig",
899
+ },
900
+ () => ({
901
+ ...makeOrchestrator(),
902
+ deployAbilityPackage: () => ({ install_id: "inst-99", tool_name: "my-tool" }),
903
+ }),
904
+ );
905
+
906
+ const result = kit.handleToolCall("redeploy_ability", {
907
+ node_id: "node-a",
908
+ tool_name: "my-tool",
909
+ command_template: "echo updated",
910
+ });
911
+
912
+ assert.equal(result.isError, false);
913
+ assert.equal(result.payload.ok, true);
914
+ assert.equal(result.payload.status, "redeployed");
915
+ assert.equal(result.payload.node_id, "node-a");
916
+ assert.equal(result.payload.tool_name, "my-tool");
917
+ assert.equal(result.payload.install_id, "inst-99");
918
+ });
919
+
920
+ // -----------------------------------------------------------------
921
+ // export_ability_skill verifies SKILL.md structure
922
+ // -----------------------------------------------------------------
923
+ it("export_ability_skill verifies SKILL.md structure", () => {
924
+ const kit = new RemoteControlCaseKit(
925
+ {
926
+ endpoint: "http://127.0.0.1:50051",
927
+ tenant: "tenant-a",
928
+ connectTimeoutMs: 5000,
929
+ signatureBase64: "sig",
930
+ },
931
+ () => makeOrchestrator(),
932
+ );
933
+
934
+ const result = kit.handleToolCall("export_ability_skill", {
935
+ name: "my-skill",
936
+ command_template: "echo hello",
937
+ target: "claude",
938
+ });
939
+
940
+ assert.equal(result.isError, false);
941
+ assert.ok(result.payload.ability_md.includes("---\nname:"), "ability_md should contain frontmatter start");
942
+ assert.ok(result.payload.ability_md.includes("## Parameters"), "ability_md should contain Parameters section");
943
+ assert.ok(result.payload.ability_md.includes("| Name |"), "ability_md should contain parameter table header");
944
+ assert.ok(result.payload.invoke_script.includes("curl -sS -X POST"), "invoke_script should contain curl POST");
945
+ });
946
+
947
+ // -----------------------------------------------------------------
948
+ // build_ability_descriptor with Unicode name
949
+ // -----------------------------------------------------------------
950
+ it("build_ability_descriptor with Unicode name", () => {
951
+ const kit = new RemoteControlCaseKit(
952
+ {
953
+ endpoint: "http://127.0.0.1:50051",
954
+ tenant: "tenant-a",
955
+ connectTimeoutMs: 5000,
956
+ signatureBase64: "sig",
957
+ },
958
+ () => makeOrchestrator(),
959
+ );
960
+
961
+ const result = kit.handleToolCall("build_ability_descriptor", {
962
+ name: "数据分析",
963
+ command_template: "python3 analyze.py",
964
+ });
965
+
966
+ // Unicode-only names are rejected by sanitizeId (ASCII identifiers only).
967
+ assert.equal(result.isError, true);
968
+ assert.ok(result.payload.error);
969
+ });
970
+
971
+ // -----------------------------------------------------------------
972
+ // forget_all confirmed removes and reports failures
973
+ // -----------------------------------------------------------------
974
+ it("forget_all confirmed removes and reports failures", () => {
975
+ let uninstallCalls = [];
976
+ const kit = new RemoteControlCaseKit(
977
+ {
978
+ endpoint: "http://127.0.0.1:50051",
979
+ tenant: "tenant-a",
980
+ connectTimeoutMs: 5000,
981
+ signatureBase64: "sig",
982
+ },
983
+ () => ({
984
+ ...makeOrchestrator(),
985
+ listMcpTools: () => [
986
+ { tool_name: "tool-a", description: "ok", capability_name: "cap.a", install_id: "inst-1" },
987
+ { tool_name: "tool-b", description: "no id", capability_name: "cap.b", install_id: "" },
988
+ { tool_name: "tool-c", description: "whitespace id", capability_name: "cap.c", install_id: " " },
989
+ ],
990
+ uninstallAbility: (nodeId, installId, reason) => {
991
+ uninstallCalls.push({ nodeId, installId, reason });
992
+ return { node_id: nodeId, install_id: installId, reason };
993
+ },
994
+ }),
995
+ );
996
+
997
+ const result = kit.handleToolCall("forget_all", {
998
+ node_id: "node-a",
999
+ confirm: true,
1000
+ });
1001
+
1002
+ assert.equal(result.isError, false);
1003
+ assert.equal(result.payload.ok, true);
1004
+ assert.equal(result.payload.removed.length, 1);
1005
+ assert.equal(result.payload.failed.length, 2);
1006
+ assert.equal(uninstallCalls.length, 1);
1007
+ assert.equal(uninstallCalls[0].installId, "inst-1");
1008
+ });
1009
+
1010
+ // -----------------------------------------------------------------
1011
+ // build_ability_descriptor rejects empty command_template
1012
+ // -----------------------------------------------------------------
1013
+ it("build_ability_descriptor rejects empty command_template", () => {
1014
+ const kit = new RemoteControlCaseKit(
1015
+ {
1016
+ endpoint: "http://127.0.0.1:50051",
1017
+ tenant: "tenant-a",
1018
+ connectTimeoutMs: 5000,
1019
+ signatureBase64: "sig",
1020
+ },
1021
+ () => makeOrchestrator(),
1022
+ );
1023
+
1024
+ const result = kit.handleToolCall("build_ability_descriptor", {
1025
+ name: "test",
1026
+ });
1027
+
1028
+ assert.equal(result.isError, true);
1029
+ assert.ok(result.payload.error);
1030
+ });
1031
+
1032
+ // -----------------------------------------------------------------
1033
+ // Spec validation tests
1034
+ // -----------------------------------------------------------------
1035
+ it("all tool specs have unique names", () => {
1036
+ const specs = remoteControlToolSpecs();
1037
+ const names = specs.map((s) => s.name);
1038
+ const unique = new Set(names);
1039
+ assert.equal(names.length, unique.size, `Duplicate spec names found: ${names.filter((n, i) => names.indexOf(n) !== i)}`);
1040
+ });
1041
+
1042
+ it("all specs have required inputSchema fields", () => {
1043
+ const specs = remoteControlToolSpecs();
1044
+ for (const spec of specs) {
1045
+ assert.ok(spec.inputSchema, `spec ${spec.name} is missing inputSchema`);
1046
+ assert.equal(spec.inputSchema.type, "object", `spec ${spec.name} inputSchema.type should be "object"`);
1047
+ }
1048
+ });
1049
+
1050
+ it("uses default timeout when timeout_ms is missing", () => {
1051
+ let capturedOptions = null;
1052
+ const kit = new RemoteControlCaseKit(
1053
+ {
1054
+ endpoint: "http://127.0.0.1:50051",
1055
+ tenant: "tenant-a",
1056
+ connectTimeoutMs: 5000,
1057
+ signatureBase64: "sig",
1058
+ },
1059
+ () => ({
1060
+ ...makeOrchestrator(),
1061
+ callMcpToolStream: (_toolName, _nodeId, _args, opts) => {
1062
+ capturedOptions = opts;
1063
+ return {
1064
+ close: () => {},
1065
+ async *[Symbol.asyncIterator]() {},
1066
+ };
1067
+ },
1068
+ close: () => {},
1069
+ }),
1070
+ );
1071
+
1072
+ const handle = kit.handleToolCallStream("call_remote_tool_stream", {
1073
+ node_id: "node-a",
1074
+ tool_name: "tool-a",
1075
+ });
1076
+ assert.ok(handle);
1077
+ handle.close();
1078
+ assert.ok(capturedOptions);
1079
+ assert.equal(capturedOptions.timeoutMs, DEFAULT_MCP_TOOL_STREAM_TIMEOUT_MS);
1080
+ });
87
1081
  });