shadow-claw 1.52.0 → 1.52.1

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 (112) hide show
  1. package/.well-known/mcp/server-card.json +1 -1
  2. package/.well-known/mcp.json +1 -1
  3. package/README.md +9 -9
  4. package/dist/cli/build/build.js +1 -1
  5. package/dist/cli/{chunk-DYq-orHK.js → chunk-BgTv9w0K.js} +1 -1
  6. package/dist/cli/{chunk-C6jii2Fl.js → chunk-d5hIhj8t.js} +1 -1
  7. package/dist/cli/cli.js +1 -1
  8. package/dist/lib/components/index.js +1 -1
  9. package/dist/lib/components/shadow-claw-pages.js +1 -1
  10. package/dist/lib/index.js +1 -1
  11. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js → shadow-claw-pages-CjHS50HN.js} +2 -2
  12. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js.map → shadow-claw-pages-CjHS50HN.js.map} +1 -1
  13. package/dist/public/.well-known/mcp/server-card.json +1 -1
  14. package/dist/public/.well-known/mcp.json +1 -1
  15. package/dist/public/AGENTS.md +1 -1
  16. package/dist/public/README.md +9 -9
  17. package/dist/public/docs/README.md +1 -1
  18. package/dist/public/docs/architecture/orchestrator.md +2 -2
  19. package/dist/public/docs/architecture/overview.md +3 -3
  20. package/dist/public/docs/architecture/worker-protocol.md +29 -29
  21. package/dist/public/docs/example/article/index.html +1 -1
  22. package/dist/public/docs/example/article.html +1 -1
  23. package/dist/public/docs/publishing/index.html +1 -1
  24. package/dist/public/docs/publishing.html +1 -1
  25. package/dist/public/docs/skill-creator/index.html +1 -1
  26. package/dist/public/docs/skill-creator.html +1 -1
  27. package/dist/public/docs/subsystems/local-models.md +1 -1
  28. package/dist/public/docs/subsystems/providers.md +11 -4
  29. package/dist/public/e2e/README.md +2 -2
  30. package/dist/public/index.html +1 -1
  31. package/dist/public/index.js +1 -1
  32. package/dist/public/llms.txt +1 -1
  33. package/dist/public/main/index.html +1 -1
  34. package/dist/public/main/memory/index.html +1 -1
  35. package/dist/public/main/memory.html +1 -1
  36. package/dist/public/main.html +1 -1
  37. package/dist/public/service-worker.js +1 -1
  38. package/dist/public/{shadow-claw-Ba2ZFE9m.js → shadow-claw-T9oPpqxT.js} +1 -1
  39. package/dist/public/{shadow-claw-pages-Cy6T0G9y.js → shadow-claw-pages-IW_LNEL_.js} +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/build/build.test.ts +95 -0
  42. package/src/cli/build/build.ts +5 -1
  43. package/src/cli/commands/agent.test.ts +134 -0
  44. package/src/cli/commands/backup.test.ts +85 -0
  45. package/src/cli/commands/clients.test.ts +32 -0
  46. package/src/cli/commands/mcp.test.ts +260 -1
  47. package/src/cli/commands/send-file.test.ts +73 -0
  48. package/src/cli/commands/send.test.ts +58 -0
  49. package/src/cli/commands/tasks.test.ts +78 -0
  50. package/src/cli/commands/webrtc-listen.test.ts +74 -0
  51. package/src/cli/tools/assert-version-bump.test.ts +30 -1
  52. package/src/cli/tools/file-search-replace.test.ts +33 -1
  53. package/src/cli/tools/patch-service-worker-trusted-types.test.ts +24 -1
  54. package/src/cli/tools/touch-nojekyll.test.ts +12 -0
  55. package/src/cli/tools/version.test.ts +109 -5
  56. package/src/cli/utils/agent-core.test.ts +40 -0
  57. package/src/cli/utils/control-client.test.ts +239 -2
  58. package/src/cli/utils/load-workspace-config.test.ts +63 -0
  59. package/src/cli/utils/local-models.test.ts +98 -0
  60. package/src/cli/utils/progress-bar.test.ts +41 -2
  61. package/src/cli/utils/resolve-cache-dir.test.ts +61 -0
  62. package/src/cli/utils/resolve-project-root.test.ts +17 -0
  63. package/src/cli/utils/stdin.test.ts +38 -0
  64. package/src/cli/utils/stdio.test.ts +51 -1
  65. package/src/cli/utils/suppress-warnings.test.ts +9 -0
  66. package/src/cli/utils/webrtc-control-client.test.ts +569 -0
  67. package/src/common/utils/frontmatter.test.ts +117 -0
  68. package/src/components/common/help/providers.test.ts +67 -0
  69. package/src/components/common/shadow-claw-provider-model-picker/shadow-claw-provider-model-picker.test.ts +121 -0
  70. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +131 -0
  71. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +130 -0
  72. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +12 -0
  73. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +328 -0
  74. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +530 -8
  75. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +459 -0
  76. package/src/components/settings/shadow-claw-networking/shadow-claw-networking.test.ts +88 -0
  77. package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +92 -0
  78. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +127 -0
  79. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +173 -0
  80. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +62 -0
  81. package/src/components/settings/shadow-claw-webvm/shadow-claw-webvm.test.ts +104 -0
  82. package/src/components/shadow-claw/shadow-claw.test.ts +112 -0
  83. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +109 -0
  84. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +361 -0
  85. package/src/components/shadow-claw-dialog/shadow-claw-dialog.test.ts +76 -0
  86. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +307 -0
  87. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +398 -1
  88. package/src/components/shadow-claw-pages/shadow-claw-pages.test.ts +85 -0
  89. package/src/components/shadow-claw-pages/shadow-claw-pages.ts +2 -1
  90. package/src/components/shadow-claw-pdf-viewer/shadow-claw-pdf-viewer.test.ts +229 -0
  91. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +233 -0
  92. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +223 -1
  93. package/src/components/shadow-claw-toast/shadow-claw-toast.test.ts +79 -0
  94. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +229 -1
  95. package/src/server/logger.test.ts +57 -0
  96. package/src/server/peer.test.ts +47 -0
  97. package/src/server/routes/gemini.test.ts +244 -0
  98. package/src/server/server-peer.test.ts +245 -11
  99. package/src/server/services/llamafile-manager.test.ts +668 -0
  100. package/src/share-target/pending-shares.test.ts +90 -0
  101. package/src/shell/fs.test.ts +125 -0
  102. package/src/shell/shell.test.ts +8 -0
  103. package/src/subsystems/git/credentials.test.ts +260 -0
  104. package/src/subsystems/git/git.test.ts +64 -0
  105. package/src/subsystems/providers/executeNativeAiTask.test.ts +78 -0
  106. package/src/subsystems/providers/utils/parseLocalModelToolCall.test.ts +57 -0
  107. package/src/worker/tools/builtin-ai/builtin-ai.test.ts +88 -0
  108. package/src/worker/tools/email/email.test.ts +358 -0
  109. package/src/worker/tools/email/utils/resolveIntegrationEmailAuth.test.ts +120 -0
  110. package/src/worker/tools/email/utils/resolveUniqueAttachmentPath.test.ts +80 -0
  111. package/src/worker/tools/fetch-file/fetch-file.test.ts +111 -0
  112. package/src/worker/tools/fetch-url/fetch-url.test.ts +295 -0
