@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
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Tests for route policy enforcement (enforcePolicy).
3
+ *
4
+ * Covers:
5
+ * - Unregistered endpoints return null (allowed)
6
+ * - Principal type check denies disallowed types
7
+ * - Scope check denies missing scopes
8
+ * - Allowed requests return null
9
+ * - Channel inbound requires svc_gateway principal type
10
+ * - Dev bypass allows all requests through
11
+ */
12
+
13
+ import { describe, expect, mock, test } from 'bun:test';
14
+
15
+ mock.module('../../../util/logger.js', () => ({
16
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
17
+ get: () => () => {},
18
+ }),
19
+ }));
20
+
21
+ // Track auth bypass state for tests
22
+ let authDisabled = false;
23
+ mock.module('../../../config/env.js', () => ({
24
+ isHttpAuthDisabled: () => authDisabled,
25
+ hasUngatedHttpAuthDisabled: () => false,
26
+ }));
27
+
28
+ import { enforcePolicy, getPolicy } from '../route-policy.js';
29
+ import type { AuthContext, Scope } from '../types.js';
30
+
31
+ /** Build a synthetic AuthContext for testing. */
32
+ function buildTestContext(overrides?: {
33
+ principalType?: AuthContext['principalType'];
34
+ scopes?: Scope[];
35
+ }): AuthContext {
36
+ return {
37
+ subject: 'actor:self:test-principal',
38
+ principalType: overrides?.principalType ?? 'actor',
39
+ assistantId: 'self',
40
+ actorPrincipalId: 'test-principal',
41
+ scopeProfile: 'actor_client_v1',
42
+ scopes: new Set(overrides?.scopes ?? ['chat.read', 'chat.write', 'approval.read', 'approval.write']),
43
+ policyEpoch: 1,
44
+ };
45
+ }
46
+
47
+ describe('enforcePolicy', () => {
48
+ test('returns null for unregistered endpoints (no policy)', () => {
49
+ authDisabled = false;
50
+ const ctx = buildTestContext();
51
+ const result = enforcePolicy('nonexistent/endpoint', ctx);
52
+ expect(result).toBeNull();
53
+ });
54
+
55
+ test('returns null when actor context has required scopes and type', () => {
56
+ authDisabled = false;
57
+ const ctx = buildTestContext({ scopes: ['chat.read', 'chat.write'] });
58
+ const result = enforcePolicy('messages:POST', ctx);
59
+ expect(result).toBeNull();
60
+ });
61
+
62
+ test('returns 403 when principal type is not allowed', () => {
63
+ authDisabled = false;
64
+ // channels/inbound requires svc_gateway, not actor
65
+ const ctx = buildTestContext({ principalType: 'actor', scopes: ['ingress.write'] });
66
+ const result = enforcePolicy('channels/inbound', ctx);
67
+ expect(result).not.toBeNull();
68
+ expect(result!.status).toBe(403);
69
+ const _body = result!.json();
70
+ // Response.json() returns a promise
71
+ });
72
+
73
+ test('returns 403 when required scope is missing', () => {
74
+ authDisabled = false;
75
+ // messages:POST requires chat.write, we only provide chat.read
76
+ const ctx = buildTestContext({ scopes: ['chat.read'] });
77
+ const result = enforcePolicy('messages:POST', ctx);
78
+ expect(result).not.toBeNull();
79
+ expect(result!.status).toBe(403);
80
+ });
81
+
82
+ test('channel inbound requires svc_gateway principal type', () => {
83
+ authDisabled = false;
84
+ const policy = getPolicy('channels/inbound');
85
+ expect(policy).toBeDefined();
86
+ expect(policy!.allowedPrincipalTypes).toContain('svc_gateway');
87
+ expect(policy!.allowedPrincipalTypes).not.toContain('actor');
88
+ expect(policy!.requiredScopes).toContain('ingress.write');
89
+ });
90
+
91
+ test('channel inbound allows svc_gateway with ingress.write', () => {
92
+ authDisabled = false;
93
+ const ctx = buildTestContext({
94
+ principalType: 'svc_gateway',
95
+ scopes: ['ingress.write', 'internal.write'],
96
+ });
97
+ const result = enforcePolicy('channels/inbound', ctx);
98
+ expect(result).toBeNull();
99
+ });
100
+
101
+ test('internal endpoints require svc_gateway principal type', () => {
102
+ authDisabled = false;
103
+ const policy = getPolicy('internal/twilio/voice-webhook');
104
+ expect(policy).toBeDefined();
105
+ expect(policy!.allowedPrincipalTypes).toContain('svc_gateway');
106
+ expect(policy!.allowedPrincipalTypes).not.toContain('actor');
107
+ expect(policy!.requiredScopes).toContain('internal.write');
108
+ });
109
+
110
+ test('internal endpoints deny actor principal type', () => {
111
+ authDisabled = false;
112
+ const ctx = buildTestContext({
113
+ principalType: 'actor',
114
+ scopes: ['internal.write'],
115
+ });
116
+ const result = enforcePolicy('internal/twilio/voice-webhook', ctx);
117
+ expect(result).not.toBeNull();
118
+ expect(result!.status).toBe(403);
119
+ });
120
+
121
+ test('standard actor endpoints allow actor, svc_gateway, and ipc', () => {
122
+ authDisabled = false;
123
+ const policy = getPolicy('messages:POST');
124
+ expect(policy).toBeDefined();
125
+ expect(policy!.allowedPrincipalTypes).toContain('actor');
126
+ expect(policy!.allowedPrincipalTypes).toContain('svc_gateway');
127
+ expect(policy!.allowedPrincipalTypes).toContain('ipc');
128
+ });
129
+
130
+ test('dev bypass allows all requests through regardless of policy', () => {
131
+ authDisabled = true;
132
+ // Actor trying to access channels/inbound (which requires svc_gateway)
133
+ const ctx = buildTestContext({ principalType: 'actor', scopes: [] });
134
+ const result = enforcePolicy('channels/inbound', ctx);
135
+ expect(result).toBeNull();
136
+ authDisabled = false;
137
+ });
138
+
139
+ test('approval endpoints require approval.write scope', () => {
140
+ authDisabled = false;
141
+ const policy = getPolicy('confirm');
142
+ expect(policy).toBeDefined();
143
+ expect(policy!.requiredScopes).toContain('approval.write');
144
+ });
145
+
146
+ test('guardian-actions/pending requires approval.read scope', () => {
147
+ authDisabled = false;
148
+ const policy = getPolicy('guardian-actions/pending');
149
+ expect(policy).toBeDefined();
150
+ expect(policy!.requiredScopes).toContain('approval.read');
151
+ });
152
+
153
+ test('guardian-actions/decision requires approval.write scope', () => {
154
+ authDisabled = false;
155
+ const policy = getPolicy('guardian-actions/decision');
156
+ expect(policy).toBeDefined();
157
+ expect(policy!.requiredScopes).toContain('approval.write');
158
+ });
159
+
160
+ test('events endpoint requires chat.read scope', () => {
161
+ authDisabled = false;
162
+ const policy = getPolicy('events');
163
+ expect(policy).toBeDefined();
164
+ expect(policy!.requiredScopes).toContain('chat.read');
165
+ });
166
+ });
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { hasAllScopes, hasScope, resolveScopeProfile } from '../scopes.js';
4
+ import type { AuthContext, Scope, ScopeProfile } from '../types.js';
5
+
6
+ /** Utility to create a minimal AuthContext with a given scope profile. */
7
+ function makeCtx(profile: ScopeProfile): AuthContext {
8
+ return {
9
+ subject: 'test:self:id',
10
+ principalType: 'actor',
11
+ assistantId: 'self',
12
+ scopeProfile: profile,
13
+ scopes: resolveScopeProfile(profile),
14
+ policyEpoch: 1,
15
+ };
16
+ }
17
+
18
+ describe('resolveScopeProfile', () => {
19
+ test('actor_client_v1 includes all client scopes', () => {
20
+ const scopes = resolveScopeProfile('actor_client_v1');
21
+ const expected: Scope[] = [
22
+ 'chat.read', 'chat.write',
23
+ 'approval.read', 'approval.write',
24
+ 'settings.read', 'settings.write',
25
+ 'attachments.read', 'attachments.write',
26
+ 'calls.read', 'calls.write',
27
+ 'feature_flags.read', 'feature_flags.write',
28
+ ];
29
+ for (const s of expected) {
30
+ expect(scopes.has(s)).toBe(true);
31
+ }
32
+ expect(scopes.size).toBe(expected.length);
33
+ });
34
+
35
+ test('actor_client_v1 does not include server-only scopes', () => {
36
+ const scopes = resolveScopeProfile('actor_client_v1');
37
+ expect(scopes.has('ingress.write')).toBe(false);
38
+ expect(scopes.has('internal.write')).toBe(false);
39
+ expect(scopes.has('ipc.all')).toBe(false);
40
+ });
41
+
42
+ test('gateway_ingress_v1 includes ingress and internal scopes', () => {
43
+ const scopes = resolveScopeProfile('gateway_ingress_v1');
44
+ expect(scopes.has('ingress.write')).toBe(true);
45
+ expect(scopes.has('internal.write')).toBe(true);
46
+ expect(scopes.size).toBe(2);
47
+ });
48
+
49
+ test('gateway_service_v1 includes chat, settings, attachments, and internal scopes', () => {
50
+ const scopes = resolveScopeProfile('gateway_service_v1');
51
+ expect(scopes.has('chat.write')).toBe(true);
52
+ expect(scopes.has('settings.read')).toBe(true);
53
+ expect(scopes.has('settings.write')).toBe(true);
54
+ expect(scopes.has('attachments.read')).toBe(true);
55
+ expect(scopes.has('attachments.write')).toBe(true);
56
+ expect(scopes.has('internal.write')).toBe(true);
57
+ expect(scopes.size).toBe(6);
58
+ });
59
+
60
+ test('ipc_v1 includes only ipc.all', () => {
61
+ const scopes = resolveScopeProfile('ipc_v1');
62
+ expect(scopes.has('ipc.all')).toBe(true);
63
+ expect(scopes.size).toBe(1);
64
+ });
65
+ });
66
+
67
+ describe('hasScope', () => {
68
+ test('returns true for a scope the profile includes', () => {
69
+ const ctx = makeCtx('actor_client_v1');
70
+ expect(hasScope(ctx, 'chat.read')).toBe(true);
71
+ });
72
+
73
+ test('returns false for a scope the profile excludes', () => {
74
+ const ctx = makeCtx('actor_client_v1');
75
+ expect(hasScope(ctx, 'ingress.write')).toBe(false);
76
+ });
77
+
78
+ test('returns true for ipc.all on ipc_v1 profile', () => {
79
+ const ctx = makeCtx('ipc_v1');
80
+ expect(hasScope(ctx, 'ipc.all')).toBe(true);
81
+ });
82
+ });
83
+
84
+ describe('hasAllScopes', () => {
85
+ test('returns true when all requested scopes are present', () => {
86
+ const ctx = makeCtx('actor_client_v1');
87
+ expect(hasAllScopes(ctx, 'chat.read', 'chat.write', 'approval.read')).toBe(true);
88
+ });
89
+
90
+ test('returns false when any requested scope is missing', () => {
91
+ const ctx = makeCtx('actor_client_v1');
92
+ expect(hasAllScopes(ctx, 'chat.read', 'ingress.write')).toBe(false);
93
+ });
94
+
95
+ test('returns true for empty scope list', () => {
96
+ const ctx = makeCtx('actor_client_v1');
97
+ expect(hasAllScopes(ctx)).toBe(true);
98
+ });
99
+
100
+ test('returns true for single present scope', () => {
101
+ const ctx = makeCtx('gateway_ingress_v1');
102
+ expect(hasAllScopes(ctx, 'ingress.write')).toBe(true);
103
+ });
104
+
105
+ test('returns false for single absent scope', () => {
106
+ const ctx = makeCtx('gateway_ingress_v1');
107
+ expect(hasAllScopes(ctx, 'chat.read')).toBe(false);
108
+ });
109
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { parseSub } from '../subject.js';
4
+
5
+ describe('parseSub', () => {
6
+ // -------------------------------------------------------------------------
7
+ // actor pattern
8
+ // -------------------------------------------------------------------------
9
+
10
+ test('parses actor:<assistantId>:<actorPrincipalId>', () => {
11
+ const result = parseSub('actor:self:principal-abc');
12
+ expect(result.ok).toBe(true);
13
+ if (result.ok) {
14
+ expect(result.principalType).toBe('actor');
15
+ expect(result.assistantId).toBe('self');
16
+ expect(result.actorPrincipalId).toBe('principal-abc');
17
+ expect(result.sessionId).toBeUndefined();
18
+ }
19
+ });
20
+
21
+ test('parses actor pattern with complex ids', () => {
22
+ const result = parseSub('actor:asst-uuid-123:principal-uuid-456');
23
+ expect(result.ok).toBe(true);
24
+ if (result.ok) {
25
+ expect(result.principalType).toBe('actor');
26
+ expect(result.assistantId).toBe('asst-uuid-123');
27
+ expect(result.actorPrincipalId).toBe('principal-uuid-456');
28
+ }
29
+ });
30
+
31
+ // -------------------------------------------------------------------------
32
+ // svc:gateway pattern
33
+ // -------------------------------------------------------------------------
34
+
35
+ test('parses svc:gateway:<assistantId>', () => {
36
+ const result = parseSub('svc:gateway:self');
37
+ expect(result.ok).toBe(true);
38
+ if (result.ok) {
39
+ expect(result.principalType).toBe('svc_gateway');
40
+ expect(result.assistantId).toBe('self');
41
+ expect(result.actorPrincipalId).toBeUndefined();
42
+ expect(result.sessionId).toBeUndefined();
43
+ }
44
+ });
45
+
46
+ // -------------------------------------------------------------------------
47
+ // ipc pattern
48
+ // -------------------------------------------------------------------------
49
+
50
+ test('parses ipc:<assistantId>:<sessionId>', () => {
51
+ const result = parseSub('ipc:self:session-xyz');
52
+ expect(result.ok).toBe(true);
53
+ if (result.ok) {
54
+ expect(result.principalType).toBe('ipc');
55
+ expect(result.assistantId).toBe('self');
56
+ expect(result.sessionId).toBe('session-xyz');
57
+ expect(result.actorPrincipalId).toBeUndefined();
58
+ }
59
+ });
60
+
61
+ // -------------------------------------------------------------------------
62
+ // Malformed input
63
+ // -------------------------------------------------------------------------
64
+
65
+ test('fails on empty string', () => {
66
+ const result = parseSub('');
67
+ expect(result.ok).toBe(false);
68
+ if (!result.ok) {
69
+ expect(result.reason).toContain('empty');
70
+ }
71
+ });
72
+
73
+ test('fails on unrecognized prefix', () => {
74
+ const result = parseSub('unknown:self:id');
75
+ expect(result.ok).toBe(false);
76
+ if (!result.ok) {
77
+ expect(result.reason).toContain('unrecognized');
78
+ }
79
+ });
80
+
81
+ test('fails on actor with too few parts', () => {
82
+ const result = parseSub('actor:self');
83
+ expect(result.ok).toBe(false);
84
+ if (!result.ok) {
85
+ expect(result.reason).toContain('unrecognized');
86
+ }
87
+ });
88
+
89
+ test('fails on actor with too many parts', () => {
90
+ const result = parseSub('actor:self:principal:extra');
91
+ expect(result.ok).toBe(false);
92
+ if (!result.ok) {
93
+ expect(result.reason).toContain('unrecognized');
94
+ }
95
+ });
96
+
97
+ test('fails on actor with empty assistantId', () => {
98
+ const result = parseSub('actor::principal-abc');
99
+ expect(result.ok).toBe(false);
100
+ if (!result.ok) {
101
+ expect(result.reason).toContain('empty');
102
+ }
103
+ });
104
+
105
+ test('fails on actor with empty actorPrincipalId', () => {
106
+ const result = parseSub('actor:self:');
107
+ expect(result.ok).toBe(false);
108
+ if (!result.ok) {
109
+ expect(result.reason).toContain('empty');
110
+ }
111
+ });
112
+
113
+ test('fails on svc:gateway with empty assistantId', () => {
114
+ const result = parseSub('svc:gateway:');
115
+ expect(result.ok).toBe(false);
116
+ if (!result.ok) {
117
+ expect(result.reason).toContain('empty');
118
+ }
119
+ });
120
+
121
+ test('fails on svc with wrong second part', () => {
122
+ const result = parseSub('svc:other:self');
123
+ expect(result.ok).toBe(false);
124
+ if (!result.ok) {
125
+ expect(result.reason).toContain('unrecognized');
126
+ }
127
+ });
128
+
129
+ test('fails on ipc with empty sessionId', () => {
130
+ const result = parseSub('ipc:self:');
131
+ expect(result.ok).toBe(false);
132
+ if (!result.ok) {
133
+ expect(result.reason).toContain('empty');
134
+ }
135
+ });
136
+
137
+ test('fails on ipc with empty assistantId', () => {
138
+ const result = parseSub('ipc::session-abc');
139
+ expect(result.ok).toBe(false);
140
+ if (!result.ok) {
141
+ expect(result.reason).toContain('empty');
142
+ }
143
+ });
144
+
145
+ test('fails on bare prefix with no colons', () => {
146
+ const result = parseSub('actor');
147
+ expect(result.ok).toBe(false);
148
+ });
149
+ });
@@ -0,0 +1,263 @@
1
+ import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+
7
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'auth-token-test-')));
8
+
9
+ mock.module('../../../util/platform.js', () => ({
10
+ getRootDir: () => testDir,
11
+ getDataDir: () => testDir,
12
+ getDbPath: () => join(testDir, 'test.db'),
13
+ normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
14
+ isMacOS: () => process.platform === 'darwin',
15
+ isLinux: () => process.platform === 'linux',
16
+ isWindows: () => process.platform === 'win32',
17
+ getSocketPath: () => join(testDir, 'test.sock'),
18
+ getPidPath: () => join(testDir, 'test.pid'),
19
+ getLogPath: () => join(testDir, 'test.log'),
20
+ ensureDataDir: () => {},
21
+ }));
22
+
23
+ mock.module('../../../util/logger.js', () => ({
24
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
25
+ get: () => () => {},
26
+ }),
27
+ }));
28
+
29
+ import { CURRENT_POLICY_EPOCH } from '../policy.js';
30
+ import { hashToken, initAuthSigningKey, mintToken, verifyToken } from '../token-service.js';
31
+
32
+ const TEST_KEY = Buffer.from('test-signing-key-32-bytes-long!!');
33
+
34
+ beforeEach(() => {
35
+ initAuthSigningKey(TEST_KEY);
36
+ });
37
+
38
+ afterAll(() => {
39
+ try { rmSync(testDir, { recursive: true, force: true }); } catch {}
40
+ });
41
+
42
+ describe('mintToken / verifyToken round-trip', () => {
43
+ test('mint + verify succeeds for valid token targeting vellum-daemon', () => {
44
+ const token = mintToken({
45
+ aud: 'vellum-daemon',
46
+ sub: 'actor:self:principal-abc',
47
+ scope_profile: 'actor_client_v1',
48
+ policy_epoch: CURRENT_POLICY_EPOCH,
49
+ ttlSeconds: 300,
50
+ });
51
+
52
+ expect(token).toBeTruthy();
53
+ expect(token.split('.').length).toBe(3);
54
+
55
+ const result = verifyToken(token, 'vellum-daemon');
56
+ expect(result.ok).toBe(true);
57
+ if (result.ok) {
58
+ expect(result.claims.iss).toBe('vellum-auth');
59
+ expect(result.claims.aud).toBe('vellum-daemon');
60
+ expect(result.claims.sub).toBe('actor:self:principal-abc');
61
+ expect(result.claims.scope_profile).toBe('actor_client_v1');
62
+ expect(result.claims.policy_epoch).toBe(CURRENT_POLICY_EPOCH);
63
+ expect(result.claims.iat).toBeDefined();
64
+ expect(result.claims.jti).toBeDefined();
65
+ }
66
+ });
67
+
68
+ test('mint + verify succeeds for gateway audience', () => {
69
+ const token = mintToken({
70
+ aud: 'vellum-gateway',
71
+ sub: 'svc:gateway:self',
72
+ scope_profile: 'gateway_ingress_v1',
73
+ policy_epoch: CURRENT_POLICY_EPOCH,
74
+ ttlSeconds: 60,
75
+ });
76
+
77
+ const result = verifyToken(token, 'vellum-gateway');
78
+ expect(result.ok).toBe(true);
79
+ if (result.ok) {
80
+ expect(result.claims.aud).toBe('vellum-gateway');
81
+ expect(result.claims.sub).toBe('svc:gateway:self');
82
+ }
83
+ });
84
+
85
+ test('each mint produces a unique jti', () => {
86
+ const params = {
87
+ aud: 'vellum-daemon' as const,
88
+ sub: 'actor:self:p1',
89
+ scope_profile: 'actor_client_v1' as const,
90
+ policy_epoch: CURRENT_POLICY_EPOCH,
91
+ ttlSeconds: 300,
92
+ };
93
+
94
+ const t1 = mintToken(params);
95
+ const t2 = mintToken(params);
96
+
97
+ const r1 = verifyToken(t1, 'vellum-daemon');
98
+ const r2 = verifyToken(t2, 'vellum-daemon');
99
+
100
+ expect(r1.ok).toBe(true);
101
+ expect(r2.ok).toBe(true);
102
+ if (r1.ok && r2.ok) {
103
+ expect(r1.claims.jti).not.toBe(r2.claims.jti);
104
+ }
105
+ });
106
+ });
107
+
108
+ describe('verifyToken failure cases', () => {
109
+ test('rejects expired token', () => {
110
+ const token = mintToken({
111
+ aud: 'vellum-daemon',
112
+ sub: 'actor:self:p1',
113
+ scope_profile: 'actor_client_v1',
114
+ policy_epoch: CURRENT_POLICY_EPOCH,
115
+ ttlSeconds: -10, // already expired
116
+ });
117
+
118
+ const result = verifyToken(token, 'vellum-daemon');
119
+ expect(result.ok).toBe(false);
120
+ if (!result.ok) {
121
+ expect(result.reason).toBe('token_expired');
122
+ }
123
+ });
124
+
125
+ test('rejects wrong audience', () => {
126
+ const token = mintToken({
127
+ aud: 'vellum-daemon',
128
+ sub: 'actor:self:p1',
129
+ scope_profile: 'actor_client_v1',
130
+ policy_epoch: CURRENT_POLICY_EPOCH,
131
+ ttlSeconds: 300,
132
+ });
133
+
134
+ const result = verifyToken(token, 'vellum-gateway');
135
+ expect(result.ok).toBe(false);
136
+ if (!result.ok) {
137
+ expect(result.reason).toContain('audience_mismatch');
138
+ }
139
+ });
140
+
141
+ test('rejects malformed token (no dots)', () => {
142
+ const result = verifyToken('not-a-jwt', 'vellum-daemon');
143
+ expect(result.ok).toBe(false);
144
+ if (!result.ok) {
145
+ expect(result.reason).toContain('malformed_token');
146
+ }
147
+ });
148
+
149
+ test('rejects malformed token (only 2 parts)', () => {
150
+ const result = verifyToken('part1.part2', 'vellum-daemon');
151
+ expect(result.ok).toBe(false);
152
+ if (!result.ok) {
153
+ expect(result.reason).toContain('malformed_token');
154
+ }
155
+ });
156
+
157
+ test('rejects tampered payload', () => {
158
+ const token = mintToken({
159
+ aud: 'vellum-daemon',
160
+ sub: 'actor:self:p1',
161
+ scope_profile: 'actor_client_v1',
162
+ policy_epoch: CURRENT_POLICY_EPOCH,
163
+ ttlSeconds: 300,
164
+ });
165
+
166
+ const parts = token.split('.');
167
+ // Tamper with the payload
168
+ parts[1] = parts[1] + 'X';
169
+ const tampered = parts.join('.');
170
+
171
+ const result = verifyToken(tampered, 'vellum-daemon');
172
+ expect(result.ok).toBe(false);
173
+ if (!result.ok) {
174
+ expect(result.reason).toBe('invalid_signature');
175
+ }
176
+ });
177
+
178
+ test('rejects tampered signature', () => {
179
+ const token = mintToken({
180
+ aud: 'vellum-daemon',
181
+ sub: 'actor:self:p1',
182
+ scope_profile: 'actor_client_v1',
183
+ policy_epoch: CURRENT_POLICY_EPOCH,
184
+ ttlSeconds: 300,
185
+ });
186
+
187
+ const parts = token.split('.');
188
+ parts[2] = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
189
+ const tampered = parts.join('.');
190
+
191
+ const result = verifyToken(tampered, 'vellum-daemon');
192
+ expect(result.ok).toBe(false);
193
+ if (!result.ok) {
194
+ expect(result.reason).toBe('invalid_signature');
195
+ }
196
+ });
197
+
198
+ test('rejects token with stale policy epoch', () => {
199
+ const token = mintToken({
200
+ aud: 'vellum-daemon',
201
+ sub: 'actor:self:p1',
202
+ scope_profile: 'actor_client_v1',
203
+ policy_epoch: 0, // stale
204
+ ttlSeconds: 300,
205
+ });
206
+
207
+ const result = verifyToken(token, 'vellum-daemon');
208
+ expect(result.ok).toBe(false);
209
+ if (!result.ok) {
210
+ expect(result.reason).toBe('stale_policy_epoch');
211
+ }
212
+ });
213
+
214
+ test('rejects token signed with a different key', () => {
215
+ // Mint with current key
216
+ const token = mintToken({
217
+ aud: 'vellum-daemon',
218
+ sub: 'actor:self:p1',
219
+ scope_profile: 'actor_client_v1',
220
+ policy_epoch: CURRENT_POLICY_EPOCH,
221
+ ttlSeconds: 300,
222
+ });
223
+
224
+ // Switch to a different key
225
+ initAuthSigningKey(Buffer.from('different-key-32-bytes-longXXXX'));
226
+
227
+ const result = verifyToken(token, 'vellum-daemon');
228
+ expect(result.ok).toBe(false);
229
+ if (!result.ok) {
230
+ expect(result.reason).toBe('invalid_signature');
231
+ }
232
+
233
+ // Restore original key for remaining tests
234
+ initAuthSigningKey(TEST_KEY);
235
+ });
236
+ });
237
+
238
+ describe('hashToken', () => {
239
+ test('produces consistent SHA-256 hex digest', () => {
240
+ const hash1 = hashToken('test-token');
241
+ const hash2 = hashToken('test-token');
242
+ expect(hash1).toBe(hash2);
243
+ expect(hash1.length).toBe(64);
244
+ });
245
+
246
+ test('different tokens produce different hashes', () => {
247
+ const t1 = mintToken({
248
+ aud: 'vellum-daemon',
249
+ sub: 'actor:self:p1',
250
+ scope_profile: 'actor_client_v1',
251
+ policy_epoch: CURRENT_POLICY_EPOCH,
252
+ ttlSeconds: 300,
253
+ });
254
+ const t2 = mintToken({
255
+ aud: 'vellum-daemon',
256
+ sub: 'actor:self:p2',
257
+ scope_profile: 'actor_client_v1',
258
+ policy_epoch: CURRENT_POLICY_EPOCH,
259
+ ttlSeconds: 300,
260
+ });
261
+ expect(hashToken(t1)).not.toBe(hashToken(t2));
262
+ });
263
+ });