@vellumai/assistant 0.4.46 → 0.4.48

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 (194) hide show
  1. package/ARCHITECTURE.md +5 -5
  2. package/docs/architecture/security.md +5 -5
  3. package/package.json +1 -1
  4. package/src/__tests__/browser-fill-credential.test.ts +5 -2
  5. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +2 -1
  6. package/src/__tests__/channel-readiness-routes.test.ts +20 -19
  7. package/src/__tests__/cli.test.ts +23 -0
  8. package/src/__tests__/credential-broker-browser-fill.test.ts +23 -22
  9. package/src/__tests__/credential-broker-server-use.test.ts +22 -21
  10. package/src/__tests__/credential-broker.test.ts +2 -1
  11. package/src/__tests__/credential-metadata-store.test.ts +240 -18
  12. package/src/__tests__/credential-resolve.test.ts +5 -4
  13. package/src/__tests__/credential-security-e2e.test.ts +8 -8
  14. package/src/__tests__/credential-security-invariants.test.ts +104 -6
  15. package/src/__tests__/credential-vault-unit.test.ts +22 -20
  16. package/src/__tests__/credential-vault.test.ts +284 -12
  17. package/src/__tests__/credentials-cli.test.ts +11 -6
  18. package/src/__tests__/gateway-only-enforcement.test.ts +4 -2
  19. package/src/__tests__/gemini-image-service.test.ts +75 -45
  20. package/src/__tests__/gemini-provider.test.ts +9 -6
  21. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -33
  22. package/src/__tests__/guardian-action-copy-generator.test.ts +0 -20
  23. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -28
  24. package/src/__tests__/guardian-action-followup-store.test.ts +1 -1
  25. package/src/__tests__/guardian-grant-minting.test.ts +35 -0
  26. package/src/__tests__/integration-status.test.ts +53 -21
  27. package/src/__tests__/managed-proxy-context.test.ts +5 -3
  28. package/src/__tests__/media-generate-image.test.ts +63 -2
  29. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -3
  30. package/src/__tests__/messaging-send-tool.test.ts +4 -6
  31. package/src/__tests__/provider-fail-open-selection.test.ts +3 -1
  32. package/src/__tests__/provider-managed-proxy-integration.test.ts +70 -6
  33. package/src/__tests__/schema-transforms.test.ts +226 -0
  34. package/src/__tests__/script-proxy-injection-runtime.test.ts +23 -13
  35. package/src/__tests__/script-proxy-policy-runtime.test.ts +1 -1
  36. package/src/__tests__/script-proxy-session-manager.test.ts +1 -1
  37. package/src/__tests__/secret-onetime-send.test.ts +5 -3
  38. package/src/__tests__/session-messaging-secret-redirect.test.ts +5 -4
  39. package/src/__tests__/skills-uninstall.test.ts +2 -2
  40. package/src/__tests__/skills.test.ts +0 -9
  41. package/src/__tests__/slack-channel-config.test.ts +9 -8
  42. package/src/__tests__/slack-share-routes.test.ts +11 -6
  43. package/src/__tests__/telegram-bot-username-resolution.test.ts +3 -0
  44. package/src/__tests__/twilio-config.test.ts +2 -1
  45. package/src/__tests__/twilio-provider.test.ts +4 -2
  46. package/src/__tests__/twilio-routes.test.ts +5 -4
  47. package/src/calls/call-domain.ts +7 -4
  48. package/src/calls/twilio-config.ts +2 -1
  49. package/src/calls/twilio-provider.ts +2 -1
  50. package/src/calls/twilio-rest.ts +2 -1
  51. package/src/cli/commands/browser-relay.ts +40 -15
  52. package/src/cli/commands/credentials.ts +9 -8
  53. package/src/cli/commands/oauth.ts +1 -1
  54. package/src/cli.ts +3 -2
  55. package/src/config/bundled-skills/claude-code/TOOLS.json +0 -4
  56. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +29 -32
  57. package/src/config/bundled-skills/gmail/SKILL.md +4 -4
  58. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +54 -61
  59. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +25 -28
  60. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +14 -17
  61. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +39 -44
  62. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +61 -58
  63. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +50 -49
  64. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +11 -13
  65. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +148 -146
  66. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +4 -7
  67. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +175 -173
  68. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +4 -7
  69. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +71 -76
  70. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +32 -38
  71. package/src/config/bundled-skills/google-calendar/SKILL.md +2 -2
  72. package/src/config/bundled-skills/google-calendar/calendar-client.ts +70 -29
  73. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +9 -10
  74. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +5 -6
  75. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +4 -5
  76. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +14 -15
  77. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +37 -37
  78. package/src/config/bundled-skills/google-calendar/tools/shared.ts +4 -9
  79. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +24 -3
  80. package/src/config/bundled-skills/messaging/SKILL.md +6 -6
  81. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +62 -63
  82. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +15 -16
  83. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +4 -5
  84. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +6 -7
  85. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +4 -5
  86. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +14 -15
  87. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +4 -5
  88. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +128 -128
  89. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +33 -34
  90. package/src/config/bundled-skills/messaging/tools/shared.ts +11 -11
  91. package/src/config/bundled-skills/slack/tools/shared.ts +4 -10
  92. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +4 -5
  93. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +15 -16
  94. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +4 -5
  95. package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +4 -5
  96. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +4 -5
  97. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +95 -92
  98. package/src/config/loader.ts +6 -0
  99. package/src/daemon/computer-use-session.ts +7 -1
  100. package/src/daemon/guardian-action-generators.ts +4 -5
  101. package/src/daemon/handlers/config-slack-channel.ts +37 -20
  102. package/src/daemon/handlers/config-telegram.ts +33 -20
  103. package/src/daemon/lifecycle.ts +9 -1
  104. package/src/daemon/message-types/integrations.ts +1 -0
  105. package/src/daemon/ride-shotgun-handler.ts +3 -1
  106. package/src/daemon/session-messaging.ts +3 -1
  107. package/src/daemon/session-tool-setup.ts +18 -2
  108. package/src/daemon/session.ts +1 -1
  109. package/src/email/providers/index.ts +2 -1
  110. package/src/instrument.ts +15 -1
  111. package/src/media/app-icon-generator.ts +30 -4
  112. package/src/media/avatar-router.ts +26 -3
  113. package/src/media/gemini-image-service.ts +28 -2
  114. package/src/memory/guardian-action-store.ts +1 -1
  115. package/src/memory/schema/guardian.ts +1 -1
  116. package/src/messaging/provider.ts +16 -10
  117. package/src/messaging/providers/gmail/adapter.ts +40 -23
  118. package/src/messaging/providers/gmail/client.ts +203 -122
  119. package/src/messaging/providers/gmail/people-client.ts +26 -18
  120. package/src/messaging/providers/slack/adapter.ts +29 -19
  121. package/src/messaging/providers/slack/client.ts +265 -78
  122. package/src/messaging/providers/telegram-bot/adapter.ts +5 -4
  123. package/src/messaging/providers/whatsapp/adapter.ts +6 -3
  124. package/src/messaging/registry.ts +2 -1
  125. package/src/oauth/byo-connection.test.ts +436 -0
  126. package/src/oauth/byo-connection.ts +112 -0
  127. package/src/oauth/connect-orchestrator.ts +27 -0
  128. package/src/oauth/connection-resolver.ts +34 -0
  129. package/src/oauth/connection.ts +38 -0
  130. package/src/oauth/platform-connection.test.ts +163 -0
  131. package/src/oauth/platform-connection.ts +110 -0
  132. package/src/oauth/provider-base-urls.ts +21 -0
  133. package/src/oauth/provider-profiles.ts +1 -1
  134. package/src/oauth/token-persistence.ts +20 -20
  135. package/src/permissions/checker.ts +5 -1
  136. package/src/prompts/system-prompt.ts +49 -12
  137. package/src/providers/gemini/client.ts +15 -6
  138. package/src/providers/managed-proxy/constants.ts +2 -2
  139. package/src/providers/managed-proxy/context.ts +5 -1
  140. package/src/providers/ratelimit.ts +17 -0
  141. package/src/providers/registry.ts +2 -2
  142. package/src/runtime/AGENTS.md +17 -0
  143. package/src/runtime/channel-invite-transports/telegram.ts +2 -1
  144. package/src/runtime/channel-readiness-service.ts +168 -195
  145. package/src/runtime/channel-readiness-types.ts +4 -0
  146. package/src/runtime/guardian-action-conversation-turn.ts +1 -3
  147. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  148. package/src/runtime/guardian-action-message-composer.ts +3 -23
  149. package/src/runtime/http-server.ts +9 -4
  150. package/src/runtime/http-types.ts +0 -1
  151. package/src/runtime/middleware/rate-limiter.ts +74 -20
  152. package/src/runtime/routes/channel-readiness-routes.ts +2 -0
  153. package/src/runtime/routes/diagnostics-routes.ts +11 -9
  154. package/src/runtime/routes/guardian-approval-interception.ts +20 -5
  155. package/src/runtime/routes/integrations/slack/share.ts +3 -2
  156. package/src/runtime/routes/integrations/twilio.ts +6 -5
  157. package/src/runtime/routes/secret-routes.ts +3 -2
  158. package/src/runtime/routes/settings-routes.ts +75 -17
  159. package/src/runtime/telegram-streaming-delivery.test.ts +132 -0
  160. package/src/runtime/telegram-streaming-delivery.ts +11 -1
  161. package/src/schedule/integration-status.ts +5 -4
  162. package/src/security/credential-key.ts +170 -0
  163. package/src/security/token-manager.ts +36 -7
  164. package/src/tools/apps/definitions.ts +0 -5
  165. package/src/tools/assets/materialize.ts +0 -5
  166. package/src/tools/assets/search.ts +0 -5
  167. package/src/tools/browser/headless-browser.ts +1 -67
  168. package/src/tools/claude-code/claude-code.ts +0 -5
  169. package/src/tools/computer-use/request-computer-control.ts +0 -5
  170. package/src/tools/credentials/broker.ts +6 -4
  171. package/src/tools/credentials/metadata-store.ts +72 -20
  172. package/src/tools/credentials/resolve.ts +2 -1
  173. package/src/tools/credentials/vault.ts +77 -16
  174. package/src/tools/filesystem/edit.ts +1 -6
  175. package/src/tools/filesystem/read.ts +0 -5
  176. package/src/tools/filesystem/write.ts +1 -6
  177. package/src/tools/host-filesystem/edit.ts +1 -6
  178. package/src/tools/host-filesystem/read.ts +1 -6
  179. package/src/tools/host-filesystem/write.ts +1 -6
  180. package/src/tools/mcp/mcp-tool-factory.ts +18 -1
  181. package/src/tools/memory/definitions.ts +0 -5
  182. package/src/tools/network/web-fetch.ts +0 -5
  183. package/src/tools/network/web-search.ts +0 -5
  184. package/src/tools/schema-transforms.ts +99 -0
  185. package/src/tools/skills/load.ts +0 -5
  186. package/src/tools/swarm/delegate.ts +0 -5
  187. package/src/tools/system/avatar-generator.ts +0 -5
  188. package/src/tools/ui-surface/definitions.ts +0 -15
  189. package/src/tools/watch/screen-watch.ts +0 -5
  190. package/src/version.ts +10 -0
  191. package/src/watcher/providers/github.ts +51 -52
  192. package/src/watcher/providers/gmail.ts +88 -80
  193. package/src/watcher/providers/google-calendar.ts +93 -86
  194. package/src/watcher/providers/linear.ts +87 -93