@@ -2,6 +2,7 @@ import {
2
2
  parseLocalModelToolCall,
3
3
  parseLocalModelToolCalls,
4
4
  convertToolSchemasToOpenAI,
5
+ createToolCallStreamFilter,
5
6
  } from "./parseLocalModelToolCall.js";
6
7
 
7
8
  describe("parseLocalModelToolCall", () => {
@@ -342,4 +343,60 @@ describe("convertToolSchemasToOpenAI", () => {
342
343
  expect(typeof params.properties.dataModel.properties).toBe("object");
343
344
  expect(params.properties.value.type).toBe("string");
344
345
  });
346
+
347
+ describe("createToolCallStreamFilter", () => {
348
+ it("handles undefined onToken callback gracefully", () => {
349
+ const filter = createToolCallStreamFilter();
350
+ expect(() => {
351
+ filter.onToken("token");
352
+ filter.finalize(false);
353
+ }).not.toThrow();
354
+ });
355
+
356
+ it("suppresses tokens when a tool call prefix is detected", () => {
357
+ const emitted: string[] = [];
358
+ const filter = createToolCallStreamFilter((t) => emitted.push(t));
359
+
360
+ filter.onToken("call");
361
+ filter.onToken(":fetch_url");
362
+ filter.onToken('{"url":"https://example.com"}');
363
+ filter.finalize(true);
364
+
365
+ expect(emitted).toEqual([]);
366
+ });
367
+
368
+ it("emits normal text after buffering non-prefix token", () => {
369
+ const emitted: string[] = [];
370
+ const filter = createToolCallStreamFilter((t) => emitted.push(t));
371
+
372
+ filter.onToken("The ");
373
+ filter.onToken("weather ");
374
+ filter.onToken("is sunny.");
375
+ filter.finalize(false);
376
+
377
+ expect(emitted.join("")).toBe("The weather is sunny.");
378
+ });
379
+
380
+ it("flushes remaining buffered text on finalize when not a tool call", () => {
381
+ const emitted: string[] = [];
382
+ const filter = createToolCallStreamFilter((t) => emitted.push(t));
383
+
384
+ filter.onToken("c");
385
+ filter.onToken("at");
386
+ filter.finalize(false);
387
+
388
+ expect(emitted.join("")).toBe("cat");
389
+ });
390
+
391
+ it("suppresses <|tool_call and other XML-style prefixes", () => {
392
+ const emitted: string[] = [];
393
+ const filter = createToolCallStreamFilter((t) => emitted.push(t));
394
+
395
+ filter.onToken("<|tool_call");
396
+ filter.onToken(">call:bash{}");
397
+ filter.finalize(true);
398
+
399
+ expect(emitted).toEqual([]);
400
+ });
401
+ });
345
402
  });
