@vellumai/assistant 0.12.0-staging.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/Dockerfile +5 -0
  2. package/docs/architecture/turn-actor.md +9 -0
  3. package/knip.json +1 -0
  4. package/node_modules/@vellumai/app-icons/package.json +18 -0
  5. package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
  6. package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
  7. package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  17. package/openapi.yaml +628 -0
  18. package/package.json +3 -1
  19. package/scripts/smoke-container-workspace-dependencies.ts +19 -0
  20. package/src/__tests__/app-builder-icon-names.test.ts +29 -0
  21. package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  25. package/src/__tests__/conversation-attachments.test.ts +142 -0
  26. package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
  27. package/src/__tests__/conversation-event-sink.test.ts +50 -0
  28. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
  29. package/src/__tests__/conversation-queue.test.ts +297 -0
  30. package/src/__tests__/credential-routes.test.ts +185 -6
  31. package/src/__tests__/drain-kick-guard.test.ts +2 -0
  32. package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
  33. package/src/__tests__/messaging-send-tool.test.ts +42 -0
  34. package/src/__tests__/oauth-commands-routes.test.ts +47 -0
  35. package/src/__tests__/subagent-manager-notify.test.ts +25 -4
  36. package/src/activation/progress-store.test.ts +1564 -0
  37. package/src/activation/progress-store.ts +1296 -0
  38. package/src/activation/turn-hooks.test.ts +246 -0
  39. package/src/activation/turn-hooks.ts +126 -0
  40. package/src/api/events/subagent-status-changed.ts +7 -5
  41. package/src/api/responses/activation.ts +136 -0
  42. package/src/apps/app-store.ts +8 -0
  43. package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
  44. package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
  45. package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
  46. package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
  47. package/src/cli/commands/channels/index.help.ts +70 -18
  48. package/src/cli/commands/channels/index.ts +17 -6
  49. package/src/cli/commands/channels/request.ts +66 -0
  50. package/src/cli/commands/oauth/request.test.ts +2 -0
  51. package/src/cli/commands/oauth/request.ts +227 -186
  52. package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  54. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
  55. package/src/config/feature-flag-registry.json +35 -5
  56. package/src/daemon/assistant-attachments.ts +11 -0
  57. package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
  58. package/src/daemon/conversation-agent-loop.ts +71 -3
  59. package/src/daemon/conversation-attachments.ts +42 -1
  60. package/src/daemon/conversation-event-sink.ts +25 -0
  61. package/src/daemon/conversation-process.ts +69 -21
  62. package/src/daemon/conversation-store.ts +4 -3
  63. package/src/daemon/conversation-surfaces.ts +19 -4
  64. package/src/daemon/message-types/sync.ts +2 -0
  65. package/src/ipc/assistant-server.ts +2 -0
  66. package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
  67. package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
  68. package/src/notifications/AGENTS.md +1 -1
  69. package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
  70. package/src/notifications/conversation-pairing.ts +47 -5
  71. package/src/notifications/delivered-post-record.ts +3 -0
  72. package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
  73. package/src/persistence/conversation-crud.ts +39 -0
  74. package/src/persistence/delivery-crud.ts +13 -0
  75. package/src/plugins/AGENTS.md +1 -0
  76. package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
  77. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
  78. package/src/runtime/routes/activation-routes.test.ts +415 -0
  79. package/src/runtime/routes/activation-routes.ts +172 -0
  80. package/src/runtime/routes/credential-routes.ts +46 -3
  81. package/src/runtime/routes/index.ts +2 -0
  82. package/src/runtime/routes/oauth-commands-routes.ts +21 -8
  83. package/src/runtime/routes/platform-managed-credentials.ts +48 -0
  84. package/src/runtime/routes/secret-routes.ts +3 -12
  85. package/src/runtime/routes/user-route-resolution.ts +21 -0
  86. package/src/runtime/routes/user-routes.ts +112 -9
  87. package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
  88. package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
  89. package/src/runtime/sync/resource-sync-events.ts +23 -0
  90. package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
  91. package/src/runtime/sync/worker-daemon-notify.ts +39 -1
  92. package/src/tools/apps/executors.ts +8 -8