@@ -294,6 +294,52 @@ describe("credential metadata store", () => {
294
294
  });
295
295
  });
296
296
 
297
+ // ── v4 Schema: hasRefreshToken ──────────────────────────────────────
298
+
299
+ describe("v4 schema — hasRefreshToken", () => {
300
+ test("creates record with hasRefreshToken", () => {
301
+ const record = upsertCredentialMetadata("github", "access_token", {
302
+ hasRefreshToken: true,
303
+ });
304
+ expect(record.hasRefreshToken).toBe(true);
305
+ });
306
+
307
+ test("creates record with hasRefreshToken false", () => {
308
+ const record = upsertCredentialMetadata("github", "access_token", {
309
+ hasRefreshToken: false,
310
+ });
311
+ expect(record.hasRefreshToken).toBe(false);
312
+ });
313
+
314
+ test("defaults hasRefreshToken to undefined when not provided", () => {
315
+ const record = upsertCredentialMetadata("github", "access_token");
316
+ expect(record.hasRefreshToken).toBeUndefined();
317
+ });
318
+
319
+ test("updates hasRefreshToken on existing record", () => {
320
+ upsertCredentialMetadata("github", "access_token", {
321
+ hasRefreshToken: false,
322
+ });
323
+ const updated = upsertCredentialMetadata("github", "access_token", {
324
+ hasRefreshToken: true,
325
+ });
326
+ expect(updated.hasRefreshToken).toBe(true);
327
+ });
328
+
329
+ test("round-trip: hasRefreshToken survives serialization", () => {
330
+ upsertCredentialMetadata("github", "access_token", {
331
+ hasRefreshToken: true,
332
+ allowedTools: ["api_request"],
333
+ });
334
+
335
+ // Re-read from disk
336
+ const loaded = getCredentialMetadata("github", "access_token");
337
+ expect(loaded).toBeDefined();
338
+ expect(loaded!.hasRefreshToken).toBe(true);
339
+ expect(loaded!.allowedTools).toEqual(["api_request"]);
340
+ });
341
+ });
342
+
297
343
  // ── Version migration ──────────────────────────────────────────────
