@vellumai/assistant 0.4.45 → 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.
- package/ARCHITECTURE.md +6 -6
- package/docs/architecture/memory.md +1 -1
- package/docs/architecture/scheduling.md +2 -3
- package/docs/architecture/security.md +5 -5
- package/docs/trusted-contact-access.md +5 -6
- package/package.json +4 -1
- package/src/__tests__/avatar-e2e.test.ts +18 -219
- package/src/__tests__/avatar-generator.test.ts +5 -57
- package/src/__tests__/browser-fill-credential.test.ts +5 -2
- package/src/__tests__/bundled-skill-retrieval-guard.test.ts +2 -1
- package/src/__tests__/channel-readiness-routes.test.ts +20 -19
- package/src/__tests__/cli.test.ts +23 -0
- package/src/__tests__/credential-broker-browser-fill.test.ts +23 -22
- package/src/__tests__/credential-broker-server-use.test.ts +22 -21
- package/src/__tests__/credential-broker.test.ts +2 -1
- package/src/__tests__/credential-metadata-store.test.ts +240 -18
- package/src/__tests__/credential-resolve.test.ts +5 -4
- package/src/__tests__/credential-security-e2e.test.ts +8 -8
- package/src/__tests__/credential-security-invariants.test.ts +104 -7
- package/src/__tests__/credential-vault-unit.test.ts +22 -20
- package/src/__tests__/credential-vault.test.ts +284 -12
- package/src/__tests__/credentials-cli.test.ts +11 -6
- package/src/__tests__/gateway-only-enforcement.test.ts +4 -2
- package/src/__tests__/gemini-image-service.test.ts +75 -45
- package/src/__tests__/gemini-provider.test.ts +9 -6
- package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -33
- package/src/__tests__/guardian-action-copy-generator.test.ts +0 -20
- package/src/__tests__/guardian-action-followup-executor.test.ts +1 -28
- package/src/__tests__/guardian-action-followup-store.test.ts +1 -1
- package/src/__tests__/guardian-grant-minting.test.ts +35 -0
- package/src/__tests__/integration-status.test.ts +53 -21
- package/src/__tests__/managed-proxy-context.test.ts +5 -3
- package/src/__tests__/media-generate-image.test.ts +63 -2
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -3
- package/src/__tests__/messaging-send-tool.test.ts +4 -6
- package/src/__tests__/provider-fail-open-selection.test.ts +3 -1
- package/src/__tests__/provider-managed-proxy-integration.test.ts +70 -6
- package/src/__tests__/schedule-store.test.ts +1 -1
- package/src/__tests__/schema-transforms.test.ts +226 -0
- package/src/__tests__/script-proxy-injection-runtime.test.ts +23 -13
- package/src/__tests__/script-proxy-policy-runtime.test.ts +1 -1
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -1
- package/src/__tests__/secret-onetime-send.test.ts +5 -3
- package/src/__tests__/session-messaging-secret-redirect.test.ts +5 -4
- package/src/__tests__/skills-uninstall.test.ts +2 -2
- package/src/__tests__/skills.test.ts +0 -9
- package/src/__tests__/slack-channel-config.test.ts +9 -8
- package/src/__tests__/slack-share-routes.test.ts +11 -6
- package/src/__tests__/telegram-bot-username-resolution.test.ts +3 -0
- package/src/__tests__/twilio-config.test.ts +2 -1
- package/src/__tests__/twilio-provider.test.ts +4 -2
- package/src/__tests__/twilio-routes.test.ts +5 -4
- package/src/__tests__/verification-control-plane-policy.test.ts +1 -1
- package/src/approvals/AGENTS.md +1 -1
- package/src/calls/call-domain.ts +7 -4
- package/src/calls/twilio-config.ts +2 -1
- package/src/calls/twilio-provider.ts +2 -1
- package/src/calls/twilio-rest.ts +2 -2
- package/src/cli/commands/browser-relay.ts +40 -15
- package/src/cli/commands/credentials.ts +9 -8
- package/src/cli/commands/oauth.ts +1 -1
- package/src/cli.ts +3 -2
- package/src/config/bundled-skills/claude-code/TOOLS.json +0 -4
- package/src/config/bundled-skills/contacts/tools/google-contacts.ts +29 -32
- package/src/config/bundled-skills/gmail/SKILL.md +4 -4
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +54 -61
- package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +25 -28
- package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +14 -17
- package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +39 -44
- package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +61 -58
- package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +50 -49
- package/src/config/bundled-skills/gmail/tools/gmail-label.ts +11 -13
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +148 -146
- package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +4 -7
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +175 -173
- package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +4 -7
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +71 -76
- package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +32 -38
- package/src/config/bundled-skills/google-calendar/SKILL.md +2 -2
- package/src/config/bundled-skills/google-calendar/calendar-client.ts +70 -29
- package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +9 -10
- package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +5 -6
- package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +4 -5
- package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +14 -15
- package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +37 -37
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +4 -9
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +24 -3
- package/src/config/bundled-skills/messaging/SKILL.md +6 -6
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +62 -63
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +15 -16
- package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +4 -5
- package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +6 -7
- package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +4 -5
- package/src/config/bundled-skills/messaging/tools/messaging-read.ts +14 -15
- package/src/config/bundled-skills/messaging/tools/messaging-search.ts +4 -5
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +128 -128
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +33 -34
- package/src/config/bundled-skills/messaging/tools/shared.ts +11 -11
- package/src/config/bundled-skills/notifications/SKILL.md +1 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +5 -5
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/slack/tools/shared.ts +4 -10
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +4 -5
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +15 -16
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +4 -5
- package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +4 -5
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +4 -5
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +95 -92
- package/src/config/loader.ts +6 -0
- package/src/daemon/computer-use-session.ts +7 -1
- package/src/daemon/guardian-action-generators.ts +4 -5
- package/src/daemon/handlers/config-slack-channel.ts +37 -20
- package/src/daemon/handlers/config-telegram.ts +33 -20
- package/src/daemon/lifecycle.ts +9 -1
- package/src/daemon/message-types/integrations.ts +1 -0
- package/src/daemon/ride-shotgun-handler.ts +3 -1
- package/src/daemon/session-messaging.ts +3 -1
- package/src/daemon/session-tool-setup.ts +18 -2
- package/src/daemon/session.ts +1 -1
- package/src/email/providers/index.ts +2 -1
- package/src/instrument.ts +15 -1
- package/src/media/app-icon-generator.ts +30 -4
- package/src/media/avatar-router.ts +28 -62
- package/src/media/gemini-image-service.ts +28 -2
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/memory/guardian-action-store.ts +1 -1
- package/src/memory/schema/guardian.ts +1 -1
- package/src/messaging/provider.ts +16 -10
- package/src/messaging/providers/gmail/adapter.ts +40 -23
- package/src/messaging/providers/gmail/client.ts +203 -122
- package/src/messaging/providers/gmail/people-client.ts +26 -18
- package/src/messaging/providers/slack/adapter.ts +29 -19
- package/src/messaging/providers/slack/client.ts +265 -78
- package/src/messaging/providers/telegram-bot/adapter.ts +5 -4
- package/src/messaging/providers/whatsapp/adapter.ts +6 -3
- package/src/messaging/registry.ts +2 -1
- package/src/oauth/byo-connection.test.ts +436 -0
- package/src/oauth/byo-connection.ts +112 -0
- package/src/oauth/connect-orchestrator.ts +27 -0
- package/src/oauth/connection-resolver.ts +34 -0
- package/src/oauth/connection.ts +38 -0
- package/src/oauth/platform-connection.test.ts +163 -0
- package/src/oauth/platform-connection.ts +110 -0
- package/src/oauth/provider-base-urls.ts +21 -0
- package/src/oauth/provider-profiles.ts +1 -1
- package/src/oauth/token-persistence.ts +20 -20
- package/src/permissions/checker.ts +6 -1
- package/src/prompts/system-prompt.ts +52 -15
- package/src/prompts/templates/BOOTSTRAP.md +1 -1
- package/src/providers/gemini/client.ts +15 -6
- package/src/providers/managed-proxy/constants.ts +2 -2
- package/src/providers/managed-proxy/context.ts +5 -1
- package/src/providers/ratelimit.ts +17 -0
- package/src/providers/registry.ts +2 -2
- package/src/runtime/AGENTS.md +18 -1
- package/src/runtime/auth/route-policy.ts +1 -0
- package/src/runtime/channel-invite-transports/telegram.ts +2 -1
- package/src/runtime/channel-readiness-service.ts +168 -195
- package/src/runtime/channel-readiness-types.ts +4 -0
- package/src/runtime/guardian-action-conversation-turn.ts +1 -3
- package/src/runtime/guardian-action-followup-executor.ts +1 -2
- package/src/runtime/guardian-action-message-composer.ts +3 -23
- package/src/runtime/http-server.ts +9 -4
- package/src/runtime/http-types.ts +0 -1
- package/src/runtime/middleware/rate-limiter.ts +74 -20
- package/src/runtime/middleware/twilio-validation.ts +1 -3
- package/src/runtime/routes/channel-readiness-routes.ts +2 -0
- package/src/runtime/routes/diagnostics-routes.ts +11 -9
- package/src/runtime/routes/guardian-approval-interception.ts +20 -5
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +71 -25
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +12 -5
- package/src/runtime/routes/integrations/slack/share.ts +3 -2
- package/src/runtime/routes/integrations/twilio.ts +6 -5
- package/src/runtime/routes/secret-routes.ts +3 -2
- package/src/runtime/routes/settings-routes.ts +75 -17
- package/src/runtime/telegram-streaming-delivery.test.ts +132 -0
- package/src/runtime/telegram-streaming-delivery.ts +11 -1
- package/src/schedule/integration-status.ts +5 -4
- package/src/security/credential-key.ts +170 -0
- package/src/security/token-manager.ts +36 -7
- package/src/tools/apps/definitions.ts +0 -5
- package/src/tools/assets/materialize.ts +0 -5
- package/src/tools/assets/search.ts +0 -5
- package/src/tools/browser/headless-browser.ts +1 -67
- package/src/tools/claude-code/claude-code.ts +0 -5
- package/src/tools/computer-use/request-computer-control.ts +0 -5
- package/src/tools/credentials/broker.ts +6 -4
- package/src/tools/credentials/metadata-store.ts +72 -20
- package/src/tools/credentials/resolve.ts +2 -1
- package/src/tools/credentials/vault.ts +77 -16
- package/src/tools/filesystem/edit.ts +1 -6
- package/src/tools/filesystem/read.ts +0 -5
- package/src/tools/filesystem/write.ts +1 -6
- package/src/tools/host-filesystem/edit.ts +1 -6
- package/src/tools/host-filesystem/read.ts +1 -6
- package/src/tools/host-filesystem/write.ts +1 -6
- package/src/tools/mcp/mcp-tool-factory.ts +18 -1
- package/src/tools/memory/definitions.ts +0 -5
- package/src/tools/network/web-fetch.ts +0 -5
- package/src/tools/network/web-search.ts +0 -5
- package/src/tools/schema-transforms.ts +99 -0
- package/src/tools/skills/load.ts +0 -5
- package/src/tools/swarm/delegate.ts +0 -5
- package/src/tools/system/avatar-generator.ts +3 -44
- package/src/tools/ui-surface/definitions.ts +0 -15
- package/src/tools/watch/screen-watch.ts +0 -5
- package/src/version.ts +10 -0
- package/src/watcher/providers/github.ts +51 -52
- package/src/watcher/providers/gmail.ts +88 -80
- package/src/watcher/providers/google-calendar.ts +93 -86
- package/src/watcher/providers/linear.ts +87 -93
- package/src/__tests__/avatar-router.test.ts +0 -149
- package/src/__tests__/managed-avatar-client.test.ts +0 -337
- package/src/config/bundled-skills/doordash/SKILL.md +0 -170
- package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +0 -205
- package/src/config/bundled-skills/doordash/__tests__/doordash-session.test.ts +0 -74
- package/src/config/bundled-skills/doordash/doordash-cli.ts +0 -1081
- package/src/config/bundled-skills/doordash/doordash-entry.ts +0 -22
- package/src/config/bundled-skills/doordash/lib/cart-queries.ts +0 -787
- package/src/config/bundled-skills/doordash/lib/client.ts +0 -1069
- package/src/config/bundled-skills/doordash/lib/order-queries.ts +0 -85
- package/src/config/bundled-skills/doordash/lib/queries.ts +0 -28
- package/src/config/bundled-skills/doordash/lib/query-extractor.ts +0 -94
- package/src/config/bundled-skills/doordash/lib/search-queries.ts +0 -203
- package/src/config/bundled-skills/doordash/lib/session.ts +0 -96
- package/src/config/bundled-skills/doordash/lib/shared/errors.ts +0 -61
- package/src/config/bundled-skills/doordash/lib/shared/network-recorder.ts +0 -380
- package/src/config/bundled-skills/doordash/lib/shared/platform.ts +0 -55
- package/src/config/bundled-skills/doordash/lib/shared/recording-store.ts +0 -43
- package/src/config/bundled-skills/doordash/lib/shared/recording-types.ts +0 -49
- package/src/config/bundled-skills/doordash/lib/shared/truncate.ts +0 -6
- package/src/config/bundled-skills/doordash/lib/store-queries.ts +0 -246
- package/src/config/bundled-skills/doordash/lib/types.ts +0 -367
- package/src/media/avatar-types.ts +0 -53
- package/src/media/managed-avatar-client.ts +0 -225
|
@@ -48,6 +48,7 @@ mock.module("../tools/registry.js", () => ({
|
|
|
48
48
|
// Imports under test
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
50
|
|
|
51
|
+
import { credentialKey } from "../security/credential-key.js";
|
|
51
52
|
import { getSecureKey, setSecureKey } from "../security/secure-keys.js";
|
|
52
53
|
import { CredentialBroker } from "../tools/credentials/broker.js";
|
|
53
54
|
import {
|
|
@@ -108,7 +109,7 @@ describe("CredentialBroker transient credentials", () => {
|
|
|
108
109
|
const result = broker.consume(auth.token.tokenId);
|
|
109
110
|
expect(result.success).toBe(true);
|
|
110
111
|
expect(result.value).toBe("one-time-secret");
|
|
111
|
-
expect(result.storageKey).toBe("
|
|
112
|
+
expect(result.storageKey).toBe(credentialKey("svc", "key"));
|
|
112
113
|
|
|
113
114
|
// Second authorize + consume should NOT have the transient value
|
|
114
115
|
const auth2 = broker.authorize({
|
|
@@ -148,7 +149,7 @@ describe("CredentialBroker transient credentials", () => {
|
|
|
148
149
|
upsertCredentialMetadata("github", "token", {
|
|
149
150
|
allowedTools: ["browser_fill_credential"],
|
|
150
151
|
});
|
|
151
|
-
setSecureKey("
|
|
152
|
+
setSecureKey(credentialKey("github", "token"), "stored-value");
|
|
152
153
|
broker.injectTransient("github", "token", "transient-value");
|
|
153
154
|
|
|
154
155
|
// First fill uses transient
|
|
@@ -411,7 +412,7 @@ describe("credential_store tool — prompt action", () => {
|
|
|
411
412
|
expect(result.content).not.toContain("prompt-secret-val");
|
|
412
413
|
|
|
413
414
|
// Verify stored
|
|
414
|
-
expect(getSecureKey("
|
|
415
|
+
expect(getSecureKey(credentialKey("test-prompt", "api_key"))).toBe(
|
|
415
416
|
"prompt-secret-val",
|
|
416
417
|
);
|
|
417
418
|
});
|
|
@@ -595,16 +596,18 @@ describe("credential_store tool — oauth2_connect error paths", () => {
|
|
|
595
596
|
expect(result.content).toContain("client_id is required");
|
|
596
597
|
});
|
|
597
598
|
|
|
598
|
-
test("uses stored client_id from
|
|
599
|
-
// Store
|
|
600
|
-
// requiresClientSecret guardrail will
|
|
601
|
-
// is missing, so we need both to
|
|
602
|
-
// is resolved correctly.
|
|
599
|
+
test("uses stored client_id from metadata", async () => {
|
|
600
|
+
// Store client_id in metadata (the canonical source) and client_secret
|
|
601
|
+
// in the secure store — the requiresClientSecret guardrail will
|
|
602
|
+
// short-circuit if client_secret is missing, so we need both to
|
|
603
|
+
// validate that stored client_id is resolved correctly.
|
|
604
|
+
upsertCredentialMetadata("integration:gmail", "access_token", {
|
|
605
|
+
oauth2ClientId: "stored-client-id-123",
|
|
606
|
+
});
|
|
603
607
|
setSecureKey(
|
|
604
|
-
"
|
|
605
|
-
"
|
|
608
|
+
credentialKey("integration:gmail", "client_secret"),
|
|
609
|
+
"test-secret",
|
|
606
610
|
);
|
|
607
|
-
setSecureKey("credential:integration:gmail:client_secret", "test-secret");
|
|
608
611
|
|
|
609
612
|
const result = await credentialStoreTool.execute(
|
|
610
613
|
{
|
|
@@ -624,12 +627,11 @@ describe("credential_store tool — oauth2_connect error paths", () => {
|
|
|
624
627
|
});
|
|
625
628
|
|
|
626
629
|
test("rejects when client_secret is missing for service that requires it", async () => {
|
|
627
|
-
// Store only client_id — client_secret is intentionally
|
|
628
|
-
// validate the requiresClientSecret guardrail.
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
);
|
|
630
|
+
// Store only client_id in metadata — client_secret is intentionally
|
|
631
|
+
// absent to validate the requiresClientSecret guardrail.
|
|
632
|
+
upsertCredentialMetadata("integration:gmail", "access_token", {
|
|
633
|
+
oauth2ClientId: "stored-client-id-456",
|
|
634
|
+
});
|
|
633
635
|
|
|
634
636
|
const result = await credentialStoreTool.execute(
|
|
635
637
|
{
|
|
@@ -786,7 +788,7 @@ describe("credential_store tool — store validation edge cases", () => {
|
|
|
786
788
|
);
|
|
787
789
|
|
|
788
790
|
// Verify stored
|
|
789
|
-
expect(getSecureKey("
|
|
791
|
+
expect(getSecureKey(credentialKey("del-test", "key"))).toBe("secret");
|
|
790
792
|
const { getCredentialMetadata } =
|
|
791
793
|
await import("../tools/credentials/metadata-store.js");
|
|
792
794
|
expect(getCredentialMetadata("del-test", "key")).toBeDefined();
|
|
@@ -803,7 +805,7 @@ describe("credential_store tool — store validation edge cases", () => {
|
|
|
803
805
|
expect(result.isError).toBe(false);
|
|
804
806
|
|
|
805
807
|
// Both should be gone
|
|
806
|
-
expect(getSecureKey("
|
|
808
|
+
expect(getSecureKey(credentialKey("del-test", "key"))).toBeUndefined();
|
|
807
809
|
expect(getCredentialMetadata("del-test", "key")).toBeUndefined();
|
|
808
810
|
});
|
|
809
811
|
});
|
|
@@ -892,7 +894,7 @@ describe("CredentialBroker — serverUseById edge cases", () => {
|
|
|
892
894
|
},
|
|
893
895
|
],
|
|
894
896
|
});
|
|
895
|
-
setSecureKey("
|
|
897
|
+
setSecureKey(credentialKey("multi", "api_key"), "multi-secret");
|
|
896
898
|
|
|
897
899
|
const result = broker.serverUseById({
|
|
898
900
|
credentialId: meta.credentialId,
|
|
@@ -45,20 +45,47 @@ mock.module("../tools/registry.js", () => ({
|
|
|
45
45
|
registerTool: () => {},
|
|
46
46
|
}));
|
|
47
47
|
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Mock OAuth2 token refresh for token-manager deduplication tests
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
let mockRefreshOAuth2Token: ReturnType<
|
|
53
|
+
typeof mock<() => Promise<{ accessToken: string; expiresIn: number }>>
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
mock.module("../security/oauth2.js", () => {
|
|
57
|
+
mockRefreshOAuth2Token = mock(() =>
|
|
58
|
+
Promise.resolve({
|
|
59
|
+
accessToken: "refreshed-access-token",
|
|
60
|
+
expiresIn: 3600,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
return {
|
|
64
|
+
refreshOAuth2Token: mockRefreshOAuth2Token,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
|
|
48
68
|
// ---------------------------------------------------------------------------
|
|
49
69
|
// Import the module under test
|
|
50
70
|
// ---------------------------------------------------------------------------
|
|
51
71
|
|
|
52
72
|
// getCredentialValue is no longer exported (sealed in PR 17) — use getSecureKey directly
|
|
53
73
|
|
|
74
|
+
import { credentialKey } from "../security/credential-key.js";
|
|
54
75
|
import {
|
|
55
76
|
deleteSecureKey,
|
|
56
77
|
getSecureKey,
|
|
57
78
|
setSecureKey,
|
|
58
79
|
} from "../security/secure-keys.js";
|
|
80
|
+
import {
|
|
81
|
+
_resetInflightRefreshes,
|
|
82
|
+
_resetRefreshBreakers,
|
|
83
|
+
withValidToken,
|
|
84
|
+
} from "../security/token-manager.js";
|
|
59
85
|
import {
|
|
60
86
|
_setMetadataPath,
|
|
61
87
|
getCredentialMetadata,
|
|
88
|
+
upsertCredentialMetadata,
|
|
62
89
|
} from "../tools/credentials/metadata-store.js";
|
|
63
90
|
import { credentialStoreTool } from "../tools/credentials/vault.js";
|
|
64
91
|
import type { ToolContext } from "../tools/types.js";
|
|
@@ -120,7 +147,7 @@ async function executeVault(
|
|
|
120
147
|
};
|
|
121
148
|
}
|
|
122
149
|
|
|
123
|
-
const key =
|
|
150
|
+
const key = credentialKey(service, field);
|
|
124
151
|
const ok = setSecureKey(key, value);
|
|
125
152
|
if (!ok) {
|
|
126
153
|
return { content: "Error: failed to store credential", isError: true };
|
|
@@ -151,7 +178,7 @@ async function executeVault(
|
|
|
151
178
|
};
|
|
152
179
|
}
|
|
153
180
|
|
|
154
|
-
const key =
|
|
181
|
+
const key = credentialKey(service, field);
|
|
155
182
|
const result = deleteSecureKey(key);
|
|
156
183
|
if (result !== "deleted") {
|
|
157
184
|
return {
|
|
@@ -553,7 +580,7 @@ describe("credential_store tool", () => {
|
|
|
553
580
|
|
|
554
581
|
// Delete the secret directly without going through the tool (simulates
|
|
555
582
|
// a divergence where metadata write failed after secret deletion)
|
|
556
|
-
deleteSecureKey("
|
|
583
|
+
deleteSecureKey(credentialKey("svc-a", "key"));
|
|
557
584
|
|
|
558
585
|
const result = await credentialStoreTool.execute(
|
|
559
586
|
{ action: "list" },
|
|
@@ -596,7 +623,7 @@ describe("credential_store tool", () => {
|
|
|
596
623
|
// -----------------------------------------------------------------------
|
|
597
624
|
describe("delete action", () => {
|
|
598
625
|
test("deletes a stored credential", async () => {
|
|
599
|
-
setSecureKey("
|
|
626
|
+
setSecureKey(credentialKey("gmail", "password"), "secret");
|
|
600
627
|
|
|
601
628
|
const result = await executeVault({
|
|
602
629
|
action: "delete",
|
|
@@ -607,7 +634,7 @@ describe("credential_store tool", () => {
|
|
|
607
634
|
expect(result.content).toBe("Deleted credential for gmail/password.");
|
|
608
635
|
|
|
609
636
|
// Verify it's actually gone
|
|
610
|
-
expect(getSecureKey("
|
|
637
|
+
expect(getSecureKey(credentialKey("gmail", "password"))).toBeUndefined();
|
|
611
638
|
});
|
|
612
639
|
|
|
613
640
|
test("returns error for non-existent credential", async () => {
|
|
@@ -644,12 +671,14 @@ describe("credential_store tool", () => {
|
|
|
644
671
|
// -----------------------------------------------------------------------
|
|
645
672
|
describe("credential value access", () => {
|
|
646
673
|
test("credential values are stored via secure keys", () => {
|
|
647
|
-
setSecureKey("
|
|
648
|
-
expect(getSecureKey("
|
|
674
|
+
setSecureKey(credentialKey("github", "token"), "ghp_abc123");
|
|
675
|
+
expect(getSecureKey(credentialKey("github", "token"))).toBe("ghp_abc123");
|
|
649
676
|
});
|
|
650
677
|
|
|
651
678
|
test("returns undefined for non-existent credential", () => {
|
|
652
|
-
expect(
|
|
679
|
+
expect(
|
|
680
|
+
getSecureKey(credentialKey("nonexistent", "field")),
|
|
681
|
+
).toBeUndefined();
|
|
653
682
|
});
|
|
654
683
|
});
|
|
655
684
|
|
|
@@ -1094,8 +1123,12 @@ describe("credential_store tool", () => {
|
|
|
1094
1123
|
value: "github-pass",
|
|
1095
1124
|
});
|
|
1096
1125
|
|
|
1097
|
-
expect(getSecureKey("
|
|
1098
|
-
|
|
1126
|
+
expect(getSecureKey(credentialKey("gmail", "password"))).toBe(
|
|
1127
|
+
"gmail-pass",
|
|
1128
|
+
);
|
|
1129
|
+
expect(getSecureKey(credentialKey("github", "password"))).toBe(
|
|
1130
|
+
"github-pass",
|
|
1131
|
+
);
|
|
1099
1132
|
});
|
|
1100
1133
|
|
|
1101
1134
|
test("same service with different fields do not collide", async () => {
|
|
@@ -1112,10 +1145,249 @@ describe("credential_store tool", () => {
|
|
|
1112
1145
|
value: "backup@example.com",
|
|
1113
1146
|
});
|
|
1114
1147
|
|
|
1115
|
-
expect(getSecureKey("
|
|
1116
|
-
expect(getSecureKey("
|
|
1148
|
+
expect(getSecureKey(credentialKey("gmail", "password"))).toBe("pass123");
|
|
1149
|
+
expect(getSecureKey(credentialKey("gmail", "recovery_email"))).toBe(
|
|
1117
1150
|
"backup@example.com",
|
|
1118
1151
|
);
|
|
1119
1152
|
});
|
|
1120
1153
|
});
|
|
1121
1154
|
});
|
|
1155
|
+
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
// Token refresh deduplication tests
|
|
1158
|
+
// ---------------------------------------------------------------------------
|
|
1159
|
+
|
|
1160
|
+
describe("withValidToken refresh deduplication", () => {
|
|
1161
|
+
beforeAll(() => {
|
|
1162
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
beforeEach(() => {
|
|
1166
|
+
_resetBackend();
|
|
1167
|
+
for (const entry of readdirSync(TEST_DIR)) {
|
|
1168
|
+
rmSync(join(TEST_DIR, entry), { recursive: true, force: true });
|
|
1169
|
+
}
|
|
1170
|
+
_setStorePath(STORE_PATH);
|
|
1171
|
+
_setMetadataPath(join(TEST_DIR, "metadata.json"));
|
|
1172
|
+
_resetRefreshBreakers();
|
|
1173
|
+
_resetInflightRefreshes();
|
|
1174
|
+
mockRefreshOAuth2Token.mockClear();
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
afterEach(() => {
|
|
1178
|
+
_setMetadataPath(null);
|
|
1179
|
+
_setStorePath(null);
|
|
1180
|
+
_resetBackend();
|
|
1181
|
+
_resetRefreshBreakers();
|
|
1182
|
+
_resetInflightRefreshes();
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
afterAll(() => {
|
|
1186
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Helper: set up a service with an access token, refresh token, and OAuth2
|
|
1191
|
+
* metadata so that token refresh can proceed through doRefresh().
|
|
1192
|
+
*/
|
|
1193
|
+
function setupService(
|
|
1194
|
+
service: string,
|
|
1195
|
+
opts?: { expired?: boolean; accessToken?: string },
|
|
1196
|
+
) {
|
|
1197
|
+
const accessToken = opts?.accessToken ?? "old-access-token";
|
|
1198
|
+
setSecureKey(credentialKey(service, "access_token"), accessToken);
|
|
1199
|
+
setSecureKey(
|
|
1200
|
+
credentialKey(service, "refresh_token"),
|
|
1201
|
+
"valid-refresh-token",
|
|
1202
|
+
);
|
|
1203
|
+
upsertCredentialMetadata(service, "access_token", {
|
|
1204
|
+
oauth2TokenUrl: "https://oauth.example.com/token",
|
|
1205
|
+
oauth2ClientId: "test-client-id",
|
|
1206
|
+
...(opts?.expired
|
|
1207
|
+
? { expiresAt: Date.now() - 60_000 } // expired 1 minute ago
|
|
1208
|
+
: { expiresAt: Date.now() + 3600_000 }), // expires in 1 hour
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
test("3 concurrent 401 refreshes for the same service call doRefresh exactly once", async () => {
|
|
1213
|
+
setupService("integration:gmail");
|
|
1214
|
+
|
|
1215
|
+
let resolveRefresh!: (value: {
|
|
1216
|
+
accessToken: string;
|
|
1217
|
+
expiresIn: number;
|
|
1218
|
+
}) => void;
|
|
1219
|
+
const refreshPromise = new Promise<{
|
|
1220
|
+
accessToken: string;
|
|
1221
|
+
expiresIn: number;
|
|
1222
|
+
}>((resolve) => {
|
|
1223
|
+
resolveRefresh = resolve;
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
mockRefreshOAuth2Token.mockImplementation(() => refreshPromise);
|
|
1227
|
+
|
|
1228
|
+
const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
|
|
1229
|
+
|
|
1230
|
+
const callback = async (token: string) => {
|
|
1231
|
+
if (token === "old-access-token") throw err401;
|
|
1232
|
+
return `result-with-${token}`;
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
// Launch 3 concurrent withValidToken calls — all will get a non-expired
|
|
1236
|
+
// token first, call the callback, get a 401, and then try to refresh.
|
|
1237
|
+
const p1 = withValidToken("integration:gmail", callback);
|
|
1238
|
+
const p2 = withValidToken("integration:gmail", callback);
|
|
1239
|
+
const p3 = withValidToken("integration:gmail", callback);
|
|
1240
|
+
|
|
1241
|
+
// Let the event loop tick so all 3 calls enter the 401 retry path
|
|
1242
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1243
|
+
|
|
1244
|
+
// Resolve the single refresh attempt
|
|
1245
|
+
resolveRefresh({ accessToken: "new-token-123", expiresIn: 3600 });
|
|
1246
|
+
|
|
1247
|
+
const results = await Promise.all([p1, p2, p3]);
|
|
1248
|
+
|
|
1249
|
+
// All 3 should succeed with the refreshed token
|
|
1250
|
+
expect(results).toEqual([
|
|
1251
|
+
"result-with-new-token-123",
|
|
1252
|
+
"result-with-new-token-123",
|
|
1253
|
+
"result-with-new-token-123",
|
|
1254
|
+
]);
|
|
1255
|
+
|
|
1256
|
+
// refreshOAuth2Token should have been called exactly once
|
|
1257
|
+
expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
test("concurrent refreshes for different services proceed independently", async () => {
|
|
1261
|
+
setupService("integration:gmail");
|
|
1262
|
+
setupService("integration:slack");
|
|
1263
|
+
|
|
1264
|
+
let resolveGmail!: (value: {
|
|
1265
|
+
accessToken: string;
|
|
1266
|
+
expiresIn: number;
|
|
1267
|
+
}) => void;
|
|
1268
|
+
let resolveSlack!: (value: {
|
|
1269
|
+
accessToken: string;
|
|
1270
|
+
expiresIn: number;
|
|
1271
|
+
}) => void;
|
|
1272
|
+
|
|
1273
|
+
const gmailPromise = new Promise<{
|
|
1274
|
+
accessToken: string;
|
|
1275
|
+
expiresIn: number;
|
|
1276
|
+
}>((resolve) => {
|
|
1277
|
+
resolveGmail = resolve;
|
|
1278
|
+
});
|
|
1279
|
+
const slackPromise = new Promise<{
|
|
1280
|
+
accessToken: string;
|
|
1281
|
+
expiresIn: number;
|
|
1282
|
+
}>((resolve) => {
|
|
1283
|
+
resolveSlack = resolve;
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
let refreshCallCount = 0;
|
|
1287
|
+
mockRefreshOAuth2Token.mockImplementation(() => {
|
|
1288
|
+
refreshCallCount++;
|
|
1289
|
+
// Both services use the same tokenUrl in this test, so we track by
|
|
1290
|
+
// call order to return the correct deferred promise.
|
|
1291
|
+
if (refreshCallCount === 1) return gmailPromise;
|
|
1292
|
+
return slackPromise;
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
|
|
1296
|
+
|
|
1297
|
+
const gmailCallback = async (token: string) => {
|
|
1298
|
+
if (token === "old-access-token") throw err401;
|
|
1299
|
+
return `gmail-${token}`;
|
|
1300
|
+
};
|
|
1301
|
+
const slackCallback = async (token: string) => {
|
|
1302
|
+
if (token === "old-access-token") throw err401;
|
|
1303
|
+
return `slack-${token}`;
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
const p1 = withValidToken("integration:gmail", gmailCallback);
|
|
1307
|
+
const p2 = withValidToken("integration:slack", slackCallback);
|
|
1308
|
+
|
|
1309
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1310
|
+
|
|
1311
|
+
// Resolve both independently
|
|
1312
|
+
resolveGmail({ accessToken: "gmail-new-token", expiresIn: 3600 });
|
|
1313
|
+
resolveSlack({ accessToken: "slack-new-token", expiresIn: 3600 });
|
|
1314
|
+
|
|
1315
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
1316
|
+
|
|
1317
|
+
expect(r1).toBe("gmail-gmail-new-token");
|
|
1318
|
+
expect(r2).toBe("slack-slack-new-token");
|
|
1319
|
+
|
|
1320
|
+
// Both services should have triggered their own refresh
|
|
1321
|
+
expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(2);
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
test("deduplication cleans up after refresh completes, allowing subsequent refreshes", async () => {
|
|
1325
|
+
setupService("integration:gmail");
|
|
1326
|
+
|
|
1327
|
+
let refreshCount = 0;
|
|
1328
|
+
mockRefreshOAuth2Token.mockImplementation(() => {
|
|
1329
|
+
refreshCount++;
|
|
1330
|
+
return Promise.resolve({
|
|
1331
|
+
accessToken: `token-${refreshCount}`,
|
|
1332
|
+
expiresIn: 3600,
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
|
|
1337
|
+
|
|
1338
|
+
// First call triggers a refresh
|
|
1339
|
+
const r1 = await withValidToken(
|
|
1340
|
+
"integration:gmail",
|
|
1341
|
+
async (token: string) => {
|
|
1342
|
+
if (token === "old-access-token") throw err401;
|
|
1343
|
+
return token;
|
|
1344
|
+
},
|
|
1345
|
+
);
|
|
1346
|
+
expect(r1).toBe("token-1");
|
|
1347
|
+
expect(refreshCount).toBe(1);
|
|
1348
|
+
|
|
1349
|
+
// Set up so the next call will also get a 401 (token-1 stored from first refresh)
|
|
1350
|
+
const r2 = await withValidToken(
|
|
1351
|
+
"integration:gmail",
|
|
1352
|
+
async (token: string) => {
|
|
1353
|
+
if (token === "token-1") throw err401;
|
|
1354
|
+
return token;
|
|
1355
|
+
},
|
|
1356
|
+
);
|
|
1357
|
+
expect(r2).toBe("token-2");
|
|
1358
|
+
// Second refresh should have happened (not deduplicated with the first,
|
|
1359
|
+
// since the first already completed)
|
|
1360
|
+
expect(refreshCount).toBe(2);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
test("deduplication propagates refresh errors to all waiting callers", async () => {
|
|
1364
|
+
setupService("integration:gmail");
|
|
1365
|
+
|
|
1366
|
+
mockRefreshOAuth2Token.mockImplementation(() =>
|
|
1367
|
+
Promise.reject(
|
|
1368
|
+
Object.assign(
|
|
1369
|
+
new Error("OAuth2 token refresh failed (HTTP 401: invalid_grant)"),
|
|
1370
|
+
),
|
|
1371
|
+
),
|
|
1372
|
+
);
|
|
1373
|
+
|
|
1374
|
+
const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
|
|
1375
|
+
|
|
1376
|
+
const callback = async (token: string) => {
|
|
1377
|
+
if (token === "old-access-token") throw err401;
|
|
1378
|
+
return "should-not-reach";
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
// Launch 2 concurrent calls — both should fail with the same error
|
|
1382
|
+
const p1 = withValidToken("integration:gmail", callback);
|
|
1383
|
+
const p2 = withValidToken("integration:gmail", callback);
|
|
1384
|
+
|
|
1385
|
+
const results = await Promise.allSettled([p1, p2]);
|
|
1386
|
+
|
|
1387
|
+
expect(results[0].status).toBe("rejected");
|
|
1388
|
+
expect(results[1].status).toBe("rejected");
|
|
1389
|
+
|
|
1390
|
+
// Only one actual refresh attempt
|
|
1391
|
+
expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
|
|
5
|
+
import { credentialKey } from "../security/credential-key.js";
|
|
5
6
|
import type { CredentialMetadata } from "../tools/credentials/metadata-store.js";
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -238,7 +239,7 @@ function seedCredential(
|
|
|
238
239
|
...extra,
|
|
239
240
|
};
|
|
240
241
|
metadataStore.push(record);
|
|
241
|
-
secureKeyStore.set(
|
|
242
|
+
secureKeyStore.set(credentialKey(service, field), secret);
|
|
242
243
|
return record;
|
|
243
244
|
}
|
|
244
245
|
|
|
@@ -437,7 +438,7 @@ describe("assistant credentials CLI", () => {
|
|
|
437
438
|
expect(parsed.credentialId).toBeTruthy();
|
|
438
439
|
|
|
439
440
|
// Verify secret stored in mock map
|
|
440
|
-
expect(secureKeyStore.get("
|
|
441
|
+
expect(secureKeyStore.get(credentialKey("twilio", "account_sid"))).toBe(
|
|
441
442
|
"AC1234567890",
|
|
442
443
|
);
|
|
443
444
|
|
|
@@ -546,7 +547,7 @@ describe("assistant credentials CLI", () => {
|
|
|
546
547
|
expect(meta2!.updatedAt).toBeGreaterThan(firstUpdatedAt);
|
|
547
548
|
|
|
548
549
|
// Verify secret is overwritten
|
|
549
|
-
expect(secureKeyStore.get("
|
|
550
|
+
expect(secureKeyStore.get(credentialKey("twilio", "account_sid"))).toBe(
|
|
550
551
|
"new_value",
|
|
551
552
|
);
|
|
552
553
|
});
|
|
@@ -568,7 +569,9 @@ describe("assistant credentials CLI", () => {
|
|
|
568
569
|
expect(parsed.field).toBe("auth_token");
|
|
569
570
|
|
|
570
571
|
// Verify both removed
|
|
571
|
-
expect(secureKeyStore.has("
|
|
572
|
+
expect(secureKeyStore.has(credentialKey("twilio", "auth_token"))).toBe(
|
|
573
|
+
false,
|
|
574
|
+
);
|
|
572
575
|
expect(
|
|
573
576
|
metadataStore.find(
|
|
574
577
|
(m) => m.service === "twilio" && m.field === "auth_token",
|
|
@@ -833,8 +836,10 @@ describe("assistant credentials CLI", () => {
|
|
|
833
836
|
expect(parsed.value).toBe("instance_secret_abc123");
|
|
834
837
|
|
|
835
838
|
// Verify the correct key was looked up in the secure store
|
|
836
|
-
expect(secureKeyStore.has("
|
|
837
|
-
|
|
839
|
+
expect(secureKeyStore.has(credentialKey("twilio", "auth_token"))).toBe(
|
|
840
|
+
true,
|
|
841
|
+
);
|
|
842
|
+
expect(secureKeyStore.get(credentialKey("twilio", "auth_token"))).toBe(
|
|
838
843
|
"instance_secret_abc123",
|
|
839
844
|
);
|
|
840
845
|
});
|
|
@@ -112,9 +112,11 @@ mock.module("../calls/twilio-provider.js", () => ({
|
|
|
112
112
|
},
|
|
113
113
|
}));
|
|
114
114
|
|
|
115
|
+
import { credentialKey } from "../security/credential-key.js";
|
|
116
|
+
|
|
115
117
|
const secureKeyStore: Record<string, string | undefined> = {
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
+
[credentialKey("twilio", "account_sid")]: "AC_test",
|
|
119
|
+
[credentialKey("twilio", "auth_token")]: "test_token",
|
|
118
120
|
};
|
|
119
121
|
|
|
120
122
|
mock.module("../security/secure-keys.js", () => ({
|