@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.
- package/Dockerfile +5 -0
- package/docs/architecture/turn-actor.md +9 -0
- package/knip.json +1 -0
- package/node_modules/@vellumai/app-icons/package.json +18 -0
- package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
- package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
- package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/openapi.yaml +628 -0
- package/package.json +3 -1
- package/scripts/smoke-container-workspace-dependencies.ts +19 -0
- package/src/__tests__/app-builder-icon-names.test.ts +29 -0
- package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +2 -0
- package/src/__tests__/conversation-attachments.test.ts +142 -0
- package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
- package/src/__tests__/conversation-event-sink.test.ts +50 -0
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
- package/src/__tests__/conversation-queue.test.ts +297 -0
- package/src/__tests__/credential-routes.test.ts +185 -6
- package/src/__tests__/drain-kick-guard.test.ts +2 -0
- package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
- package/src/__tests__/messaging-send-tool.test.ts +42 -0
- package/src/__tests__/oauth-commands-routes.test.ts +47 -0
- package/src/__tests__/subagent-manager-notify.test.ts +25 -4
- package/src/activation/progress-store.test.ts +1564 -0
- package/src/activation/progress-store.ts +1296 -0
- package/src/activation/turn-hooks.test.ts +246 -0
- package/src/activation/turn-hooks.ts +126 -0
- package/src/api/events/subagent-status-changed.ts +7 -5
- package/src/api/responses/activation.ts +136 -0
- package/src/apps/app-store.ts +8 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
- package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
- package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
- package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
- package/src/cli/commands/channels/index.help.ts +70 -18
- package/src/cli/commands/channels/index.ts +17 -6
- package/src/cli/commands/channels/request.ts +66 -0
- package/src/cli/commands/oauth/request.test.ts +2 -0
- package/src/cli/commands/oauth/request.ts +227 -186
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
- package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
- package/src/config/feature-flag-registry.json +35 -5
- package/src/daemon/assistant-attachments.ts +11 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
- package/src/daemon/conversation-agent-loop.ts +71 -3
- package/src/daemon/conversation-attachments.ts +42 -1
- package/src/daemon/conversation-event-sink.ts +25 -0
- package/src/daemon/conversation-process.ts +69 -21
- package/src/daemon/conversation-store.ts +4 -3
- package/src/daemon/conversation-surfaces.ts +19 -4
- package/src/daemon/message-types/sync.ts +2 -0
- package/src/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
- package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
- package/src/notifications/conversation-pairing.ts +47 -5
- package/src/notifications/delivered-post-record.ts +3 -0
- package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
- package/src/persistence/conversation-crud.ts +39 -0
- package/src/persistence/delivery-crud.ts +13 -0
- package/src/plugins/AGENTS.md +1 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
- package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
- package/src/runtime/routes/activation-routes.test.ts +415 -0
- package/src/runtime/routes/activation-routes.ts +172 -0
- package/src/runtime/routes/credential-routes.ts +46 -3
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/oauth-commands-routes.ts +21 -8
- package/src/runtime/routes/platform-managed-credentials.ts +48 -0
- package/src/runtime/routes/secret-routes.ts +3 -12
- package/src/runtime/routes/user-route-resolution.ts +21 -0
- package/src/runtime/routes/user-routes.ts +112 -9
- package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
- package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
- package/src/runtime/sync/resource-sync-events.ts +23 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
- package/src/runtime/sync/worker-daemon-notify.ts +39 -1
- 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
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
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";
|