@@ -450,6 +450,17 @@ async function waitForCondition(
450
450
  }
451
451
  }
452
452
 
453
+ /** Count the history reloads a conversation performs from this point on. */
454
+ function countHistoryReloads(conversation: Conversation): () => number {
455
+ const originalLoad = conversation.loadFromDb.bind(conversation);
456
+ let reloads = 0;
457
+ conversation.loadFromDb = async () => {
458
+ reloads++;
459
+ return originalLoad();
460
+ };
461
+ return () => reloads;
462
+ }
463
+
453
464
  /**
454
465
  * Resolve the Nth pending AgentLoop.run() call. Fires the minimal events
455
466
  * that `runAgentLoop` expects (usage + message_complete) so the conversation
@@ -715,6 +726,292 @@ describe("Conversation message queue", () => {
715
726
  await new Promise((r) => setTimeout(r, 10));
716
727
  });
717
728
 
729
+ test("a drained turn commits its sender as the resting actor and re-scopes history", async () => {
730
+ // A contact's channel turn leaves the resting slot, and the resident
731
+ // history scoped from it, naming the contact. The guardian's message
732
+ // queued behind that turn must run as the guardian everywhere the slot is
733
+ // read, not only in the per-turn snapshot: the `<turn_context>` actor
734
+ // section is derived at turn start and history is scoped from the slot,
735
+ // so a drain that stamped only the snapshot rendered the guardian's own
736
+ // web turn as a trusted contact's and hid the guardian's rows from it.
737
+ const conversation = makeConversation();
738
+ await conversation.loadFromDb();
739
+
740
+ conversation.setTrustContext({
741
+ trustClass: "trusted_contact",
742
+ sourceChannel: "slack",
743
+ requesterExternalUserId: "U-contact",
744
+ });
745
+
746
+ const p1 = conversation.processMessage({
747
+ content: "msg-1",
748
+ attachments: [],
749
+ onEvent: () => {},
750
+ requestId: "req-1",
751
+ });
752
+ await waitForPendingRun(1);
753
+
754
+ const guardian = {
755
+ trustClass: "guardian" as const,
756
+ sourceChannel: "vellum" as const,
757
+ requesterExternalUserId: "guardian-principal",
758
+ };
759
+ const reloads = countHistoryReloads(conversation);
760
+ conversation.enqueueMessage({
761
+ content: "msg-2",
762
+ requestId: "req-2",
763
+ trustContext: guardian,
764
+ });
765
+
766
+ await resolveRun(0);
767
+ await p1;
768
+ await waitForPendingRun(2);
769
+
770
+ // Read while run 2 is in flight: the slot, the snapshot, and the frozen
771
+ // actor section all describe the guardian, and the history was reloaded
772
+ // for that scope exactly once.
773
+ expect(conversation.getTrustContext()).toBe(guardian);
774
+ expect(conversation.currentTurnTrustContext).toBe(guardian);
775
+ expect(conversation.currentTurnInboundActorContext).toBeNull();
776
+ expect(reloads()).toBe(1);
777
+
778
+ await resolveRun(1);
779
+ await new Promise((r) => setTimeout(r, 10));
780
+ });
781
+
782
+ test("a batched drain commits the head's sender as the resting actor", async () => {
783
+ // Same commitment on the batched path, which coalesces only messages that
784
+ // share a trust identity, so the head's sender is the batch's actor.
785
+ const conversation = makeConversation();
786
+ await conversation.loadFromDb();
787
+
788
+ conversation.setTrustContext({
789
+ trustClass: "trusted_contact",
790
+ sourceChannel: "slack",
791
+ requesterExternalUserId: "U-contact",
792
+ });
793
+
794
+ const p1 = conversation.processMessage({
795
+ content: "msg-1",
796
+ attachments: [],
797
+ onEvent: () => {},
798
+ requestId: "req-1",
799
+ });
800
+ await waitForPendingRun(1);
801
+
802
+ const guardian = {
803
+ trustClass: "guardian" as const,
804
+ sourceChannel: "vellum" as const,
805
+ requesterExternalUserId: "guardian-principal",
806
+ };
807
+ const reloads = countHistoryReloads(conversation);
808
+ conversation.enqueueMessage({
809
+ content: "msg-2",
810
+ requestId: "req-2",
811
+ trustContext: guardian,
812
+ });
813
+ conversation.enqueueMessage({
814
+ content: "msg-3",
815
+ requestId: "req-3",
816
+ trustContext: guardian,
817
+ });
818
+
819
+ await resolveRun(0);
820
+ await p1;
821
+ await waitForPendingRun(2);
822
+
823
+ // One batched run for both siblings, committed as the guardian.
824
+ expect(pendingRuns.length).toBe(2);
825
+ expect(conversation.getTrustContext()).toBe(guardian);
826
+ expect(conversation.currentTurnTrustContext).toBe(guardian);
827
+ expect(conversation.currentTurnInboundActorContext).toBeNull();
828
+ expect(reloads()).toBe(1);
829
+
830
+ await resolveRun(1);
831
+ await new Promise((r) => setTimeout(r, 10));
832
+ });
833
+
834
+ test("a drained turn keeps its sender when the slot moves during the history reload", async () => {
835
+ // The commit captures the sender before the reload awaits. A writer that
836
+ // moves the slot inside that await (a wake's stamp, a pointer elevation)
837
+ // must not become the turn's actor: the history was reloaded for the
838
+ // sender, and running someone else's trust over it is the escalation.
839
+ const conversation = makeConversation();
840
+ await conversation.loadFromDb();
841
+
842
+ conversation.setTrustContext({
843
+ trustClass: "trusted_contact",
844
+ sourceChannel: "slack",
845
+ requesterExternalUserId: "U-contact",
846
+ });
847
+
848
+ const p1 = conversation.processMessage({
849
+ content: "msg-1",
850
+ attachments: [],
851
+ onEvent: () => {},
852
+ requestId: "req-1",
853
+ });
854
+ await waitForPendingRun(1);
855
+
856
+ const guardian = {
857
+ trustClass: "guardian" as const,
858
+ sourceChannel: "vellum" as const,
859
+ requesterExternalUserId: "guardian-principal",
860
+ };
861
+ const intruder = {
862
+ trustClass: "unknown" as const,
863
+ sourceChannel: "telegram" as const,
864
+ requesterExternalUserId: "T-stranger",
865
+ };
866
+ conversation.enqueueMessage({
867
+ content: "msg-2",
868
+ requestId: "req-2",
869
+ trustContext: guardian,
870
+ });
871
+
872
+ // The drain's reload is the await the writer lands inside.
873
+ const originalLoad = conversation.loadFromDb.bind(conversation);
874
+ let movedDuringReload = false;
875
+ conversation.loadFromDb = async () => {
876
+ const result = await originalLoad();
877
+ conversation.setTrustContext(intruder);
878
+ movedDuringReload = true;
879
+ return result;
880
+ };
881
+
882
+ await resolveRun(0);
883
+ await p1;
884
+ await waitForPendingRun(2);
885
+
886
+ expect(movedDuringReload).toBe(true);
887
+ expect(conversation.getTrustContext()).toBe(intruder);
888
+ expect(conversation.currentTurnTrustContext).toBe(guardian);
889
+ expect(conversation.currentTurnInboundActorContext).toBeNull();
890
+
891
+ await resolveRun(1);
892
+ await new Promise((r) => setTimeout(r, 10));
893
+ });
894
+
895
+ test("a drain whose history reload fails puts the resting actor back", async () => {
896
+ // A reload that fails starts no turn: the message is requeued for the
897
+ // next drain, so the slot must not keep naming a sender whose turn never
898
+ // began, or conversation-level readers report an owner that is not there.
899
+ const conversation = makeConversation();
900
+ await conversation.loadFromDb();
901
+
902
+ const contact = {
903
+ trustClass: "trusted_contact" as const,
904
+ sourceChannel: "slack" as const,
905
+ requesterExternalUserId: "U-contact",
906
+ };
907
+ conversation.setTrustContext(contact);
908
+
909
+ const p1 = conversation.processMessage({
910
+ content: "msg-1",
911
+ attachments: [],
912
+ onEvent: () => {},
913
+ requestId: "req-1",
914
+ });
915
+ await waitForPendingRun(1);
916
+
917
+ const guardian = {
918
+ trustClass: "guardian" as const,
919
+ sourceChannel: "vellum" as const,
920
+ requesterExternalUserId: "guardian-principal",
921
+ };
922
+ conversation.enqueueMessage({
923
+ content: "msg-2",
924
+ requestId: "req-2",
925
+ trustContext: guardian,
926
+ });
927
+
928
+ let reloadAttempts = 0;
929
+ conversation.loadFromDb = async () => {
930
+ reloadAttempts++;
931
+ throw new Error("history store exploded");
932
+ };
933
+
934
+ // Finish the first turn; the drain (and its one retry) fail on the reload.
935
+ await resolveRun(0);
936
+ await p1;
937
+ await waitForCondition(() => reloadAttempts >= 2);
938
+ await new Promise((r) => setTimeout(r, 20));
939
+
940
+ expect(pendingRuns.length).toBe(1);
941
+ expect(conversation.getQueueDepth()).toBe(1);
942
+ expect(conversation.getTrustContext()).toBe(contact);
943
+ });
944
+
945
+ test("the turn-context actor section follows the turn's actor when the slot moves before the loop opens", async () => {
946
+ // The actor section is frozen at turn start from the turn's own actor,
947
+ // not from the resting slot. The drain stamps the slot and then awaits
948
+ // (persist) before the loop opens; an out-of-band writer landing in that
949
+ // window (pointer elevation restore, a wake's restore) moves the slot
950
+ // without owning the turn, and a read of the slot there would describe
951
+ // that writer's actor to the model.
952
+ const conversation = makeConversation();
953
+ await conversation.loadFromDb();
954
+
955
+ const contact = {
956
+ trustClass: "trusted_contact" as const,
957
+ sourceChannel: "slack" as const,
958
+ requesterExternalUserId: "U-contact",
959
+ };
960
+ conversation.setTrustContext(contact);
961
+
962
+ const p1 = conversation.processMessage({
963
+ content: "msg-1",
964
+ attachments: [],
965
+ onEvent: () => {},
966
+ requestId: "req-1",
967
+ });
968
+ await waitForPendingRun(1);
969
+
970
+ const guardian = {
971
+ trustClass: "guardian" as const,
972
+ sourceChannel: "vellum" as const,
973
+ requesterExternalUserId: "guardian-principal",
974
+ };
975
+ conversation.enqueueMessage({
976
+ content: "msg-2",
977
+ requestId: "req-2",
978
+ trustContext: guardian,
979
+ });
980
+
981
+ // Land the out-of-band slot write inside the drain's window, after its
982
+ // stamp and before the loop opens: the drain awaits persistUserMessage
983
+ // there. The resting slot alone is moved; the per-turn snapshot is the
984
+ // drain's to carry into the run.
985
+ const originalPersist = conversation.persistUserMessage.bind(conversation);
986
+ let slotMoved = false;
987
+ (
988
+ conversation as unknown as {
989
+ persistUserMessage: typeof conversation.persistUserMessage;
990
+ }
991
+ ).persistUserMessage = async (opts) => {
992
+ const result = await originalPersist(opts);
993
+ conversation.setTrustContext(contact);
994
+ slotMoved = true;
995
+ return result;
996
+ };
997
+
998
+ await resolveRun(0);
999
+ await p1;
1000
+ await waitForPendingRun(2);
1001
+
1002
+ // Guard the test itself: the injection must have run, and the slot must
1003
+ // disagree with the turn, or the assertion below passes for the wrong
1004
+ // reason.
1005
+ expect(slotMoved).toBe(true);
1006
+ expect(conversation.getTrustContext()).toBe(contact);
1007
+ expect(conversation.currentTurnTrustContext).toBe(guardian);
1008
+ // The guardian's turn renders no actor section, whatever the slot says.
1009
+ expect(conversation.currentTurnInboundActorContext).toBeNull();
1010
+
1011
+ await resolveRun(1);
1012
+ await new Promise((r) => setTimeout(r, 10));
1013
+ });
1014
+
718
1015
  test("a processMessage turn keeps its turn-start trust when the slot moves before the loop opens", async () => {
719
1016
  // processMessage is the third turn entry point, and the one a web turn
720
1017
  // takes on an idle conversation (the route only enqueues while
@@ -1,6 +1,6 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import { BadRequestError } from "../runtime/routes/errors.js";
3
+ import { BadRequestError, ForbiddenError } from "../runtime/routes/errors.js";
4
4
  import type { CredentialMetadata } from "../tools/credentials/metadata-store.js";
5
5
 
6
6
  // ---------------------------------------------------------------------------
@@ -169,6 +169,9 @@ const setRoute = ROUTES.find((r) => r.operationId === "credentials_set");
169
169
  const listRoute = ROUTES.find((r) => r.operationId === "credentials_list");
170
170
  const deleteRoute = ROUTES.find((r) => r.operationId === "credentials_delete");
171
171
  const revealRoute = ROUTES.find((r) => r.operationId === "credentials_reveal");
172
+ const inspectRoute = ROUTES.find(
173
+ (r) => r.operationId === "credentials_inspect",
174
+ );
172
175
 
173
176
  type SetResponse = { credentialId: string; service: string; field: string };
174
177
  type ListResponse = {
@@ -591,16 +594,19 @@ describe("credentials routes", () => {
591
594
 
592
595
  test("non-secret vellum platform fields are stored but never scrubbed", async () => {
593
596
  /**
594
- * Platform identity ids and the base URL are benign UUIDs/URLs that
595
- * legitimately appear in transcripts; scrubbing them would redact
596
- * ordinary conversation content. Shares the exemption with the
597
- * /v1/secrets credential branch via isNonSecretPlatformField.
597
+ * Platform identity ids are benign UUIDs that legitimately appear in
598
+ * transcripts; scrubbing them would redact ordinary conversation
599
+ * content. Shares the exemption with the /v1/secrets credential branch
600
+ * via isNonSecretPlatformField.
601
+ *
602
+ * `platform_base_url` carries the same exemption but is not settable
603
+ * here: it decides which host receives the platform API key, so the
604
+ * credentials API refuses it (see the platform-managed suite).
598
605
  */