@@ -11,6 +11,7 @@ let executeRewriteText: any;
11
11
  let executeProofreadText: any;
12
12
  let executeDetectLanguage: any;
13
13
  let executeTranslateText: any;
14
+ let executeEmbedText: any;
14
15
 
15
16
  describe("builtin-ai worker tools", () => {
16
17
  beforeAll(async () => {
@@ -21,6 +22,7 @@ describe("builtin-ai worker tools", () => {
21
22
  executeProofreadText = mod.executeProofreadText;
22
23
  executeDetectLanguage = mod.executeDetectLanguage;
23
24
  executeTranslateText = mod.executeTranslateText;
25
+ executeEmbedText = mod.executeEmbedText;
24
26
  });
25
27
 
26
28
  beforeEach(() => {
@@ -45,6 +47,19 @@ describe("builtin-ai worker tools", () => {
45
47
  });
46
48
  };
47
49
 
50
+ const mockMainThreadReject = (err: any) => {
51
+ mockPost.mockImplementationOnce((msg: any) => {
52
+ if (msg.type === "request-native-ai-task") {
53
+ setTimeout(() => {
54
+ const resolvers = (globalThis as any).pendingNativeAiResolvers;
55
+ if (resolvers && resolvers[msg.payload.id]) {
56
+ resolvers[msg.payload.id].reject(err);
57
+ }
58
+ }, 0);
59
+ }
60
+ });
61
+ };
62
+
48
63
  describe("executeSummarizeText", () => {
49
64
  it("returns error message when text is missing", async () => {
50
65
  const res = await executeSummarizeText({});
@@ -264,5 +279,78 @@ describe("builtin-ai worker tools", () => {
264
279
  }),
265
280
  );
266
281
  });
282
+
283
+ it("handles error during translate", async () => {
284
+ mockMainThreadReject(new Error("Translation failed"));
285
+ const res = await executeTranslateText({
286
+ text: "Hello",
287
+ sourceLanguage: "en",
288
+ targetLanguage: "fr",
289
+ });
290
+ expect(res).toContain("Error translating text: Translation failed");
291
+ });
292
+ });
293
+
294
+ describe("executeEmbedText", () => {
295
+ it("returns error message when text is missing", async () => {
296
+ const res1 = await executeEmbedText({});
297
+ expect(res1).toBe("Error: text is required for embed_text");
298
+
299
+ const res2 = await executeEmbedText({ text: [] });
300
+ expect(res2).toBe("Error: text is required for embed_text");
301
+ });
302
+
303
+ it("posts request to main thread and returns output", async () => {
304
+ mockMainThreadResponse([0.1, 0.2, 0.3]);
305
+ const res = await executeEmbedText({
306
+ text: "Embed this",
307
+ taskType: "query",
308
+ });
309
+ expect(JSON.parse(res)).toEqual([0.1, 0.2, 0.3]);
310
+ expect(mockPost).toHaveBeenCalledWith(
311
+ expect.objectContaining({
312
+ type: "request-native-ai-task",
313
+ payload: expect.objectContaining({
314
+ taskType: "semantic-embedder",
315
+ input: expect.objectContaining({
316
+ text: "Embed this",
317
+ taskType: "query",
318
+ }),
319
+ }),
320
+ }),
321
+ );
322
+ });
323
+
324
+ it("handles error during embedding", async () => {
325
+ mockMainThreadReject(new Error("Model unavailable"));
326
+ const res = await executeEmbedText({ text: "Embed this" });
327
+ expect(res).toContain("Error generating embeddings: Model unavailable");
328
+ });
329
+ });
330
+
331
+ describe("error handling for remaining tools", () => {
332
+ it("handles write text rejection", async () => {
333
+ mockMainThreadReject(new Error("Write error"));
334
+ const res = await executeWriteText({ prompt: "Write something" });
335
+ expect(res).toContain("Error writing text: Write error");
336
+ });
337
+
338
+ it("handles rewrite text rejection", async () => {
339
+ mockMainThreadReject(new Error("Rewrite error"));
340
+ const res = await executeRewriteText({ text: "Rewrite something" });
341
+ expect(res).toContain("Error rewriting text: Rewrite error");
342
+ });
343
+
344
+ it("handles proofread rejection", async () => {
345
+ mockMainThreadReject(new Error("Proofread error"));
346
+ const res = await executeProofreadText({ text: "Proofread something" });
347
+ expect(res).toContain("Error proofreading text: Proofread error");
348
+ });
349
+
350
+ it("handles detect language rejection", async () => {
351
+ mockMainThreadReject(new Error("Detect error"));
352
+ const res = await executeDetectLanguage({ text: "Detect something" });
353
+ expect(res).toContain("Error detecting language: Detect error");
354
+ });
267
355
  });
268
356
  });
@@ -463,6 +463,364 @@ describe("worker/tools/email", () => {
463
463
  expect(deleteMsgResult).toContain('"deleted": [');
464
464
  });
465
465
 
466
+ it("send_message handles missing SMTP host, attachment read failure, and oauth 401 retry", async () => {
467
+ // Missing SMTP host
468
+ mockGetEmailConnection.mockResolvedValueOnce({
469
+ id: "conn-no-smtp",
470
+ pluginId: "imap",
471
+ enabled: true,
472
+ config: {},
473
+ credentialRef: {
474
+ authType: "basic_userpass",
475
+ encryptedSecret: "enc:pw",
476
+ username: "user@example.com",
477
+ },
478
+ });
479
+
480
+ const noSmtpResult = await executeManageEmailTool(
481
+ {} as any,
482
+ {
483
+ action: "send_message",
484
+ connection_id: "conn-no-smtp",
485
+ to: ["a@b.com"],
486
+ subject: "Test",
487
+ body: "Hello",
488
+ },
489
+ "group-1",
490
+ );
491
+ expect(noSmtpResult).toContain("missing SMTP host");
492
+
493
+ // Attachment read error
494
+ mockGetEmailConnection.mockResolvedValueOnce({
495
+ id: "conn-att-fail",
496
+ pluginId: "imap",
497
+ enabled: true,
498
+ config: { smtpHost: "smtp.example.com" },
499
+ credentialRef: {
500
+ authType: "basic_userpass",
501
+ encryptedSecret: "enc:pw",
502
+ username: "user@example.com",
503
+ },
504
+ });
505
+ mockReadGroupFileBytes.mockRejectedValueOnce(new Error("File not found"));
506
+
507
+ const attFailResult = await executeManageEmailTool(
508
+ {} as any,
509
+ {
510
+ action: "send_message",
511
+ connection_id: "conn-att-fail",
512
+ to: ["a@b.com"],
513
+ subject: "Test",
514
+ body: "Hello",
515
+ attachments: ["missing.txt"],
516
+ },
517
+ "group-1",
518
+ );
519
+ expect(attFailResult).toContain(
520
+ "Attachment file not found or unreadable: missing.txt",
521
+ );
522
+
523
+ // OAuth 401 retry
524
+ mockGetEmailConnection.mockResolvedValueOnce({
525
+ id: "conn-oauth-send",
526
+ pluginId: "imap",
527
+ enabled: true,
528
+ config: { smtpHost: "smtp.example.com" },
529
+ credentialRef: {
530
+ authType: "oauth",
531
+ accountId: "acct-send",
532
+ providerId: "google",
533
+ username: "user@example.com",
534
+ },
535
+ });
536
+ mockResolveServiceCredentials
537
+ .mockResolvedValueOnce({ token: "token-s1" })
538
+ .mockResolvedValueOnce({ token: "token-s2" });
539
+
540
+ (global.fetch as any)
541
+ .mockResolvedValueOnce({
542
+ ok: false,
543
+ status: 401,
544
+ statusText: "Unauthorized",
545
+ json: async () => ({ error: "unauthorized" }),
546
+ })
547
+ .mockResolvedValueOnce({
548
+ ok: true,
549
+ status: 200,
550
+ json: async () => ({ sent: true, refreshed: true }),
551
+ });
552
+
553
+ const retrySendResult = await executeManageEmailTool(
554
+ {} as any,
555
+ {
556
+ action: "send_message",
557
+ connection_id: "conn-oauth-send",
558
+ to: ["target@example.com"],
559
+ subject: "Test",
560
+ body: "Hello",
561
+ },
562
+ "group-1",
563
+ );
564
+ expect(retrySendResult).toContain('"refreshed": true');
565
+ });
566
+
567
+ it("download_attachments handles missing IMAP host, oauth 401 retry, and failed response", async () => {
568
+ // Missing IMAP host
569
+ mockGetEmailConnection.mockResolvedValueOnce({
570
+ id: "conn-no-imap",
571
+ pluginId: "imap",
572
+ enabled: true,
573
+ config: {},
574
+ credentialRef: {
575
+ authType: "basic_userpass",
576
+ encryptedSecret: "enc:pw",
577
+ username: "user@example.com",
578
+ },
579
+ });
580
+
581
+ const noImapResult = await executeManageEmailTool(
582
+ {} as any,
583
+ {
584
+ action: "download_attachments",
585
+ connection_id: "conn-no-imap",
586
+ message_uid: 10,
587
+ },
588
+ "group-1",
589
+ );
590
+ expect(noImapResult).toContain("missing IMAP host");
591
+
592
+ // OAuth 401 retry & download failure response
593
+ mockGetEmailConnection.mockResolvedValue({
594
+ id: "conn-oauth-dl",
595
+ pluginId: "imap",
596
+ enabled: true,
597
+ config: { host: "imap.example.com" },
598
+ credentialRef: {
599
+ authType: "oauth",
600
+ accountId: "acct-dl",
601
+ providerId: "google",
602
+ username: "user@example.com",
603
+ },
604
+ });
605
+ mockResolveServiceCredentials
606
+ .mockResolvedValueOnce({ token: "token-dl1" })
607
+ .mockResolvedValueOnce({ token: "token-dl2" });
608
+
609
+ (global.fetch as any)
610
+ .mockResolvedValueOnce({
611
+ ok: false,
612
+ status: 401,
613
+ statusText: "Unauthorized",
614
+ json: async () => ({ error: "unauthorized" }),
615
+ })
616
+ .mockResolvedValueOnce({
617
+ ok: false,
618
+ status: 500,
619
+ statusText: "Server Error",
620
+ json: async () => ({ error: "disk error" }),
621
+ });
622
+
623
+ const failDlResult = await executeManageEmailTool(
624
+ {} as any,
625
+ {
626
+ action: "download_attachments",
627
+ connection_id: "conn-oauth-dl",
628
+ message_uid: 10,
629
+ },
630
+ "group-1",
631
+ );
632
+ expect(failDlResult).toContain(
633
+ "IMAP attachment download failed (500): disk error",
634
+ );
635
+ });
636
+
637
+ it("mark_as_read handles oauth 401 retry and failed response", async () => {
638
+ mockGetEmailConnection.mockResolvedValue({
639
+ id: "conn-oauth-mark",
640
+ pluginId: "imap",
641
+ enabled: true,
642
+ config: { host: "imap.example.com" },
643
+ credentialRef: {
644
+ authType: "oauth",
645
+ accountId: "acct-mark",
646
+ providerId: "google",
647
+ username: "user@example.com",
648
+ },
649
+ });
650
+ mockResolveServiceCredentials
651
+ .mockResolvedValueOnce({ token: "token-m1" })
652
+ .mockResolvedValueOnce({ token: "token-m2" });
653
+
654
+ (global.fetch as any)
655
+ .mockResolvedValueOnce({
656
+ ok: false,
657
+ status: 401,
658
+ statusText: "Unauthorized",
659
+ json: async () => ({ error: "unauthorized" }),
660
+ })
661
+ .mockResolvedValueOnce({
662
+ ok: false,
663
+ status: 400,
664
+ statusText: "Bad Request",
665
+ json: async () => ({ error: "invalid uids" }),
666
+ });
667
+
668
+ const failMarkResult = await executeManageEmailTool(
669
+ {} as any,
670
+ {
671
+ action: "mark_as_read",
672
+ connection_id: "conn-oauth-mark",
673
+ message_uids: [999],
674
+ },
675
+ "group-1",
676
+ );
677
+ expect(failMarkResult).toContain("IMAP modify failed (400): invalid uids");
678
+
679
+ // Plugin doesn't support messages.read
680
+ mockGetEmailPluginManifest.mockReturnValueOnce({
681
+ id: "imap",
682
+ name: "IMAP",
683
+ actions: ["messages.send"],
684
+ });
685
+ mockGetEmailConnection.mockResolvedValueOnce({
686
+ id: "conn-unsupported",
687
+ pluginId: "imap",
688
+ enabled: true,
689
+ config: { host: "imap.example.com" },
690
+ credentialRef: {
691
+ authType: "basic_userpass",
692
+ username: "u",
693
+ encryptedSecret: "enc:pw",
694
+ },
695
+ });
696
+ const unsuppResult = await executeManageEmailTool(
697
+ {} as any,
698
+ {
699
+ action: "mark_as_read",
700
+ connection_id: "conn-unsupported",
701
+ message_uids: [1],
702
+ },
703
+ "group-1",
704
+ );
705
+ expect(unsuppResult).toContain("does not support inbox message updates");
706
+
707
+ // Missing IMAP host in mark_as_read
708
+ mockGetEmailConnection.mockResolvedValueOnce({
709
+ id: "conn-no-host",
710
+ pluginId: "imap",
711
+ enabled: true,
712
+ config: {},
713
+ credentialRef: {
714
+ authType: "basic_userpass",
715
+ username: "u",
716
+ encryptedSecret: "enc:pw",
717
+ },
718
+ });
719
+ const noHostResult = await executeManageEmailTool(
720
+ {} as any,
721
+ {
722
+ action: "mark_as_read",
723
+ connection_id: "conn-no-host",
724
+ message_uids: [1],
725
+ },
726
+ "group-1",
727
+ );
728
+ expect(noHostResult).toContain("missing IMAP host");
729
+
730
+ // OAuth 401 retry where refreshedAuth returns an error string
731
+ mockGetEmailConnection.mockResolvedValueOnce({
732
+ id: "conn-oauth-fail-refresh",
733
+ pluginId: "imap",
734
+ enabled: true,
735
+ config: { host: "imap.example.com" },
736
+ credentialRef: {
737
+ authType: "oauth",
738
+ accountId: "acc-refresh",
739
+ username: "u",
740
+ },
741
+ });
742
+ mockResolveServiceCredentials
743
+ .mockResolvedValueOnce({ token: "initial-token" })
744
+ .mockResolvedValueOnce(null); // Second resolve fails
745
+ (global.fetch as any).mockResolvedValueOnce({
746
+ ok: false,
747
+ status: 401,
748
+ statusText: "Unauthorized",
749
+ json: async () => ({}),
750
+ });
751
+ const refreshFailResult = await executeManageEmailTool(
752
+ {} as any,
753
+ {
754
+ action: "mark_as_read",
755
+ connection_id: "conn-oauth-fail-refresh",
756
+ message_uids: [1],
757
+ },
758
+ "group-1",
759
+ );
760
+ expect(refreshFailResult).toContain("OAuth account reconnect required");
761
+ });
762
+
763
+ it("download_attachments validates missing uid, disabled connection, and not found", async () => {
764
+ // Missing uid
765
+ mockGetEmailConnection.mockResolvedValueOnce({
766
+ id: "conn-uid",
767
+ pluginId: "imap",
768
+ enabled: true,
769
+ config: {},
770
+ });
771
+ const noUid = await executeManageEmailTool(
772
+ {} as any,
773
+ { action: "download_attachments", connection_id: "conn-uid" },
774
+ "group-1",
775
+ );
776
+ expect(noUid).toContain("requires message_uid");
777
+
778
+ // Disabled connection
779
+ mockGetEmailConnection.mockResolvedValueOnce({
780
+ id: "conn-disabled",
781
+ pluginId: "imap",
782
+ enabled: false,
783
+ config: {},
784
+ });
785
+ const disabledRes = await executeManageEmailTool(
786
+ {} as any,
787
+ {
788
+ action: "download_attachments",
789
+ connection_id: "conn-disabled",
790
+ message_uid: 1,
791
+ },
792
+ "group-1",
793
+ );
794
+ expect(disabledRes).toContain("is disabled");
795
+
796
+ // Not found
797
+ mockGetEmailConnection.mockResolvedValueOnce(null);
798
+ mockListEmailConnections.mockResolvedValueOnce([]);
799
+ const notFoundRes = await executeManageEmailTool(
800
+ {} as any,
801
+ {
802
+ action: "download_attachments",
803
+ connection_id: "conn-missing",
804
+ message_uid: 1,
805
+ },
806
+ "group-1",
807
+ );
808
+ expect(notFoundRes).toContain(
809
+ "Integration connection not found: conn-missing",
810
+ );
811
+ });
812
+
813
+ it("returns error on unsupported manage_email action", async () => {
814
+ const result = await executeManageEmailTool(
815
+ {} as any,
816
+ { action: "unknown_action" },
817
+ "group-1",
818
+ );
819
+ expect(result).toBe(
820
+ "Error: Unsupported manage_email action: unknown_action",
821
+ );
822
+ });
823
+
466
824
  it("parses email attachment inputs and guesses mime types correctly", async () => {
467
825
  const { asEmailAttachmentInputs } =
468
826
  await import("./utils/asEmailAttachmentInputs.js");
@@ -0,0 +1,120 @@
1
+ import { jest } from "@jest/globals";
2
+
3
+ let resolveIntegrationEmailAuth: any;
4
+ let mockResolveServiceCredentials: any;
5
+ let mockDecryptValue: any;
6
+
7
+ describe("resolveIntegrationEmailAuth", () => {
8
+ beforeEach(async () => {
9
+ jest.resetModules();
10
+ mockDecryptValue = jest.fn(async () => "password123");
11
+ mockResolveServiceCredentials = jest.fn();
12
+
13
+ jest.unstable_mockModule("../../../../security/crypto.js", () => ({
14
+ decryptValue: mockDecryptValue,
15
+ }));
16
+
17
+ jest.unstable_mockModule(
18
+ "../../../../subsystems/accounts/service-accounts.js",
19
+ () => ({
20
+ resolveServiceCredentials: mockResolveServiceCredentials,
21
+ }),
22
+ );
23
+
24
+ const mod = await import("./resolveIntegrationEmailAuth.js");
25
+ resolveIntegrationEmailAuth = mod.resolveIntegrationEmailAuth;
26
+ });
27
+
28
+ it("returns error when username is missing", async () => {
29
+ const conn: any = {
30
+ id: "conn-1",
31
+ config: {},
32
+ };
33
+ const result = await resolveIntegrationEmailAuth({} as any, conn, {});
34
+ expect(result).toBe(
35
+ "Error: Integration connection conn-1 is missing username credentials.",
36
+ );
37
+ });
38
+
39
+ it("handles OAuth connection missing linked accountId", async () => {
40
+ const conn: any = {
41
+ id: "conn-2",
42
+ config: { username: "user@test.com" },
43
+ credentialRef: { authType: "oauth" },
44
+ };
45
+ const result = await resolveIntegrationEmailAuth({} as any, conn, {});
46
+ expect(result).toBe(
47
+ "Error: Integration connection conn-2 is missing linked OAuth account.",
48
+ );
49
+ });
50
+
51
+ it("handles OAuth reconnect required when creds cannot be resolved or lack token", async () => {
52
+ mockResolveServiceCredentials.mockResolvedValueOnce(null);
53
+
54
+ const conn: any = {
55
+ id: "conn-3",
56
+ config: { username: "user@test.com" },
57
+ credentialRef: {
58
+ authType: "oauth",
59
+ accountId: "acc-1",
60
+ providerId: "google",
61
+ },
62
+ };
63
+
64
+ const result = await resolveIntegrationEmailAuth({} as any, conn, {});
65
+ expect(result).toContain(
66
+ "OAuth account reconnect required for integration conn-3",
67
+ );
68
+ expect(result).toContain("Connect OAuth for google");
69
+ });
70
+
71
+ it("handles OAuth success", async () => {
72
+ mockResolveServiceCredentials.mockResolvedValueOnce({
73
+ token: "oauth-tok-123",
74
+ });
75
+
76
+ const conn: any = {
77
+ id: "conn-oauth-ok",
78
+ config: { username: "user@test.com" },
79
+ credentialRef: {
80
+ authType: "oauth",
81
+ accountId: "acc-1",
82
+ providerId: "google",
83
+ },
84
+ };
85
+
86
+ const result = await resolveIntegrationEmailAuth({} as any, conn, {});
87
+ expect(result).toEqual({
88
+ authType: "oauth",
89
+ username: "user@test.com",
90
+ accessToken: "oauth-tok-123",
91
+ });
92
+ });
93
+
94
+ it("returns error when basic password credentials are missing", async () => {
95
+ mockDecryptValue.mockResolvedValueOnce(null);
96
+ const conn: any = {
97
+ id: "conn-4",
98
+ config: { username: "user@test.com" },
99
+ credentialRef: { authType: "basic_userpass" },
100
+ };
101
+ const result = await resolveIntegrationEmailAuth({} as any, conn, {});
102
+ expect(result).toBe(
103
+ "Error: Integration connection conn-4 is missing password credentials.",
104
+ );
105
+ });
106
+
107
+ it("resolves basic auth with password", async () => {
108
+ const conn: any = {
109
+ id: "conn-5",
110
+ config: { username: "user@test.com" },
111
+ credentialRef: { authType: "basic_userpass", encryptedSecret: "enc:pw" },
112
+ };
113
+ const result = await resolveIntegrationEmailAuth({} as any, conn, {});
114
+ expect(result).toEqual({
115
+ authType: "basic_userpass",
116
+ username: "user@test.com",
117
+ password: "password123",
118
+ });
119
+ });
120
+ });