@vellumai/assistant 0.4.13 → 0.4.15

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 (133) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -19,8 +19,8 @@ import { loadConfig } from '../../../config/loader.js';
19
19
  import { getOrCreateConversation } from '../../../memory/conversation-key-store.js';
20
20
  import * as externalConversationStore from '../../../memory/external-conversation-store.js';
21
21
  import { DAEMON_INTERNAL_ASSISTANT_ID } from '../../../runtime/assistant-scope.js';
22
+ import { mintDaemonDeliveryToken } from '../../../runtime/auth/token-service.js';
22
23
  import { getSecureKey } from '../../../security/secure-keys.js';
23
- import { readHttpToken } from '../../../util/platform.js';
24
24
  import type { MessagingProvider } from '../../provider.js';
25
25
  import type {
26
26
  ConnectionInfo,
@@ -40,13 +40,9 @@ function getGatewayUrl(): string {
40
40
  return getGatewayInternalBaseUrl();
41
41
  }
42
42
 
43
- /** Read the runtime HTTP bearer token used to authenticate with the gateway. */
43
+ /** Mint a short-lived JWT for authenticating with the gateway. */
44
44
  function getBearerToken(): string {
45
- const token = readHttpToken();
46
- if (!token) {
47
- throw new Error('No runtime HTTP bearer token available — is the daemon running?');
48
- }
49
- return token;
45
+ return mintDaemonDeliveryToken();
50
46
  }
51
47
 
52
48
  /** Check whether Twilio credentials are stored. */
@@ -14,8 +14,8 @@
14
14
  import { getGatewayInternalBaseUrl } from '../../../config/env.js';
15
15
  import { getOrCreateConversation } from '../../../memory/conversation-key-store.js';
16
16
  import * as externalConversationStore from '../../../memory/external-conversation-store.js';
17
+ import { mintDaemonDeliveryToken } from '../../../runtime/auth/token-service.js';
17
18
  import { getSecureKey } from '../../../security/secure-keys.js';
18
- import { readHttpToken } from '../../../util/platform.js';
19
19
  import type { MessagingProvider } from '../../provider.js';
20
20
  import type {
21
21
  ConnectionInfo,
@@ -35,13 +35,9 @@ function getGatewayUrl(): string {
35
35
  return getGatewayInternalBaseUrl();
36
36
  }
37
37
 
38
- /** Read the runtime HTTP bearer token used to authenticate with the gateway. */
38
+ /** Mint a short-lived JWT for authenticating with the gateway. */
39
39
  function getBearerToken(): string {
40
- const token = readHttpToken();
41
- if (!token) {
42
- throw new Error('No runtime HTTP bearer token available — is the daemon running?');
43
- }
44
- return token;
40
+ return mintDaemonDeliveryToken();
45
41
  }
46
42
 
47
43
  /** Read the Telegram bot token from the credential vault. */
@@ -13,8 +13,8 @@
13
13
  import { getGatewayInternalBaseUrl } from '../../../config/env.js';
14
14
  import { getOrCreateConversation } from '../../../memory/conversation-key-store.js';
15
15
  import * as externalConversationStore from '../../../memory/external-conversation-store.js';
16
+ import { mintDaemonDeliveryToken } from '../../../runtime/auth/token-service.js';
16
17
  import { getSecureKey } from '../../../security/secure-keys.js';
17
- import { readHttpToken } from '../../../util/platform.js';
18
18
  import type { MessagingProvider } from '../../provider.js';
19
19
  import type {
20
20
  ConnectionInfo,
@@ -34,13 +34,9 @@ function getGatewayUrl(): string {
34
34
  return getGatewayInternalBaseUrl();
35
35
  }
36
36
 
37
- /** Read the runtime HTTP bearer token used to authenticate with the gateway. */
37
+ /** Mint a short-lived JWT for authenticating with the gateway. */
38
38
  function getBearerToken(): string {
39
- const token = readHttpToken();
40
- if (!token) {
41
- throw new Error('No runtime HTTP bearer token available — is the daemon running?');
42
- }
43
- return token;
39
+ return mintDaemonDeliveryToken();
44
40
  }
45
41
 
46
42
  /** Check whether WhatsApp credentials are stored. */
@@ -13,9 +13,9 @@
13
13
  */
14
14
 
15
15
  import { getGatewayInternalBaseUrl } from '../../config/env.js';
16
+ import { mintDaemonDeliveryToken } from '../../runtime/auth/token-service.js';
16
17
  import { deliverChannelReply } from '../../runtime/gateway-client.js';
17
18
  import { getLogger } from '../../util/logger.js';
18
- import { readHttpToken } from '../../util/platform.js';
19
19
  import { nonEmpty } from '../copy-composer.js';
20
20
  import type {
21
21
  ChannelAdapter,
@@ -59,7 +59,7 @@ export class SmsAdapter implements ChannelAdapter {
59
59
  await deliverChannelReply(
60
60
  deliverUrl,
61
61
  { chatId: phoneNumber, text: messageText, assistantId: payload.assistantId },
62
- readHttpToken() ?? undefined,
62
+ mintDaemonDeliveryToken(),
63
63
  );
64
64
 
65
65
  log.info(
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import { getGatewayInternalBaseUrl } from '../../config/env.js';
11
+ import { mintDaemonDeliveryToken } from '../../runtime/auth/token-service.js';
11
12
  import { deliverChannelReply } from '../../runtime/gateway-client.js';
12
13
  import { getLogger } from '../../util/logger.js';
13
- import { readHttpToken } from '../../util/platform.js';
14
14
  import { nonEmpty } from '../copy-composer.js';
15
15
  import { isThreadSeedSane } from '../thread-seed-composer.js';
16
16
  import type {
@@ -61,7 +61,7 @@ export class TelegramAdapter implements ChannelAdapter {
61
61
  await deliverChannelReply(
62
62
  deliverUrl,
63
63
  { chatId, text: messageText },
64
- readHttpToken() ?? undefined,
64
+ mintDaemonDeliveryToken(),
65
65
  );
66
66
 
67
67
  log.info(
@@ -56,6 +56,7 @@ export class PermissionPrompter {
56
56
  executionTarget?: ExecutionTarget,
57
57
  persistentDecisionsAllowed?: boolean,
58
58
  signal?: AbortSignal,
59
+ temporaryOptionsAvailable?: Array<'allow_10m' | 'allow_thread'>,
59
60
  ): Promise<{
60
61
  decision: UserDecision;
61
62
  selectedPattern?: string;
@@ -101,6 +102,7 @@ export class PermissionPrompter {
101
102
  sessionId,
102
103
  executionTarget,
103
104
  persistentDecisionsAllowed: persistentDecisionsAllowed ?? true,
105
+ temporaryOptionsAvailable,
104
106
  });
105
107
 
106
108
  this.onStateChanged?.(requestId, 'pending', 'system');
@@ -16,7 +16,17 @@ export interface TrustRule {
16
16
  allowHighRisk?: boolean;
17
17
  }
18
18
 
19
- export type UserDecision = 'allow' | 'always_allow' | 'always_allow_high_risk' | 'deny' | 'always_deny';
19
+ export type UserDecision = 'allow' | 'allow_10m' | 'allow_thread' | 'always_allow' | 'always_allow_high_risk' | 'deny' | 'always_deny' | 'temporary_override';
20
+
21
+ /** Returns true for any allow-variant decision. Centralizes the check to prevent omissions when new allow variants are added. */
22
+ export function isAllowDecision(decision: UserDecision): boolean {
23
+ return decision === 'allow'
24
+ || decision === 'allow_10m'
25
+ || decision === 'allow_thread'
26
+ || decision === 'always_allow'
27
+ || decision === 'always_allow_high_risk'
28
+ || decision === 'temporary_override';
29
+ }
20
30
 
21
31
  export interface PermissionCheckResult {
22
32
  decision: 'allow' | 'deny' | 'prompt';
@@ -21,6 +21,8 @@ import type {
21
21
  const VALID_DISPOSITIONS: ReadonlySet<ApprovalConversationDisposition> = new Set([
22
22
  'keep_pending',
23
23
  'approve_once',
24
+ 'approve_10m',
25
+ 'approve_thread',
24
26
  'approve_always',
25
27
  'reject',
26
28
  ]);
@@ -28,6 +30,8 @@ const VALID_DISPOSITIONS: ReadonlySet<ApprovalConversationDisposition> = new Set
28
30
  /** Dispositions that represent an actual decision (not just "keep waiting"). */
29
31
  const DECISION_BEARING_DISPOSITIONS: ReadonlySet<ApprovalConversationDisposition> = new Set([
30
32
  'approve_once',
33
+ 'approve_10m',
34
+ 'approve_thread',
31
35
  'approve_always',
32
36
  'reject',
33
37
  ]);
@@ -0,0 +1,130 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../../assistant-scope.js';
4
+ import { buildAuthContext } from '../context.js';
5
+ import type { TokenClaims } from '../types.js';
6
+
7
+ function validClaims(overrides?: Partial<TokenClaims>): TokenClaims {
8
+ return {
9
+ iss: 'vellum-auth',
10
+ aud: 'vellum-daemon',
11
+ sub: 'actor:self:principal-abc',
12
+ scope_profile: 'actor_client_v1',
13
+ exp: Math.floor(Date.now() / 1000) + 300,
14
+ policy_epoch: 1,
15
+ iat: Math.floor(Date.now() / 1000),
16
+ jti: 'test-jti',
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe('buildAuthContext', () => {
22
+ test('builds context from valid actor claims', () => {
23
+ const result = buildAuthContext(validClaims());
24
+ expect(result.ok).toBe(true);
25
+ if (result.ok) {
26
+ expect(result.context.subject).toBe('actor:self:principal-abc');
27
+ expect(result.context.principalType).toBe('actor');
28
+ expect(result.context.assistantId).toBe('self');
29
+ expect(result.context.actorPrincipalId).toBe('principal-abc');
30
+ expect(result.context.sessionId).toBeUndefined();
31
+ expect(result.context.scopeProfile).toBe('actor_client_v1');
32
+ expect(result.context.policyEpoch).toBe(1);
33
+ expect(result.context.scopes.has('chat.read')).toBe(true);
34
+ expect(result.context.scopes.has('chat.write')).toBe(true);
35
+ }
36
+ });
37
+
38
+ test('builds context from valid svc:gateway claims', () => {
39
+ const result = buildAuthContext(validClaims({
40
+ sub: 'svc:gateway:self',
41
+ scope_profile: 'gateway_ingress_v1',
42
+ }));
43
+ expect(result.ok).toBe(true);
44
+ if (result.ok) {
45
+ expect(result.context.principalType).toBe('svc_gateway');
46
+ expect(result.context.assistantId).toBe('self');
47
+ expect(result.context.scopes.has('ingress.write')).toBe(true);
48
+ }
49
+ });
50
+
51
+ test('builds context from valid ipc claims', () => {
52
+ const result = buildAuthContext(validClaims({
53
+ sub: 'ipc:self:session-123',
54
+ scope_profile: 'ipc_v1',
55
+ }));
56
+ expect(result.ok).toBe(true);
57
+ if (result.ok) {
58
+ expect(result.context.principalType).toBe('ipc');
59
+ expect(result.context.assistantId).toBe('self');
60
+ expect(result.context.sessionId).toBe('session-123');
61
+ expect(result.context.scopes.has('ipc.all')).toBe(true);
62
+ }
63
+ });
64
+
65
+ test('daemon-audience token forces assistantId to DAEMON_INTERNAL_ASSISTANT_ID', () => {
66
+ // Token sub contains an external assistant ID, but audience is daemon
67
+ const result = buildAuthContext(validClaims({
68
+ aud: 'vellum-daemon',
69
+ sub: 'actor:external-assistant-xyz:principal-abc',
70
+ }));
71
+ expect(result.ok).toBe(true);
72
+ if (result.ok) {
73
+ expect(result.context.assistantId).toBe(DAEMON_INTERNAL_ASSISTANT_ID);
74
+ expect(result.context.assistantId).toBe('self');
75
+ // Other fields should still reflect the parsed sub
76
+ expect(result.context.principalType).toBe('actor');
77
+ expect(result.context.actorPrincipalId).toBe('principal-abc');
78
+ }
79
+ });
80
+
81
+ test('gateway-audience token preserves assistantId from sub', () => {
82
+ const result = buildAuthContext(validClaims({
83
+ aud: 'vellum-gateway',
84
+ sub: 'actor:external-assistant-xyz:principal-abc',
85
+ }));
86
+ expect(result.ok).toBe(true);
87
+ if (result.ok) {
88
+ expect(result.context.assistantId).toBe('external-assistant-xyz');
89
+ expect(result.context.principalType).toBe('actor');
90
+ expect(result.context.actorPrincipalId).toBe('principal-abc');
91
+ }
92
+ });
93
+
94
+ test('daemon-audience svc:gateway sub also forces assistantId to self', () => {
95
+ const result = buildAuthContext(validClaims({
96
+ aud: 'vellum-daemon',
97
+ sub: 'svc:gateway:external-id',
98
+ scope_profile: 'gateway_ingress_v1',
99
+ }));
100
+ expect(result.ok).toBe(true);
101
+ if (result.ok) {
102
+ expect(result.context.assistantId).toBe(DAEMON_INTERNAL_ASSISTANT_ID);
103
+ expect(result.context.principalType).toBe('svc_gateway');
104
+ }
105
+ });
106
+
107
+ test('fails with invalid sub pattern', () => {
108
+ const result = buildAuthContext(validClaims({ sub: 'bad:format' }));
109
+ expect(result.ok).toBe(false);
110
+ if (!result.ok) {
111
+ expect(result.reason).toContain('unrecognized');
112
+ }
113
+ });
114
+
115
+ test('fails with empty sub', () => {
116
+ const result = buildAuthContext(validClaims({ sub: '' }));
117
+ expect(result.ok).toBe(false);
118
+ if (!result.ok) {
119
+ expect(result.reason).toContain('empty');
120
+ }
121
+ });
122
+
123
+ test('preserves policy epoch from claims', () => {
124
+ const result = buildAuthContext(validClaims({ policy_epoch: 42 }));
125
+ expect(result.ok).toBe(true);
126
+ if (result.ok) {
127
+ expect(result.context.policyEpoch).toBe(42);
128
+ }
129
+ });
130
+ });
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Tests for the JWT credential service — mint round-trip, rotation,
3
+ * replay detection, and device binding enforcement.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
11
+
12
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'credential-service-test-')));
13
+
14
+ mock.module('../../../util/platform.js', () => ({
15
+ getRootDir: () => testDir,
16
+ getDataDir: () => testDir,
17
+ getDbPath: () => join(testDir, 'test.db'),
18
+ normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
19
+ readLockfile: () => ({ assistants: [{ assistantId: 'vellum-test-eel' }] }),
20
+ isMacOS: () => process.platform === 'darwin',
21
+ isLinux: () => process.platform === 'linux',
22
+ isWindows: () => process.platform === 'win32',
23
+ getSocketPath: () => join(testDir, 'test.sock'),
24
+ getPidPath: () => join(testDir, 'test.pid'),
25
+ getLogPath: () => join(testDir, 'test.log'),
26
+ ensureDataDir: () => {},
27
+ }));
28
+
29
+ mock.module('../../../util/logger.js', () => ({
30
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
31
+ get: () => () => {},
32
+ }),
33
+ }));
34
+
35
+ import { getSqlite, initializeDb, resetDb } from '../../../memory/db.js';
36
+ import { findActiveByTokenHash } from '../../actor-token-store.js';
37
+ import { mintCredentialPair, rotateCredentials } from '../credential-service.js';
38
+ import { resetExternalAssistantIdCache } from '../external-assistant-id.js';
39
+ import { hashToken, initAuthSigningKey, verifyToken } from '../token-service.js';
40
+
41
+ const TEST_KEY = Buffer.from('test-signing-key-32-bytes-long!!');
42
+
43
+ initializeDb();
44
+
45
+ beforeEach(() => {
46
+ initAuthSigningKey(TEST_KEY);
47
+ resetExternalAssistantIdCache();
48
+ resetDb();
49
+ initializeDb();
50
+ const db = getSqlite();
51
+ db.run('DELETE FROM actor_token_records');
52
+ db.run('DELETE FROM actor_refresh_token_records');
53
+ });
54
+
55
+ afterAll(() => {
56
+ try { rmSync(testDir, { recursive: true, force: true }); } catch {}
57
+ });
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Mint credential pair
61
+ // ---------------------------------------------------------------------------
62
+
63
+ describe('mintCredentialPair', () => {
64
+ test('returns JWT access token and opaque refresh token', () => {
65
+ const result = mintCredentialPair({
66
+ assistantId: 'self',
67
+ platform: 'macos',
68
+ deviceId: 'device-123',
69
+ guardianPrincipalId: 'principal-abc',
70
+ hashedDeviceId: createHash('sha256').update('device-123').digest('hex'),
71
+ });
72
+
73
+ expect(result.accessToken).toBeTruthy();
74
+ expect(result.refreshToken).toBeTruthy();
75
+ expect(result.accessTokenExpiresAt).toBeGreaterThan(Date.now());
76
+ expect(result.refreshTokenExpiresAt).toBeGreaterThan(Date.now());
77
+ expect(result.refreshAfter).toBeGreaterThan(Date.now());
78
+ expect(result.guardianPrincipalId).toBe('principal-abc');
79
+ });
80
+
81
+ test('access token is a valid 3-part JWT', () => {
82
+ const result = mintCredentialPair({
83
+ assistantId: 'self',
84
+ platform: 'macos',
85
+ deviceId: 'device-jwt',
86
+ guardianPrincipalId: 'principal-jwt',
87
+ hashedDeviceId: createHash('sha256').update('device-jwt').digest('hex'),
88
+ });
89
+
90
+ const parts = result.accessToken.split('.');
91
+ expect(parts.length).toBe(3);
92
+ });
93
+
94
+ test('access token verifies against vellum-gateway audience', () => {
95
+ const result = mintCredentialPair({
96
+ assistantId: 'self',
97
+ platform: 'macos',
98
+ deviceId: 'device-verify',
99
+ guardianPrincipalId: 'principal-verify',
100
+ hashedDeviceId: createHash('sha256').update('device-verify').digest('hex'),
101
+ });
102
+
103
+ const verify = verifyToken(result.accessToken, 'vellum-gateway');
104
+ expect(verify.ok).toBe(true);
105
+ if (verify.ok) {
106
+ expect(verify.claims.aud).toBe('vellum-gateway');
107
+ expect(verify.claims.scope_profile).toBe('actor_client_v1');
108
+ // Sub should contain the external assistant ID from lockfile
109
+ expect(verify.claims.sub).toBe('actor:vellum-test-eel:principal-verify');
110
+ }
111
+ });
112
+
113
+ test('access token hash is stored in actor-token store', () => {
114
+ const result = mintCredentialPair({
115
+ assistantId: 'self',
116
+ platform: 'macos',
117
+ deviceId: 'device-store',
118
+ guardianPrincipalId: 'principal-store',
119
+ hashedDeviceId: createHash('sha256').update('device-store').digest('hex'),
120
+ });
121
+
122
+ const tokenHash = hashToken(result.accessToken);
123
+ const record = findActiveByTokenHash(tokenHash);
124
+ expect(record).not.toBeNull();
125
+ expect(record!.platform).toBe('macos');
126
+ expect(record!.guardianPrincipalId).toBe('principal-store');
127
+ });
128
+
129
+ test('minting twice for same device revokes previous tokens', () => {
130
+ const hashedDeviceId = createHash('sha256').update('device-dup').digest('hex');
131
+ const params = {
132
+ assistantId: 'self',
133
+ platform: 'macos' as const,
134
+ deviceId: 'device-dup',
135
+ guardianPrincipalId: 'principal-dup',
136
+ hashedDeviceId,
137
+ };
138
+
139
+ const first = mintCredentialPair(params);
140
+ const second = mintCredentialPair(params);
141
+
142
+ expect(first.accessToken).not.toBe(second.accessToken);
143
+ expect(first.refreshToken).not.toBe(second.refreshToken);
144
+
145
+ // First token should be revoked
146
+ const firstTokenHash = hashToken(first.accessToken);
147
+ const firstRecord = findActiveByTokenHash(firstTokenHash);
148
+ expect(firstRecord).toBeNull();
149
+
150
+ // Second should be active
151
+ const secondTokenHash = hashToken(second.accessToken);
152
+ const secondRecord = findActiveByTokenHash(secondTokenHash);
153
+ expect(secondRecord).not.toBeNull();
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Rotate credentials
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe('rotateCredentials', () => {
162
+ test('successful rotation returns new JWT access token', () => {
163
+ const hashedDeviceId = createHash('sha256').update('device-rot').digest('hex');
164
+ const initial = mintCredentialPair({
165
+ assistantId: 'self',
166
+ platform: 'ios',
167
+ deviceId: 'device-rot',
168
+ guardianPrincipalId: 'principal-rot',
169
+ hashedDeviceId,
170
+ });
171
+
172
+ const result = rotateCredentials({
173
+ refreshToken: initial.refreshToken,
174
+ platform: 'ios',
175
+ deviceId: 'device-rot',
176
+ });
177
+
178
+ expect(result.ok).toBe(true);
179
+ if (result.ok) {
180
+ expect(result.result.accessToken).toBeTruthy();
181
+ expect(result.result.accessToken).not.toBe(initial.accessToken);
182
+ expect(result.result.refreshToken).not.toBe(initial.refreshToken);
183
+
184
+ // New access token is a valid JWT
185
+ const verify = verifyToken(result.result.accessToken, 'vellum-gateway');
186
+ expect(verify.ok).toBe(true);
187
+ }
188
+ });
189
+
190
+ test('replay detection: reusing rotated refresh token revokes family', () => {
191
+ const hashedDeviceId = createHash('sha256').update('device-replay').digest('hex');
192
+ const initial = mintCredentialPair({
193
+ assistantId: 'self',
194
+ platform: 'ios',
195
+ deviceId: 'device-replay',
196
+ guardianPrincipalId: 'principal-replay',
197
+ hashedDeviceId,
198
+ });
199
+
200
+ // First rotation succeeds
201
+ const first = rotateCredentials({
202
+ refreshToken: initial.refreshToken,
203
+ platform: 'ios',
204
+ deviceId: 'device-replay',
205
+ });
206
+ expect(first.ok).toBe(true);
207
+
208
+ // Reusing the initial (now rotated) refresh token triggers replay detection
209
+ const replay = rotateCredentials({
210
+ refreshToken: initial.refreshToken,
211
+ platform: 'ios',
212
+ deviceId: 'device-replay',
213
+ });
214
+ expect(replay.ok).toBe(false);
215
+ if (!replay.ok) {
216
+ expect(replay.error).toBe('refresh_reuse_detected');
217
+ }
218
+ });
219
+
220
+ test('invalid refresh token returns refresh_invalid', () => {
221
+ const result = rotateCredentials({
222
+ refreshToken: 'not-a-real-token',
223
+ platform: 'ios',
224
+ deviceId: 'device-bad',
225
+ });
226
+
227
+ expect(result.ok).toBe(false);
228
+ if (!result.ok) {
229
+ expect(result.error).toBe('refresh_invalid');
230
+ }
231
+ });
232
+
233
+ test('device binding mismatch returns device_binding_mismatch', () => {
234
+ const hashedDeviceId = createHash('sha256').update('device-bind').digest('hex');
235
+ const initial = mintCredentialPair({
236
+ assistantId: 'self',
237
+ platform: 'ios',
238
+ deviceId: 'device-bind',
239
+ guardianPrincipalId: 'principal-bind',
240
+ hashedDeviceId,
241
+ });
242
+
243
+ // Try to rotate with a different device ID
244
+ const result = rotateCredentials({
245
+ refreshToken: initial.refreshToken,
246
+ platform: 'ios',
247
+ deviceId: 'different-device',
248
+ });
249
+
250
+ expect(result.ok).toBe(false);
251
+ if (!result.ok) {
252
+ expect(result.error).toBe('device_binding_mismatch');
253
+ }
254
+ });
255
+
256
+ test('platform mismatch returns device_binding_mismatch', () => {
257
+ const hashedDeviceId = createHash('sha256').update('device-plat').digest('hex');
258
+ const initial = mintCredentialPair({
259
+ assistantId: 'self',
260
+ platform: 'ios',
261
+ deviceId: 'device-plat',
262
+ guardianPrincipalId: 'principal-plat',
263
+ hashedDeviceId,
264
+ });
265
+
266
+ const result = rotateCredentials({
267
+ refreshToken: initial.refreshToken,
268
+ platform: 'macos',
269
+ deviceId: 'device-plat',
270
+ });
271
+
272
+ expect(result.ok).toBe(false);
273
+ if (!result.ok) {
274
+ expect(result.error).toBe('device_binding_mismatch');
275
+ }
276
+ });
277
+ });