599
606
  for (const field of [
600
607
  "platform_assistant_id",
601
608
  "platform_organization_id",
602
609
  "platform_user_id",
603
- "platform_base_url",
604
610
  ]) {
605
611
  const result = (await setRoute!.handler({
606
612
  body: {
@@ -795,6 +801,179 @@ describe("credentials routes", () => {
795
801
  });
796
802
  });
797
803
 
804
+ describe("platform-managed credentials", () => {
805
+ const PLATFORM_KEY = "vk-platform-provisioned-key";
806
+ const PLATFORM_BASE_URL = "https://platform.example";
807
+
808
+ /**
809
+ * The platform provisions the pod's own credentials by POSTing them to
810
+ * `/v1/secrets` as `credential` entries, which land in the same secure
811
+ * store and metadata table as a user's own credentials.
812
+ */
813
+ function seedPlatformCredential(field: string, value: string): string {
814
+ const key = `vellum:${field}`;
815
+ secureStore.set(key, value);
816
+ const now = Date.now();
817
+ metadataStore.set(key, {
818
+ credentialId: `cred-${++credentialIdCounter}`,
819
+ service: "vellum",
820
+ field,
821
+ allowedTools: [],
822
+ allowedDomains: [],
823
+ createdAt: now,
824
+ updatedAt: now,
825
+ });
826
+ return metadataStore.get(key)!.credentialId;
827
+ }
828
+
829
+ test("the listing omits platform credentials and keeps the user's own", async () => {
830
+ // GIVEN the platform's provisioned credentials alongside a user's
831
+ seedPlatformCredential("assistant_api_key", PLATFORM_KEY);
832
+ seedPlatformCredential("platform_base_url", PLATFORM_BASE_URL);
833
+ await setRoute!.handler({
834
+ body: { service: "vercel", field: "api_token", value: SECRET_VALUE },
835
+ });
836
+
837
+ // WHEN the credentials are listed
838
+ const result = (await listRoute!.handler({ body: {} })) as ListResponse;
839
+
840
+ // THEN only the user's credential is a row, and no platform value leaks
841
+ expect(result.credentials.map((c) => `${c.service}:${c.field}`)).toEqual([
842
+ "vercel:api_token",
843
+ ]);
844
+ expect(JSON.stringify(result)).not.toContain(PLATFORM_KEY);
845
+ });
846
+
847
+ test("reveal refuses the platform API key for every principal", async () => {
848
+ /**
849
+ * Both farming paths land on this handler: the assistant's own tool
850
+ * shell arrives as `local`, a Settings click as `user`.
851
+ */
852
+ seedPlatformCredential("assistant_api_key", PLATFORM_KEY);
853
+
854
+ for (const principal of ["local", "user", "svc_gateway"]) {
855
+ await expect(
856
+ revealRoute!.handler({
857
+ body: { service: "vellum", field: "assistant_api_key" },
858
+ headers: { "x-vellum-principal-type": principal },
859
+ }),
860
+ ).rejects.toThrow(ForbiddenError);
861
+ }
862
+ });
863
+
864
+ test("reveal refuses a platform credential looked up by UUID", async () => {
865
+ const credentialId = seedPlatformCredential(
866
+ "assistant_api_key",
867
+ PLATFORM_KEY,
868
+ );
869
+
870
+ await expect(
871
+ revealRoute!.handler({
872
+ body: { id: credentialId },
873
+ headers: { "x-vellum-principal-type": "local" },
874
+ }),
875
+ ).rejects.toThrow(/owned by the Vellum platform/);
876
+ });
877
+
878
+ test("inspect refuses platform credentials", async () => {
879
+ seedPlatformCredential("assistant_api_key", PLATFORM_KEY);
880
+ seedPlatformCredential("platform_base_url", PLATFORM_BASE_URL);
881
+
882
+ for (const field of ["assistant_api_key", "platform_base_url"]) {
883
+ await expect(
884
+ inspectRoute!.handler({
885
+ body: { service: "vellum", field },
886
+ }),
887
+ ).rejects.toThrow(ForbiddenError);
888
+ }
889
+ });
890
+
891
+ test("set refuses platform credentials", async () => {
892
+ /**
893
+ * A write is a read by another name: repointing `platform_base_url`
894
+ * sends the next platform call, bearing the API key, to a host the
895
+ * writer chose. The platform's own provisioning does not come through
896
+ * this route; it writes over `POST /v1/secrets`.
897
+ */
898
+ for (const field of ["assistant_api_key", "platform_base_url"]) {
899
+ await expect(
900
+ setRoute!.handler({
901
+ body: { service: "vellum", field, value: "https://attacker.test" },
902
+ }),
903
+ ).rejects.toThrow(ForbiddenError);
904
+ }
905
+
906
+ expect(secureStore.has("vellum:assistant_api_key")).toBe(false);
907
+ expect(secureStore.has("vellum:platform_base_url")).toBe(false);
908
+ });
909
+
910
+ test("set does not overwrite an already-provisioned platform credential", async () => {
911
+ seedPlatformCredential("platform_base_url", PLATFORM_BASE_URL);
912
+
913
+ await expect(
914
+ setRoute!.handler({
915
+ body: {
916
+ service: "vellum",
917
+ field: "platform_base_url",
918
+ value: "https://attacker.test",
919
+ },
920
+ }),
921
+ ).rejects.toThrow(/owned by the Vellum platform/);
922
+
923
+ expect(secureStore.get("vellum:platform_base_url")).toBe(
924
+ PLATFORM_BASE_URL,
925
+ );
926
+ });
927
+
928
+ test("delete refuses platform credentials", async () => {
929
+ seedPlatformCredential("assistant_api_key", PLATFORM_KEY);
930
+ seedPlatformCredential("platform_base_url", PLATFORM_BASE_URL);
931
+
932
+ for (const field of ["assistant_api_key", "platform_base_url"]) {
933
+ await expect(
934
+ deleteRoute!.handler({ body: { service: "vellum", field } }),
935
+ ).rejects.toThrow(ForbiddenError);
936
+ }
937
+
938
+ expect(secureStore.get("vellum:assistant_api_key")).toBe(PLATFORM_KEY);
939
+ expect(secureStore.get("vellum:platform_base_url")).toBe(
940
+ PLATFORM_BASE_URL,
941
+ );
942
+ });
943
+
944
+ test("a user credential is still writable and deletable", async () => {
945
+ const stored = (await setRoute!.handler({
946
+ body: { service: "vercel", field: "api_token", value: SECRET_VALUE },
947
+ })) as SetResponse;
948
+ expect(stored.service).toBe("vercel");
949
+ expect(secureStore.get("vercel:api_token")).toBe(SECRET_VALUE);
950
+
951
+ const deleted = (await deleteRoute!.handler({
952
+ body: { service: "vercel", field: "api_token" },
953
+ })) as DeleteResponse;
954
+ expect(deleted.field).toBe("api_token");
955
+ expect(secureStore.has("vercel:api_token")).toBe(false);
956
+ });
957
+
958
+ test("a user credential still inspects and reveals", async () => {
959
+ seedPlatformCredential("assistant_api_key", PLATFORM_KEY);
960
+ await setRoute!.handler({
961
+ body: { service: "vercel", field: "api_token", value: SECRET_VALUE },
962
+ });
963
+
964
+ const inspected = (await inspectRoute!.handler({
965
+ body: { service: "vercel", field: "api_token" },
966
+ })) as { scrubbedValue: string };
967
+ expect(inspected.scrubbedValue).toBe("supe****");
968
+
969
+ const revealed = (await revealRoute!.handler({
970
+ body: { service: "vercel", field: "api_token" },
971
+ headers: { "x-vellum-principal-type": "user" },
972
+ })) as { value: string };
973
+ expect(revealed.value).toBe(SECRET_VALUE);
974
+ });
975
+ });
976
+
798
977
  describe("credentials_delete", () => {
799
978
  test("removes the secret and metadata and echoes the identifiers", async () => {
800
979
  /**
@@ -79,6 +79,8 @@ function makeFakeConversation(
79
79
  getTurnInterfaceContext: () => null,
80
80
  setTurnInterfaceContext: () => {},
81
81
  setTransportHints: () => {},
82
+ setTrustContext: () => {},
83
+ ensureActorScopedHistory: async () => {},
82
84
  emitActivityState: () => {
83
85
  if (activityFailures > 0) {
84
86
  activityFailures -= 1;
@@ -78,6 +78,12 @@ function makeFakeConversation(options: {
78
78
  setTransportHints: () => {
79
79
  mutationCalls.push("setTransportHints");
80
80
  },
81
+ setTrustContext: () => {
82
+ mutationCalls.push("setTrustContext");
83
+ },
84
+ ensureActorScopedHistory: async () => {
85
+ mutationCalls.push("ensureActorScopedHistory");
86
+ },
81
87
  emitActivityState: () => {
82
88
  mutationCalls.push("emitActivityState");
83
89
  },
@@ -304,6 +304,48 @@ describe("messaging-send tool", () => {
304
304
  }
305
305
  });
306
306
 
307
+ test("records a threaded channel send in the thread's home, with the delivered thread on the row", async () => {
308
+ provider = slackProvider;
309
+ getConversationMock.mockImplementation(() => ({
310
+ id: "home-topic",
311
+ createdAt: 1700000000000,
312
+ }));
313
+ resolveProactiveHomeConversationMock.mockImplementation(async () => ({
314
+ conversationId: "home-topic",
315
+ createdNewConversation: false,
316
+ }));
317
+
318
+ const result = await run(
319
+ {
320
+ platform: "slack",
321
+ conversation_id: "C123",
322
+ thread_id: "1700000000.000009",
323
+ text: "hello from A",
324
+ },
325
+ {
326
+ workingDir: "/tmp",
327
+ conversationId: "conv-A",
328
+ assistantId: "ast-1",
329
+ trustClass: "guardian" as const,
330
+ },
331
+ );
332
+
333
+ expect(result.isError).toBe(false);
334
+ expect(
335
+ resolveProactiveHomeConversationMock.mock.calls[0]![0],
336
+ ).toMatchObject({
337
+ sourceChannel: "slack",
338
+ externalChatId: "C123",
339
+ threadId: "1700000000.000009",
340
+ });
341
+ expect(recordDeliveredChannelPostMock.mock.calls[0]![0]).toMatchObject({
342
+ conversationId: "home-topic",
343
+ externalChatId: "C123",
344
+ threadId: "1700000000.000009",
345
+ providerMessageId: "1700000000.000100",
346
+ });
347
+ });
348
+
307
349
  test("records a channel send in the chat's home conversation once the provider has it", async () => {
308
350
  provider = telegramProvider;
309
351
  getConversationMock.mockImplementation(() => ({
@@ -656,6 +656,53 @@ describe("POST oauth/request", () => {
656
656
  expect(result.body).toEqual({ hello: "world" });
657
657
  });
658
658
 
659
+ // The recovery hint on a 401/403 follows the credential's kind. A channel
660
+ // bot's token is stored by the channel's setup, so the OAuth status and
661
+ // connect commands cannot repair it; the hint must name the channel's own
662
+ // diagnostics, through whichever door the request came.
663
+ test("401 as a channel bot points at the channel's diagnostics, never the OAuth commands", async () => {
664
+ mockProviders.slack_channel = {
665
+ ...baseProvider,
666
+ provider: "slack_channel",
667
+ authorizeUrl: "urn:manual-token",
668
+ managedServiceConfigKey: null,
669
+ baseUrl: "https://slack.com/api",
670
+ };
671
+ mockResolveResponse = {
672
+ status: 401,
673
+ headers: { "content-type": "application/json" },
674
+ body: { ok: false, error: "invalid_auth" },
675
+ };
676
+ const result = (await getRoute("POST", "oauth/request").handler(
677
+ makeArgs({
678
+ body: { provider: "slack_channel", url: "/conversations.history" },
679
+ }),
680
+ )) as { ok: boolean; status: number; hint?: string };
681
+ expect(result.ok).toBe(false);
682
+ expect(result.status).toBe(401);
683
+ expect(result.hint).toContain("slack bot credential");
684
+ expect(result.hint).toContain("assistant channels get slack");
685
+ expect(result.hint).not.toContain("oauth status");
686
+ expect(result.hint).not.toContain("oauth connect");
687
+ });
688
+
689
+ test("403 as a person's OAuth integration keeps the OAuth recovery steps", async () => {
690
+ mockResolveResponse = {
691
+ status: 403,
692
+ headers: { "content-type": "application/json" },
693
+ body: { error: "forbidden" },
694
+ };
695
+ const result = (await getRoute("POST", "oauth/request").handler(
696
+ makeArgs({
697
+ body: { provider: "google", url: "https://api.google.com/v1/me" },
698
+ }),
699
+ )) as { ok: boolean; status: number; hint?: string };
700
+ expect(result.status).toBe(403);
701
+ expect(result.hint).toContain("assistant oauth status google");
702
+ expect(result.hint).toContain("oauth connect");
703
+ expect(result.hint).not.toContain("channels get");
704
+ });
705
+
659
706
  test("passes a pre-parsed string body through to the connection unchanged", async () => {
660
707
  const multipart =
661
708
  "--boundary\r\nContent-Type: application/json\r\n\r\n{}\r\n--boundary--\r\n";