298
344
 
299
345
  describe("version migration", () => {
@@ -371,7 +417,7 @@ describe("credential metadata store", () => {
371
417
  expect(record!.credentialId).toBe("cred-stable-id");
372
418
  });
373
419
 
374
- test("v2 file is loaded without migration", () => {
420
+ test("v2 file is migrated to v4 (strips oauth2ClientSecret)", () => {
375
421
  const v2Data = {
376
422
  version: 2,
377
423
  credentials: [
@@ -382,6 +428,7 @@ describe("credential metadata store", () => {
382
428
  allowedTools: ["api_request"],
383
429
  allowedDomains: ["fal.ai"],
384
430
  alias: "fal-primary",
431
+ oauth2ClientSecret: "should-be-stripped",
385
432
  injectionTemplates: [
386
433
  {
387
434
  hostPattern: "*.fal.ai",
@@ -402,9 +449,184 @@ describe("credential metadata store", () => {
402
449
  expect(record!.alias).toBe("fal-primary");
403
450
  expect(record!.injectionTemplates).toHaveLength(1);
404
451
  expect(record!.injectionTemplates![0].hostPattern).toBe("*.fal.ai");
452
+ // oauth2ClientSecret must be stripped by v2→v3 migration
453
+ expect("oauth2ClientSecret" in record!).toBe(false);
454
+
455
+ // On-disk file should be upgraded to v4
456
+ const raw = JSON.parse(readFileSync(META_PATH, "utf-8"));
457
+ expect(raw.version).toBe(4);
458
+ expect(raw.credentials[0]).not.toHaveProperty("oauth2ClientSecret");
459
+ });
460
+
461
+ test("v3 file is migrated to v4 (removes ghost refresh_token records)", () => {
462
+ const v3Data = {
463
+ version: 3,
464
+ credentials: [
465
+ {
466
+ credentialId: "cred-v3-at",
467
+ service: "github",
468
+ field: "access_token",
469
+ allowedTools: ["api_request"],
470
+ allowedDomains: ["github.com"],
471
+ createdAt: 1700000000000,
472
+ updatedAt: 1700000000000,
473
+ },
474
+ {
475
+ credentialId: "cred-v3-rt",
476
+ service: "github",
477
+ field: "refresh_token",
478
+ allowedTools: [],
479
+ allowedDomains: [],
480
+ createdAt: 1700000000000,
481
+ updatedAt: 1700000000000,
482
+ },
483
+ ],
484
+ };
485
+ writeFileSync(META_PATH, JSON.stringify(v3Data, null, 2), "utf-8");
486
+
487
+ const records = listCredentialMetadata();
488
+ // Ghost refresh_token record removed
489
+ expect(records).toHaveLength(1);
490
+ expect(records[0].field).toBe("access_token");
491
+ expect(records[0].hasRefreshToken).toBe(true);
492
+
493
+ // On-disk file should be upgraded to v4
494
+ const raw = JSON.parse(readFileSync(META_PATH, "utf-8"));
495
+ expect(raw.version).toBe(4);
496
+ expect(raw.credentials).toHaveLength(1);
497
+ expect(raw.credentials[0].field).toBe("access_token");
498
+ });
499
+
500
+ test("v3 file without refresh_token records migrates cleanly", () => {
501
+ const v3Data = {
502
+ version: 3,
503
+ credentials: [
504
+ {
505
+ credentialId: "cred-v3-001",
506
+ service: "fal-ai",
507
+ field: "api_key",
508
+ allowedTools: ["api_request"],
509
+ allowedDomains: ["fal.ai"],
510
+ alias: "fal-primary",
511
+ injectionTemplates: [
512
+ {
513
+ hostPattern: "*.fal.ai",
514
+ injectionType: "header",
515
+ headerName: "Authorization",
516
+ valuePrefix: "Key ",
517
+ },
518
+ ],
519
+ createdAt: 1700000000000,
520
+ updatedAt: 1700000000000,
521
+ },
522
+ ],
523
+ };
524
+ writeFileSync(META_PATH, JSON.stringify(v3Data, null, 2), "utf-8");
525
+
526
+ const record = getCredentialMetadata("fal-ai", "api_key");
527
+ expect(record).toBeDefined();
528
+ expect(record!.alias).toBe("fal-primary");
529
+ expect(record!.injectionTemplates).toHaveLength(1);
530
+ expect(record!.injectionTemplates![0].hostPattern).toBe("*.fal.ai");
531
+ expect(record!.hasRefreshToken).toBeUndefined();
532
+
533
+ // On-disk file should be upgraded to v4
534
+ const raw = JSON.parse(readFileSync(META_PATH, "utf-8"));
535
+ expect(raw.version).toBe(4);
536
+ });
537
+
538
+ test("v3 migration handles multiple services with ghost records", () => {
539
+ const v3Data = {
540
+ version: 3,
541
+ credentials: [
542
+ {
543
+ credentialId: "at-github",
544
+ service: "github",
545
+ field: "access_token",
546
+ allowedTools: [],
547
+ allowedDomains: [],
548
+ createdAt: 1700000000000,
549
+ updatedAt: 1700000000000,
550
+ },
551
+ {
552
+ credentialId: "rt-github",
553
+ service: "github",
554
+ field: "refresh_token",
555
+ allowedTools: [],
556
+ allowedDomains: [],
557
+ createdAt: 1700000000000,
558
+ updatedAt: 1700000000000,
559
+ },
560
+ {
561
+ credentialId: "at-slack",
562
+ service: "slack",
563
+ field: "access_token",
564
+ allowedTools: [],
565
+ allowedDomains: [],
566
+ createdAt: 1700000000000,
567
+ updatedAt: 1700000000000,
568
+ },
569
+ {
570
+ credentialId: "at-stripe",
571
+ service: "stripe",
572
+ field: "access_token",
573
+ allowedTools: [],
574
+ allowedDomains: [],
575
+ createdAt: 1700000000000,
576
+ updatedAt: 1700000000000,
577
+ },
578
+ {
579
+ credentialId: "rt-stripe",
580
+ service: "stripe",
581
+ field: "refresh_token",
582
+ allowedTools: [],
583
+ allowedDomains: [],
584
+ createdAt: 1700000000000,
585
+ updatedAt: 1700000000000,
586
+ },
587
+ ],
588
+ };
589
+ writeFileSync(META_PATH, JSON.stringify(v3Data, null, 2), "utf-8");
590
+
591
+ const records = listCredentialMetadata();
592
+ // refresh_token records removed, only access_token records remain
593
+ expect(records).toHaveLength(3);
594
+ expect(records.every((r) => r.field !== "refresh_token")).toBe(true);
595
+ // github and stripe had refresh tokens
596
+ const github = records.find((r) => r.service === "github");
597
+ const slack = records.find((r) => r.service === "slack");
598
+ const stripe = records.find((r) => r.service === "stripe");
599
+ expect(github!.hasRefreshToken).toBe(true);
600
+ expect(slack!.hasRefreshToken).toBeUndefined();
601
+ expect(stripe!.hasRefreshToken).toBe(true);
405
602
  });
406
603
 
407
- test("future version (v3+) returns unknown version and refuses writes", () => {
604
+ test("v4 file is loaded without migration", () => {
605
+ const v4Data = {
606
+ version: 4,
607
+ credentials: [
608
+ {
609
+ credentialId: "cred-v4-001",
610
+ service: "fal-ai",
611
+ field: "api_key",
612
+ allowedTools: ["api_request"],
613
+ allowedDomains: ["fal.ai"],
614
+ alias: "fal-primary",
615
+ hasRefreshToken: true,
616
+ createdAt: 1700000000000,
617
+ updatedAt: 1700000000000,
618
+ },
619
+ ],
620
+ };
621
+ writeFileSync(META_PATH, JSON.stringify(v4Data, null, 2), "utf-8");
622
+
623
+ const record = getCredentialMetadata("fal-ai", "api_key");
624
+ expect(record).toBeDefined();
625
+ expect(record!.alias).toBe("fal-primary");
626
+ expect(record!.hasRefreshToken).toBe(true);
627
+ });
628
+
629
+ test("future version (v5+) returns unknown version and refuses writes", () => {
408
630
  const futureData = {
409
631
  version: 99,
410
632
  credentials: [],
@@ -439,7 +661,7 @@ describe("credential metadata store", () => {
439
661
  },
440
662
  );
441
663
 
442
- test("upsert on migrated v1 file saves as v2", () => {
664
+ test("upsert on migrated v1 file saves as v4", () => {
443
665
  const v1Data = {
444
666
  version: 1,
445
667
  credentials: [
@@ -459,13 +681,13 @@ describe("credential metadata store", () => {
459
681
  // Upsert triggers load (migration) + save (at current version)
460
682
  upsertCredentialMetadata("github", "token", { alias: "gh-main" });
461
683
 
462
- // Verify on-disk file is now v2
684
+ // Verify on-disk file is now v4
463
685
  const raw = JSON.parse(readFileSync(META_PATH, "utf-8"));
464
- expect(raw.version).toBe(2);
686
+ expect(raw.version).toBe(4);
465
687
  expect(raw.credentials[0].alias).toBe("gh-main");
466
688
  });
467
689
 
468
- test("v1 load auto-persists as v2 on disk without requiring a write", () => {
690
+ test("v1 load auto-persists as v4 on disk without requiring a write", () => {
469
691
  const v1Data = {
470
692
  version: 1,
471
693
  credentials: [
@@ -482,11 +704,11 @@ describe("credential metadata store", () => {
482
704
  };
483
705
  writeFileSync(META_PATH, JSON.stringify(v1Data, null, 2), "utf-8");
484
706
 
485
- // A read-only operation should still persist the v2 upgrade
707
+ // A read-only operation should still persist the v4 upgrade
486
708
  listCredentialMetadata();
487
709
 
488
710
  const raw = JSON.parse(readFileSync(META_PATH, "utf-8"));
489
- expect(raw.version).toBe(2);
711
+ expect(raw.version).toBe(4);
490
712
  expect(raw.credentials[0].credentialId).toBe("cred-autopersist");
491
713
  });
492
714
 
@@ -586,20 +808,20 @@ describe("credential metadata store", () => {
586
808
  test("file with non-array credentials field is treated as empty list", () => {
587
809
  writeFileSync(
588
810
  META_PATH,
589
- JSON.stringify({ version: 2, credentials: "not-an-array" }),
811
+ JSON.stringify({ version: 4, credentials: "not-an-array" }),
590
812
  "utf-8",
591
813
  );
592
814
  expect(listCredentialMetadata()).toEqual([]);
593
815
  });
594
816
 
595
817
  test("file with missing credentials field is treated as empty list", () => {
596
- writeFileSync(META_PATH, JSON.stringify({ version: 2 }), "utf-8");
818
+ writeFileSync(META_PATH, JSON.stringify({ version: 4 }), "utf-8");
597
819
  expect(listCredentialMetadata()).toEqual([]);
598
820
  });
599
821
 
600
822
  test("malformed records within credentials array are filtered out", () => {
601
823
  const data = {
602
- version: 2,
824
+ version: 4,
603
825
  credentials: [
604
826
  // Valid record
605
827
  {
@@ -709,7 +931,7 @@ describe("credential metadata store", () => {
709
931
 
710
932
  const raw = readFileSync(META_PATH, "utf-8");
711
933
  const parsed = JSON.parse(raw);
712
- expect(parsed.version).toBe(2);
934
+ expect(parsed.version).toBe(4);
713
935
  expect(parsed.credentials).toHaveLength(1);
714
936
  expect(parsed.credentials[0].service).toBe("slack");
715
937
  });
@@ -717,23 +939,23 @@ describe("credential metadata store", () => {
717
939
  test("file written by saveFile has version field", () => {
718
940
  upsertCredentialMetadata("test", "key");
719
941
  const raw = JSON.parse(readFileSync(META_PATH, "utf-8"));
720
- expect(raw.version).toBe(2);
942
+ expect(raw.version).toBe(4);
721
943
  });
722
944
  });
723
945
 
724
946
  // ── Empty credential lists ────────────────────────────────────────
725
947
 
726
948
  describe("empty credential lists", () => {
727
- test("empty v2 file returns empty array", () => {
949
+ test("empty v4 file returns empty array", () => {
728
950
  writeFileSync(
729
951
  META_PATH,
730
- JSON.stringify({ version: 2, credentials: [] }, null, 2),
952
+ JSON.stringify({ version: 4, credentials: [] }, null, 2),
731
953
  "utf-8",
732
954
  );
733
955
  expect(listCredentialMetadata()).toEqual([]);
734
956
  });
735
957
 
736
- test("empty v1 file is migrated to v2 with empty credentials", () => {
958
+ test("empty v1 file is migrated to v4 with empty credentials", () => {
737
959
  writeFileSync(
738
960
  META_PATH,
739
961
  JSON.stringify({ version: 1, credentials: [] }, null, 2),
@@ -741,9 +963,9 @@ describe("credential metadata store", () => {
741
963
  );
742
964
  expect(listCredentialMetadata()).toEqual([]);
743
965
 
744
- // Should be persisted as v2
966
+ // Should be persisted as v4
745
967
  const raw = JSON.parse(readFileSync(META_PATH, "utf-8"));
746
- expect(raw.version).toBe(2);
968
+ expect(raw.version).toBe(4);
747
969
  expect(raw.credentials).toEqual([]);
748
970
  });
749
971
 
@@ -12,6 +12,7 @@ mock.module("../util/logger.js", () => ({
12
12
  }),
13
13
  }));
14
14
 
15
+ import { credentialKey } from "../security/credential-key.js";
15
16
  import {
16
17
  _setMetadataPath,
17
18
  upsertCredentialMetadata,
@@ -56,7 +57,7 @@ describe("credential resolver", () => {
56
57
  expect(result!.credentialId).toBe(created.credentialId);
57
58
  expect(result!.service).toBe("github");
58
59
  expect(result!.field).toBe("token");
59
- expect(result!.storageKey).toBe("credential:github:token");
60
+ expect(result!.storageKey).toBe(credentialKey("github", "token"));
60
61
  expect(result!.metadata.allowedTools).toEqual([
61
62
  "browser_fill_credential",
62
63
  ]);
@@ -111,7 +112,7 @@ describe("credential resolver", () => {
111
112
  expect(result!.credentialId).toBe(created.credentialId);
112
113
  expect(result!.service).toBe("gmail");
113
114
  expect(result!.field).toBe("password");
114
- expect(result!.storageKey).toBe("credential:gmail:password");
115
+ expect(result!.storageKey).toBe(credentialKey("gmail", "password"));
115
116
  });
116
117
 
117
118
  test("returns undefined for non-existent ID", () => {
@@ -190,10 +191,10 @@ describe("credential resolver", () => {
190
191
  });
191
192
 
192
193
  describe("storage key format", () => {
193
- test("storage key follows credential:{service}:{field} format", () => {
194
+ test("storage key follows credential/{service}/{field} format", () => {
194
195
  upsertCredentialMetadata("my-service", "api-key");
195
196
  const result = resolveByServiceField("my-service", "api-key");
196
- expect(result!.storageKey).toBe("credential:my-service:api-key");
197
+ expect(result!.storageKey).toBe(credentialKey("my-service", "api-key"));
197
198
  });
198
199
  });
199
200
 
@@ -147,12 +147,12 @@ describe("E2E: secure store and list lifecycle", () => {
147
147
  // Value must NOT appear in tool output (invariant 1)
148
148
  expect(result.content).not.toContain("ghp_abc123");
149
149
  // Value must be in keychain
150
- expect(storedKeys.get("credential:github:token")).toBe("ghp_abc123");
150
+ expect(storedKeys.get("credential/github/token")).toBe("ghp_abc123");
151
151
  });
152
152
 
153
153
  test("list returns service/field pairs without secret values", async () => {
154
- storedKeys.set("credential:github:token", "secret1");
155
- storedKeys.set("credential:aws:access_key", "secret2");
154
+ storedKeys.set("credential/github/token", "secret1");
155
+ storedKeys.set("credential/aws/access_key", "secret2");
156
156
  metadataStore.set("github:token", {
157
157
  credentialId: "cred-github-token",
158
158
  service: "github",
@@ -177,14 +177,14 @@ describe("E2E: secure store and list lifecycle", () => {
177
177
  });
178
178
 
179
179
  test("delete removes credential from keychain", async () => {
180
- storedKeys.set("credential:github:token", "secret1");
180
+ storedKeys.set("credential/github/token", "secret1");
181
181
 
182
182
  const result = await credentialStoreTool.execute(
183
183
  { action: "delete", service: "github", field: "token" },
184
184
  makeContext(),
185
185
  );
186
186
  expect(result.isError).toBe(false);
187
- expect(storedKeys.has("credential:github:token")).toBe(false);
187
+ expect(storedKeys.has("credential/github/token")).toBe(false);
188
188
  });
189
189
  });
190
190
 
@@ -267,7 +267,7 @@ describe("E2E: one-time send override", () => {
267
267
  );
268
268
  expect(result.isError).toBe(true);
269
269
  expect(result.content).toContain("not enabled");
270
- expect(storedKeys.has("credential:svc:key")).toBe(false);
270
+ expect(storedKeys.has("credential/svc/key")).toBe(false);
271
271
  });
272
272
 
273
273
  test("accepts transient_send when config gate is on", async () => {
@@ -285,7 +285,7 @@ describe("E2E: one-time send override", () => {
285
285
  expect(result.isError).toBe(false);
286
286
  expect(result.content).toContain("NOT saved");
287
287
  // Value must NOT be in keychain
288
- expect(storedKeys.has("credential:svc:key")).toBe(false);
288
+ expect(storedKeys.has("credential/svc/key")).toBe(false);
289
289
  // Value must NOT appear in output
290
290
  expect(result.content).not.toContain("tmp1");
291
291
  });
@@ -303,7 +303,7 @@ describe("E2E: one-time send override", () => {
303
303
  ctx,
304
304
  );
305
305
  expect(result.isError).toBe(false);
306
- expect(storedKeys.has("credential:svc:key")).toBe(true);
306
+ expect(storedKeys.has("credential/svc/key")).toBe(true);
307
307
  });
308
308
  });
309
309
 
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdirSync, rmSync } from "node:fs";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
@@ -63,11 +63,13 @@ mock.module("../tools/registry.js", () => ({
63
63
  // ---------------------------------------------------------------------------
64
64
 
65
65
  import { DEFAULT_CONFIG } from "../config/defaults.js";
66
+ import { credentialKey } from "../security/credential-key.js";
66
67
  import { redactSensitiveFields } from "../security/redaction.js";
67
- import { setSecureKey } from "../security/secure-keys.js";
68
+ import { getSecureKey, setSecureKey } from "../security/secure-keys.js";
68
69
  import { CredentialBroker } from "../tools/credentials/broker.js";
69
70
  import {
70
71
  _setMetadataPath,
72
+ getCredentialMetadata,
71
73
  upsertCredentialMetadata,
72
74
  } from "../tools/credentials/metadata-store.js";
73
75
 
@@ -199,6 +201,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
199
201
  // Hard boundary: only these production files may import from secure-keys.
200
202
  // Any new import must be reviewed for secret-leak risk and added here.
201
203
  const ALLOWED_IMPORTERS = new Set([
204
+ "security/credential-key.ts", // credential key builder + migration
202
205
  "config/loader.ts", // config management (API keys)
203
206
  "tools/credentials/vault.ts", // credential store tool
204
207
  "tools/credentials/broker.ts", // brokered credential access
@@ -229,7 +232,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
229
232
  "runtime/routes/secret-routes.ts", // HTTP secret management routes (set/delete secrets)
230
233
  "daemon/ride-shotgun-handler.ts", // learn session cookie persistence
231
234
  "daemon/session-messaging.ts", // credential storage during session messaging
232
- "runtime/routes/settings-routes.ts", // settings routes OAuth credential lookup (client_id/client_secret/access tokens)
235
+ "runtime/routes/settings-routes.ts", // settings routes OAuth credential lookup (client_secret)
233
236
  ]);
234
237
 
235
238
  const thisDir = dirname(fileURLToPath(import.meta.url));
@@ -245,7 +248,11 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
245
248
  const s = statSync(full);
246
249
  if (s.isDirectory()) {
247
250
  collectTsFiles(full, files);
248
- } else if (entry.endsWith(".ts") && !entry.endsWith(".d.ts")) {
251
+ } else if (
252
+ entry.endsWith(".ts") &&
253
+ !entry.endsWith(".d.ts") &&
254
+ !entry.endsWith(".test.ts")
255
+ ) {
249
256
  files.push(full);
250
257
  }
251
258
  }
@@ -413,7 +420,10 @@ describe("Invariant 4: credentials only used for allowed purpose", () => {
413
420
  allowedTools: tc.allowedTools,
414
421
  allowedDomains: tc.allowedDomains,
415
422
  });
416
- setSecureKey(`credential:${tc.credentialId}:token`, "test-secret-value");
423
+ setSecureKey(
424
+ credentialKey(tc.credentialId, "token"),
425
+ "test-secret-value",
426
+ );
417
427
 
418
428
  const result = await broker.browserFill({
419
429
  service: tc.credentialId,
@@ -438,7 +448,7 @@ describe("Invariant 4: credentials only used for allowed purpose", () => {
438
448
  allowedTools: ["browser_fill_credential"],
439
449
  allowedDomains: ["github.com"],
440
450
  });
441
- setSecureKey("credential:github:token", "ghp_secret123");
451
+ setSecureKey(credentialKey("github", "token"), "ghp_secret123");
442
452
 
443
453
  const result = await broker.browserFill({
444
454
  service: "github",
@@ -470,6 +480,94 @@ describe("Invariant 4: credentials only used for allowed purpose", () => {
470
480
  });
471
481
  });
472
482
 
483
+ // ---------------------------------------------------------------------------
484
+ // Invariant 6 — oauth2ClientSecret never in plaintext metadata
485
+ // ---------------------------------------------------------------------------
486
+
487
+ describe("Invariant 6: oauth2ClientSecret not in metadata, only in secure store", () => {
488
+ beforeEach(() => {
489
+ mkdirSync(TEST_DIR, { recursive: true });
490
+ _setStorePath(STORE_PATH);
491
+ _resetBackend();
492
+ _setMetadataPath(join(TEST_DIR, "metadata.json"));
493
+ });
494
+
495
+ afterEach(() => {
496
+ _setMetadataPath(null);
497
+ _setStorePath(null);
498
+ _resetBackend();
499
+ rmSync(TEST_DIR, { recursive: true, force: true });
500
+ });
501
+
502
+ test("upsertCredentialMetadata does not accept oauth2ClientSecret", () => {
503
+ const record = upsertCredentialMetadata(
504
+ "integration:gmail",
505
+ "access_token",
506
+ {
507
+ oauth2TokenUrl: "https://oauth2.googleapis.com/token",
508
+ oauth2ClientId: "test-client-id",
509
+ },
510
+ );
511
+ expect("oauth2ClientSecret" in record).toBe(false);
512
+ });
513
+
514
+ test("client secret is read from secure store, not metadata", () => {
515
+ setSecureKey(
516
+ credentialKey("integration:gmail", "client_secret"),
517
+ "my-secret",
518
+ );
519
+ upsertCredentialMetadata("integration:gmail", "access_token", {
520
+ oauth2TokenUrl: "https://oauth2.googleapis.com/token",
521
+ oauth2ClientId: "test-client-id",
522
+ });
523
+
524
+ const meta = getCredentialMetadata("integration:gmail", "access_token");
525
+ expect(meta).toBeDefined();
526
+ expect("oauth2ClientSecret" in meta!).toBe(false);
527
+
528
+ // Secret is in secure store
529
+ expect(
530
+ getSecureKey(credentialKey("integration:gmail", "client_secret")),
531
+ ).toBe("my-secret");
532
+ });
533
+
534
+ test("v2 metadata with oauth2ClientSecret is stripped on migration", () => {
535
+ const v2Data = {
536
+ version: 2,
537
+ credentials: [
538
+ {
539
+ credentialId: "cred-v2-secret",
540
+ service: "integration:gmail",
541
+ field: "access_token",
542
+ allowedTools: [],
543
+ allowedDomains: [],
544
+ oauth2TokenUrl: "https://oauth2.googleapis.com/token",
545
+ oauth2ClientId: "test-client-id",
546
+ oauth2ClientSecret: "plaintext-secret-should-be-stripped",
547
+ createdAt: 1700000000000,
548
+ updatedAt: 1700000000000,
549
+ },
550
+ ],
551
+ };
552
+ writeFileSync(
553
+ join(TEST_DIR, "metadata.json"),
554
+ JSON.stringify(v2Data, null, 2),
555
+ "utf-8",
556
+ );
557
+
558
+ const meta = getCredentialMetadata("integration:gmail", "access_token");
559
+ expect(meta).toBeDefined();
560
+ expect("oauth2ClientSecret" in meta!).toBe(false);
561
+
562
+ // Verify on-disk file no longer contains the secret
563
+ const raw = JSON.parse(
564
+ readFileSync(join(TEST_DIR, "metadata.json"), "utf-8"),
565
+ );
566
+ expect(raw.credentials[0]).not.toHaveProperty("oauth2ClientSecret");
567
+ expect(raw.version).toBe(4);
568
+ });
569
+ });
570
+
473
571
  // ---------------------------------------------------------------------------
474
572
  // Cross-Cutting — One-Time Send Override
475
573
  // ---------------------------------------------------------------------------