@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
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Tests for actor-token mint/verify service, hash-only storage,
3
- * guardian bootstrap endpoint idempotency, HTTP middleware strict
4
- * enforcement, and local IPC identity fallback.
2
+ * Tests for JWT credential service, hash-only storage,
3
+ * guardian bootstrap endpoint idempotency, and pairing flow.
4
+ *
5
+ * Legacy actor-token HMAC middleware tests have been removed --
6
+ * that middleware is replaced by the JWT auth middleware in
7
+ * runtime/auth/middleware.ts (tested in auth/middleware.test.ts).
5
8
  */
6
9
  import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
7
10
  import { tmpdir } from 'node:os';
@@ -16,6 +19,7 @@ mock.module('../util/platform.js', () => ({
16
19
  getDataDir: () => testDir,
17
20
  getDbPath: () => join(testDir, 'test.db'),
18
21
  normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
22
+ readLockfile: () => null,
19
23
  isMacOS: () => process.platform === 'darwin',
20
24
  isLinux: () => process.platform === 'linux',
21
25
  isWindows: () => process.platform === 'win32',
@@ -36,12 +40,6 @@ import {
36
40
  createBinding,
37
41
  getActiveBinding,
38
42
  } from '../memory/guardian-bindings.js';
39
- import {
40
- hashToken,
41
- initSigningKey,
42
- mintActorToken,
43
- verifyActorToken,
44
- } from '../runtime/actor-token-service.js';
45
43
  import {
46
44
  createActorTokenRecord,
47
45
  findActiveByDeviceBinding,
@@ -49,20 +47,26 @@ import {
49
47
  revokeByDeviceBinding,
50
48
  revokeByTokenHash,
51
49
  } from '../runtime/actor-token-store.js';
50
+ import { resetExternalAssistantIdCache } from '../runtime/auth/external-assistant-id.js';
51
+ import { hashToken, initAuthSigningKey } from '../runtime/auth/token-service.js';
52
52
  import { ensureVellumGuardianBinding } from '../runtime/guardian-vellum-migration.js';
53
- import { resolveLocalIpcGuardianContext } from '../runtime/local-actor-identity.js';
54
- import {
55
- isActorBoundGuardian,
56
- isLocalFallbackBoundGuardian,
57
- type ServerWithRequestIP,
58
- verifyHttpActorToken,
59
- verifyHttpActorTokenWithLocalFallback,
60
- } from '../runtime/middleware/actor-token.js';
53
+ import { resolveLocalIpcAuthContext, resolveLocalIpcGuardianContext } from '../runtime/local-actor-identity.js';
61
54
 
62
55
  // ---------------------------------------------------------------------------
63
- // Mock server helpers for loopback IP checks
56
+ // Test signing key
64
57
  // ---------------------------------------------------------------------------
65
58
 
59
+ const TEST_KEY = Buffer.from('test-signing-key-32-bytes-long!!');
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Mock server helpers for loopback IP checks (used by bootstrap tests)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** Bun server shape needed for requestIP. */
66
+ type ServerWithRequestIP = {
67
+ requestIP(req: Request): { address: string; family: string; port: number } | null;
68
+ };
69
+
66
70
  /** Creates a mock server that returns the given IP for any request. */
67
71
  function mockServer(address: string): ServerWithRequestIP {
68
72
  return {
@@ -70,20 +74,20 @@ function mockServer(address: string): ServerWithRequestIP {
70
74
  };
71
75
  }
72
76
 
73
- /** Mock loopback server returns 127.0.0.1 for all requests. */
77
+ /** Mock loopback server -- returns 127.0.0.1 for all requests. */
74
78
  const loopbackServer = mockServer('127.0.0.1');
75
79
 
76
- /** Mock non-loopback server returns a LAN IP for all requests. */
80
+ /** Mock non-loopback server -- returns a LAN IP for all requests. */
77
81
  const nonLoopbackServer = mockServer('192.168.1.50');
78
82
 
79
83
  initializeDb();
80
84
 
81
85
  beforeEach(() => {
82
- // Reset the signing key to a deterministic value for reproducibility
83
- initSigningKey(Buffer.from('test-signing-key-32-bytes-long!!'));
84
- // Clear DB state between tests. resetDb closes the connection; initializeDb
85
- // re-opens it and ensures tables exist. We then truncate tables that carry
86
- // state across tests so each test starts from a clean slate.
86
+ // Initialize signing key for JWT verification
87
+ initAuthSigningKey(TEST_KEY);
88
+ // Reset the external assistant ID cache so tests don't leak state
89
+ resetExternalAssistantIdCache();
90
+ // Clear DB state between tests.
87
91
  resetDb();
88
92
  initializeDb();
89
93
  const db = getSqlite();
@@ -95,136 +99,13 @@ afterAll(() => {
95
99
  try { rmSync(testDir, { recursive: true, force: true }); } catch {}
96
100
  });
97
101
 
98
- // ---------------------------------------------------------------------------
99
- // Actor token mint/verify
100
- // ---------------------------------------------------------------------------
101
-
102
- describe('actor-token mint/verify', () => {
103
- test('mint returns token, hash, and claims with default 30-day TTL', () => {
104
- const result = mintActorToken({
105
- assistantId: 'self',
106
- platform: 'macos',
107
- deviceId: 'device-123',
108
- guardianPrincipalId: 'principal-abc',
109
- });
110
-
111
- expect(result.token).toBeTruthy();
112
- expect(result.tokenHash).toBeTruthy();
113
- expect(result.claims.assistantId).toBe('self');
114
- expect(result.claims.platform).toBe('macos');
115
- expect(result.claims.deviceId).toBe('device-123');
116
- expect(result.claims.guardianPrincipalId).toBe('principal-abc');
117
- expect(result.claims.iat).toBeGreaterThan(0);
118
- // Default TTL is 30 days
119
- expect(result.claims.exp).not.toBeNull();
120
- const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
121
- expect(result.claims.exp! - result.claims.iat).toBe(thirtyDaysMs);
122
- expect(result.claims.jti).toBeTruthy();
123
- });
124
-
125
- test('mint returns non-expiring token when ttlMs is explicitly null', () => {
126
- const result = mintActorToken({
127
- assistantId: 'self',
128
- platform: 'macos',
129
- deviceId: 'device-no-exp',
130
- guardianPrincipalId: 'principal-no-exp',
131
- ttlMs: null,
132
- });
133
-
134
- expect(result.claims.exp).toBeNull();
135
- });
136
-
137
- test('verify succeeds for valid token', () => {
138
- const { token } = mintActorToken({
139
- assistantId: 'self',
140
- platform: 'ios',
141
- deviceId: 'device-456',
142
- guardianPrincipalId: 'principal-def',
143
- });
144
-
145
- const result = verifyActorToken(token);
146
- expect(result.ok).toBe(true);
147
- if (result.ok) {
148
- expect(result.claims.assistantId).toBe('self');
149
- expect(result.claims.platform).toBe('ios');
150
- }
151
- });
152
-
153
- test('verify fails for tampered token', () => {
154
- const { token } = mintActorToken({
155
- assistantId: 'self',
156
- platform: 'macos',
157
- deviceId: 'device-789',
158
- guardianPrincipalId: 'principal-ghi',
159
- });
160
-
161
- // Tamper with the payload
162
- const parts = token.split('.');
163
- const tampered = parts[0] + 'X' + '.' + parts[1];
164
- const result = verifyActorToken(tampered);
165
- expect(result.ok).toBe(false);
166
- });
167
-
168
- test('verify fails for malformed token', () => {
169
- const result = verifyActorToken('not-a-valid-token');
170
- expect(result.ok).toBe(false);
171
- if (!result.ok) {
172
- expect(result.reason).toBe('malformed_token');
173
- }
174
- });
175
-
176
- test('verify fails for expired token', () => {
177
- const { token } = mintActorToken({
178
- assistantId: 'self',
179
- platform: 'macos',
180
- deviceId: 'device-exp',
181
- guardianPrincipalId: 'principal-exp',
182
- ttlMs: -1000, // Already expired
183
- });
184
-
185
- const result = verifyActorToken(token);
186
- expect(result.ok).toBe(false);
187
- if (!result.ok) {
188
- expect(result.reason).toBe('token_expired');
189
- }
190
- });
191
-
192
- test('hashToken produces consistent SHA-256 hex', () => {
193
- const hash1 = hashToken('test-token');
194
- const hash2 = hashToken('test-token');
195
- expect(hash1).toBe(hash2);
196
- expect(hash1.length).toBe(64); // SHA-256 hex = 64 chars
197
- });
198
-
199
- test('different tokens produce different hashes', () => {
200
- const { token: t1 } = mintActorToken({
201
- assistantId: 'self',
202
- platform: 'macos',
203
- deviceId: 'dev1',
204
- guardianPrincipalId: 'p1',
205
- });
206
- const { token: t2 } = mintActorToken({
207
- assistantId: 'self',
208
- platform: 'macos',
209
- deviceId: 'dev2',
210
- guardianPrincipalId: 'p2',
211
- });
212
- expect(hashToken(t1)).not.toBe(hashToken(t2));
213
- });
214
- });
215
-
216
102
  // ---------------------------------------------------------------------------
217
103
  // Hash-only storage
218
104
  // ---------------------------------------------------------------------------
219
105
 
220
106
  describe('actor-token store (hash-only)', () => {
221
107
  test('createActorTokenRecord stores hash, never raw token', () => {
222
- const { tokenHash } = mintActorToken({
223
- assistantId: 'self',
224
- platform: 'macos',
225
- deviceId: 'dev-store',
226
- guardianPrincipalId: 'principal-store',
227
- });
108
+ const tokenHash = hashToken('test-token-for-store');
228
109
 
229
110
  const record = createActorTokenRecord({
230
111
  tokenHash,
@@ -237,19 +118,13 @@ describe('actor-token store (hash-only)', () => {
237
118
 
238
119
  expect(record.tokenHash).toBe(tokenHash);
239
120
  expect(record.status).toBe('active');
240
- // Verify the record can be found by hash
241
121
  const found = findActiveByTokenHash(tokenHash);
242
122
  expect(found).not.toBeNull();
243
123
  expect(found!.tokenHash).toBe(tokenHash);
244
124
  });
245
125
 
246
126
  test('findActiveByDeviceBinding returns matching record', () => {
247
- const { tokenHash } = mintActorToken({
248
- assistantId: 'self',
249
- platform: 'ios',
250
- deviceId: 'dev-bind',
251
- guardianPrincipalId: 'principal-bind',
252
- });
127
+ const tokenHash = hashToken('test-token-for-binding');
253
128
 
254
129
  createActorTokenRecord({
255
130
  tokenHash,
@@ -266,12 +141,7 @@ describe('actor-token store (hash-only)', () => {
266
141
  });
267
142
 
268
143
  test('revokeByDeviceBinding marks tokens as revoked', () => {
269
- const { tokenHash } = mintActorToken({
270
- assistantId: 'self',
271
- platform: 'macos',
272
- deviceId: 'dev-revoke',
273
- guardianPrincipalId: 'principal-revoke',
274
- });
144
+ const tokenHash = hashToken('test-token-for-revoke');
275
145
 
276
146
  createActorTokenRecord({
277
147
  tokenHash,
@@ -285,18 +155,12 @@ describe('actor-token store (hash-only)', () => {
285
155
  const count = revokeByDeviceBinding('self', 'principal-revoke', 'hashed-dev-revoke');
286
156
  expect(count).toBe(1);
287
157
 
288
- // Should no longer be found as active
289
158
  const found = findActiveByTokenHash(tokenHash);
290
159
  expect(found).toBeNull();
291
160
  });
292
161
 
293
162
  test('revokeByTokenHash revokes a single token', () => {
294
- const { tokenHash } = mintActorToken({
295
- assistantId: 'self',
296
- platform: 'macos',
297
- deviceId: 'dev-single',
298
- guardianPrincipalId: 'principal-single',
299
- });
163
+ const tokenHash = hashToken('test-token-for-single-revoke');
300
164
 
301
165
  createActorTokenRecord({
302
166
  tokenHash,
@@ -334,7 +198,6 @@ describe('guardian vellum migration', () => {
334
198
  });
335
199
 
336
200
  test('ensureVellumGuardianBinding preserves existing bindings for other channels', () => {
337
- // Create a telegram binding
338
201
  createBinding({
339
202
  assistantId: 'self',
340
203
  channel: 'telegram',
@@ -344,15 +207,12 @@ describe('guardian vellum migration', () => {
344
207
  verifiedVia: 'challenge',
345
208
  });
346
209
 
347
- // Now backfill vellum
348
210
  ensureVellumGuardianBinding('self');
349
211
 
350
- // Telegram binding should still exist
351
212
  const tgBinding = getActiveBinding('self', 'telegram');
352
213
  expect(tgBinding).not.toBeNull();
353
214
  expect(tgBinding!.guardianExternalUserId).toBe('tg-user-123');
354
215
 
355
- // Vellum binding should also exist
356
216
  const vBinding = getActiveBinding('self', 'vellum');
357
217
  expect(vBinding).not.toBeNull();
358
218
  });
@@ -364,8 +224,6 @@ describe('guardian vellum migration', () => {
364
224
 
365
225
  describe('bootstrap endpoint idempotency', () => {
366
226
  test('calling bootstrap twice returns same guardianPrincipalId', async () => {
367
- // We test the logic used by the bootstrap route handler directly
368
- // rather than spinning up a full HTTP server.
369
227
  const { handleGuardianBootstrap } = await import('../runtime/routes/guardian-bootstrap-routes.js');
370
228
 
371
229
  const req1 = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
@@ -378,7 +236,7 @@ describe('bootstrap endpoint idempotency', () => {
378
236
  expect(res1.status).toBe(200);
379
237
  const body1 = await res1.json() as Record<string, unknown>;
380
238
  expect(body1.guardianPrincipalId).toBeTruthy();
381
- expect(body1.actorToken).toBeTruthy();
239
+ expect(body1.accessToken).toBeTruthy();
382
240
  expect(body1.isNew).toBe(true);
383
241
 
384
242
  // Second call with same device
@@ -392,9 +250,9 @@ describe('bootstrap endpoint idempotency', () => {
392
250
  expect(res2.status).toBe(200);
393
251
  const body2 = await res2.json() as Record<string, unknown>;
394
252
  expect(body2.guardianPrincipalId).toBe(body1.guardianPrincipalId);
395
- expect(body2.actorToken).toBeTruthy();
253
+ expect(body2.accessToken).toBeTruthy();
396
254
  // New token minted (previous revoked), but same principal
397
- expect(body2.actorToken).not.toBe(body1.actorToken);
255
+ expect(body2.accessToken).not.toBe(body1.accessToken);
398
256
  expect(body2.isNew).toBe(false);
399
257
  });
400
258
 
@@ -447,196 +305,25 @@ describe('bootstrap endpoint idempotency', () => {
447
305
 
448
306
  // Same principal, different tokens
449
307
  expect(body2.guardianPrincipalId).toBe(body1.guardianPrincipalId);
450
- expect(body2.actorToken).not.toBe(body1.actorToken);
308
+ expect(body2.accessToken).not.toBe(body1.accessToken);
451
309
  });
452
- });
453
310
 
454
- // ---------------------------------------------------------------------------
455
- // HTTP middleware strict enforcement
456
- // ---------------------------------------------------------------------------
457
-
458
- describe('HTTP actor token middleware (strict enforcement)', () => {
459
- test('rejects request without X-Actor-Token header', () => {
460
- const req = new Request('http://localhost/v1/messages', {
461
- method: 'POST',
462
- headers: { 'Content-Type': 'application/json' },
463
- });
464
-
465
- const result = verifyHttpActorToken(req);
466
- expect(result.ok).toBe(false);
467
- if (!result.ok) {
468
- expect(result.status).toBe(401);
469
- expect(result.message).toContain('Missing X-Actor-Token');
470
- }
471
- });
472
-
473
- test('rejects request with invalid (tampered) token', () => {
474
- const { token } = mintActorToken({
475
- assistantId: 'self',
476
- platform: 'macos',
477
- deviceId: 'device-tamper',
478
- guardianPrincipalId: 'principal-tamper',
479
- });
480
-
481
- const parts = token.split('.');
482
- const tampered = parts[0] + 'XXXXXX.' + parts[1];
483
-
484
- const req = new Request('http://localhost/v1/messages', {
485
- method: 'POST',
486
- headers: { 'X-Actor-Token': tampered },
487
- });
488
-
489
- const result = verifyHttpActorToken(req);
490
- expect(result.ok).toBe(false);
491
- if (!result.ok) {
492
- expect(result.status).toBe(401);
493
- }
494
- });
495
-
496
- test('rejects request with revoked token', () => {
497
- const principalId = ensureVellumGuardianBinding('self');
498
- const { token, tokenHash } = mintActorToken({
499
- assistantId: 'self',
500
- platform: 'macos',
501
- deviceId: 'device-revoked',
502
- guardianPrincipalId: principalId,
503
- });
504
-
505
- createActorTokenRecord({
506
- tokenHash,
507
- assistantId: 'self',
508
- guardianPrincipalId: principalId,
509
- hashedDeviceId: 'hashed-device-revoked',
510
- platform: 'macos',
511
- issuedAt: Date.now(),
512
- });
513
-
514
- // Revoke the token
515
- revokeByTokenHash(tokenHash);
516
-
517
- const req = new Request('http://localhost/v1/messages', {
518
- method: 'POST',
519
- headers: { 'X-Actor-Token': token },
520
- });
521
-
522
- const result = verifyHttpActorToken(req);
523
- expect(result.ok).toBe(false);
524
- if (!result.ok) {
525
- expect(result.status).toBe(401);
526
- expect(result.message).toContain('no longer active');
527
- }
528
- });
529
-
530
- test('accepts request with valid active token and resolves guardian context', () => {
531
- const principalId = ensureVellumGuardianBinding('self');
532
- const { token, tokenHash } = mintActorToken({
533
- assistantId: 'self',
534
- platform: 'macos',
535
- deviceId: 'device-valid',
536
- guardianPrincipalId: principalId,
537
- });
538
-
539
- createActorTokenRecord({
540
- tokenHash,
541
- assistantId: 'self',
542
- guardianPrincipalId: principalId,
543
- hashedDeviceId: 'hashed-device-valid',
544
- platform: 'macos',
545
- issuedAt: Date.now(),
546
- });
547
-
548
- const req = new Request('http://localhost/v1/messages', {
549
- method: 'POST',
550
- headers: { 'X-Actor-Token': token },
551
- });
552
-
553
- const result = verifyHttpActorToken(req);
554
- expect(result.ok).toBe(true);
555
- if (result.ok) {
556
- expect(result.claims.assistantId).toBe('self');
557
- expect(result.claims.guardianPrincipalId).toBe(principalId);
558
- expect(result.guardianContext).toBeTruthy();
559
- expect(result.guardianContext.trustClass).toBe('guardian');
560
- }
561
- });
562
- });
563
-
564
- // ---------------------------------------------------------------------------
565
- // Local IPC fallback (verifyHttpActorTokenWithLocalFallback)
566
- // ---------------------------------------------------------------------------
567
-
568
- describe('HTTP actor token local fallback', () => {
569
- test('falls back to local IPC identity when no actor token and no forwarding header', () => {
570
- ensureVellumGuardianBinding('self');
311
+ test('bootstrap access token is a 3-part JWT', async () => {
312
+ const { handleGuardianBootstrap } = await import('../runtime/routes/guardian-bootstrap-routes.js');
571
313
 
572
- const req = new Request('http://localhost/v1/messages', {
314
+ const req = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
573
315
  method: 'POST',
574
316
  headers: { 'Content-Type': 'application/json' },
317
+ body: JSON.stringify({ platform: 'macos', deviceId: 'test-device-jwt' }),
575
318
  });
576
319
 
577
- const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
578
- expect(result.ok).toBe(true);
579
- if (result.ok) {
580
- expect(result.guardianContext.trustClass).toBe('guardian');
581
- // localFallback should be true when claims are null
582
- if ('localFallback' in result) {
583
- expect(result.localFallback).toBe(true);
584
- expect(result.claims).toBeNull();
585
- }
586
- }
587
- });
588
-
589
- test('rejects gateway-proxied request without actor token (X-Forwarded-For present)', () => {
590
- ensureVellumGuardianBinding('self');
591
-
592
- const req = new Request('http://localhost/v1/messages', {
593
- method: 'POST',
594
- headers: {
595
- 'Content-Type': 'application/json',
596
- 'X-Forwarded-For': '1.2.3.4',
597
- },
598
- });
599
-
600
- const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
601
- expect(result.ok).toBe(false);
602
- if (!result.ok) {
603
- expect(result.status).toBe(401);
604
- expect(result.message).toContain('Proxied requests require actor identity');
605
- }
606
- });
607
-
608
- test('uses strict verification when actor token is present even with X-Forwarded-For', () => {
609
- const principalId = ensureVellumGuardianBinding('self');
610
- const { token, tokenHash } = mintActorToken({
611
- assistantId: 'self',
612
- platform: 'ios',
613
- deviceId: 'device-proxied',
614
- guardianPrincipalId: principalId,
615
- });
616
-
617
- createActorTokenRecord({
618
- tokenHash,
619
- assistantId: 'self',
620
- guardianPrincipalId: principalId,
621
- hashedDeviceId: 'hashed-device-proxied',
622
- platform: 'ios',
623
- issuedAt: Date.now(),
624
- });
625
-
626
- const req = new Request('http://localhost/v1/messages', {
627
- method: 'POST',
628
- headers: {
629
- 'X-Actor-Token': token,
630
- 'X-Forwarded-For': '1.2.3.4',
631
- },
632
- });
633
-
634
- const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
635
- expect(result.ok).toBe(true);
636
- if (result.ok) {
637
- expect(result.claims).not.toBeNull();
638
- expect(result.guardianContext.trustClass).toBe('guardian');
639
- }
320
+ const res = await handleGuardianBootstrap(req, loopbackServer);
321
+ expect(res.status).toBe(200);
322
+ const body = await res.json() as Record<string, unknown>;
323
+ const accessToken = body.accessToken as string;
324
+ expect(accessToken).toBeTruthy();
325
+ // JWTs have 3 dot-separated parts
326
+ expect(accessToken.split('.').length).toBe(3);
640
327
  });
641
328
  });
642
329
 
@@ -654,9 +341,6 @@ describe('resolveLocalIpcGuardianContext', () => {
654
341
  });
655
342
 
656
343
  test('returns guardian context with principal when no vellum binding exists (pre-bootstrap self-heal)', () => {
657
- // No binding created — fresh DB state. Pre-bootstrap path self-heals
658
- // by creating a vellum binding, then resolves through the shared pipeline
659
- // with correct field names (conversationExternalId, actorExternalId).
660
344
  const ctx = resolveLocalIpcGuardianContext();
661
345
  expect(ctx.trustClass).toBe('guardian');
662
346
  expect(ctx.sourceChannel).toBe('vellum');
@@ -670,13 +354,63 @@ describe('resolveLocalIpcGuardianContext', () => {
670
354
  });
671
355
  });
672
356
 
357
+ // ---------------------------------------------------------------------------
358
+ // Local IPC AuthContext resolution
359
+ // ---------------------------------------------------------------------------
360
+
361
+ describe('resolveLocalIpcAuthContext', () => {
362
+ test('returns AuthContext with ipc principal type', () => {
363
+ const ctx = resolveLocalIpcAuthContext('session-123');
364
+ expect(ctx.principalType).toBe('ipc');
365
+ });
366
+
367
+ test('subject follows ipc:self:<sessionId> pattern', () => {
368
+ const ctx = resolveLocalIpcAuthContext('session-abc');
369
+ expect(ctx.subject).toBe('ipc:self:session-abc');
370
+ });
371
+
372
+ test('assistantId is always self', () => {
373
+ const ctx = resolveLocalIpcAuthContext('session-123');
374
+ expect(ctx.assistantId).toBe('self');
375
+ });
376
+
377
+ test('uses ipc_v1 scope profile with ipc.all scope', () => {
378
+ const ctx = resolveLocalIpcAuthContext('session-123');
379
+ expect(ctx.scopeProfile).toBe('ipc_v1');
380
+ expect(ctx.scopes.has('ipc.all')).toBe(true);
381
+ });
382
+
383
+ test('enriches actorPrincipalId from vellum guardian binding when present', () => {
384
+ ensureVellumGuardianBinding('self');
385
+ const binding = getActiveBinding('self', 'vellum');
386
+ expect(binding).toBeTruthy();
387
+
388
+ const ctx = resolveLocalIpcAuthContext('session-123');
389
+ expect(ctx.actorPrincipalId).toBe(binding!.guardianExternalUserId);
390
+ });
391
+
392
+ test('actorPrincipalId is undefined when no vellum binding exists', () => {
393
+ // Reset DB to ensure no binding
394
+ resetDb();
395
+ initializeDb();
396
+
397
+ const ctx = resolveLocalIpcAuthContext('session-123');
398
+ // When no binding exists, actorPrincipalId is not set
399
+ expect(ctx.actorPrincipalId).toBeUndefined();
400
+ });
401
+
402
+ test('sessionId matches the provided argument', () => {
403
+ const ctx = resolveLocalIpcAuthContext('my-session');
404
+ expect(ctx.sessionId).toBe('my-session');
405
+ });
406
+ });
407
+
673
408
  // ---------------------------------------------------------------------------
674
409
  // Pairing actor-token flow
675
410
  // ---------------------------------------------------------------------------
676
411
 
677
- describe('pairing actor-token flow', () => {
678
- test('mintPairingActorToken returns actor token in approved pairing status poll', async () => {
679
- // Set up a vellum guardian binding (required for pairing token mint)
412
+ describe('pairing credential flow', () => {
413
+ test('mintPairingCredentials returns access token in approved pairing status poll', async () => {
680
414
  ensureVellumGuardianBinding('self');
681
415
 
682
416
  const { PairingStore } = await import('../daemon/pairing-store.js');
@@ -689,7 +423,6 @@ describe('pairing actor-token flow', () => {
689
423
  const pairingSecret = 'test-secret-123';
690
424
  const bearerToken = 'test-bearer';
691
425
 
692
- // Register a pairing request
693
426
  store.register({
694
427
  pairingRequestId,
695
428
  pairingSecret,
@@ -703,7 +436,6 @@ describe('pairing actor-token flow', () => {
703
436
  pairingBroadcast: () => {},
704
437
  };
705
438
 
706
- // iOS initiates pairing
707
439
  const pairReq = new Request('http://localhost/v1/pairing/request', {
708
440
  method: 'POST',
709
441
  headers: { 'Content-Type': 'application/json' },
@@ -720,22 +452,20 @@ describe('pairing actor-token flow', () => {
720
452
  const pairBody = await pairRes.json() as Record<string, unknown>;
721
453
  expect(pairBody.status).toBe('pending');
722
454
 
723
- // macOS approves the pairing
724
455
  store.approve(pairingRequestId, bearerToken);
725
456
 
726
- // iOS polls for status — should get approved with actor token
727
457
  const statusUrl = new URL(`http://localhost/v1/pairing/status?id=${pairingRequestId}&secret=${pairingSecret}`);
728
458
  const statusRes = handlePairingStatus(statusUrl, ctx);
729
459
  expect(statusRes.status).toBe(200);
730
460
  const statusBody = await statusRes.json() as Record<string, unknown>;
731
461
  expect(statusBody.status).toBe('approved');
732
- expect(statusBody.actorToken).toBeTruthy();
462
+ expect(statusBody.accessToken).toBeTruthy();
733
463
  expect(statusBody.bearerToken).toBe(bearerToken);
734
464
 
735
465
  store.stop();
736
466
  });
737
467
 
738
- test('approved actor token is available within 5 min TTL window', async () => {
468
+ test('approved access token is available within 5 min TTL window', async () => {
739
469
  ensureVellumGuardianBinding('self');
740
470
 
741
471
  const { PairingStore } = await import('../daemon/pairing-store.js');
@@ -761,7 +491,6 @@ describe('pairing actor-token flow', () => {
761
491
  pairingBroadcast: () => {},
762
492
  };
763
493
 
764
- // iOS initiates pairing
765
494
  const pairReq = new Request('http://localhost/v1/pairing/request', {
766
495
  method: 'POST',
767
496
  headers: { 'Content-Type': 'application/json' },
@@ -776,17 +505,15 @@ describe('pairing actor-token flow', () => {
776
505
  await handlePairingRequest(pairReq, ctx);
777
506
  store.approve(pairingRequestId, bearerToken);
778
507
 
779
- // First poll — mints the token
780
508
  const statusUrl = new URL(`http://localhost/v1/pairing/status?id=${pairingRequestId}&secret=${pairingSecret}`);
781
509
  const firstRes = handlePairingStatus(statusUrl, ctx);
782
510
  const firstBody = await firstRes.json() as Record<string, unknown>;
783
- const firstToken = firstBody.actorToken as string;
511
+ const firstToken = firstBody.accessToken as string;
784
512
  expect(firstToken).toBeTruthy();
785
513
 
786
- // Second poll — same token from cache
787
514
  const secondRes = handlePairingStatus(statusUrl, ctx);
788
515
  const secondBody = await secondRes.json() as Record<string, unknown>;
789
- expect(secondBody.actorToken).toBe(firstToken);
516
+ expect(secondBody.accessToken).toBe(firstToken);
790
517
 
791
518
  store.stop();
792
519
  });
@@ -822,7 +549,6 @@ describe('pairing actor-token flow', () => {
822
549
  pairingBroadcast: () => {},
823
550
  };
824
551
 
825
- // iOS initiates pairing so the request is device-bound
826
552
  const pairReq = new Request('http://localhost/v1/pairing/request', {
827
553
  method: 'POST',
828
554
  headers: { 'Content-Type': 'application/json' },
@@ -837,11 +563,9 @@ describe('pairing actor-token flow', () => {
837
563
  const pairRes = await handlePairingRequest(pairReq, ctx);
838
564
  expect(pairRes.status).toBe(200);
839
565
 
840
- // macOS approves, then transient in-memory pairing state is lost (e.g. restart)
841
566
  store.approve(pairingRequestId, bearerToken);
842
567
  cleanupPairingState(pairingRequestId);
843
568
 
844
- // Poll includes deviceId so token mint can recover from persisted hashedDeviceId
845
569
  const statusUrl = new URL(
846
570
  `http://localhost/v1/pairing/status?id=${pairingRequestId}&secret=${pairingSecret}&deviceId=${encodeURIComponent(deviceId)}`,
847
571
  );
@@ -850,7 +574,7 @@ describe('pairing actor-token flow', () => {
850
574
  const statusBody = await statusRes.json() as Record<string, unknown>;
851
575
 
852
576
  expect(statusBody.status).toBe('approved');
853
- expect(statusBody.actorToken).toBeTruthy();
577
+ expect(statusBody.accessToken).toBeTruthy();
854
578
  expect(statusBody.bearerToken).toBe(bearerToken);
855
579
 
856
580
  store.stop();
@@ -896,8 +620,6 @@ describe('pairing actor-token flow', () => {
896
620
  await handlePairingRequest(pairReq, ctx);
897
621
  store.approve(pairingRequestId, bearerToken);
898
622
 
899
- // Fire two status polls simultaneously — both synchronous so they
900
- // should not double-mint
901
623
  const statusUrl = new URL(`http://localhost/v1/pairing/status?id=${pairingRequestId}&secret=${pairingSecret}`);
902
624
  const res1 = handlePairingStatus(statusUrl, ctx);
903
625
  const res2 = handlePairingStatus(statusUrl, ctx);
@@ -905,100 +627,15 @@ describe('pairing actor-token flow', () => {
905
627
  const body1 = await res1.json() as Record<string, unknown>;
906
628
  const body2 = await res2.json() as Record<string, unknown>;
907
629
 
908
- // Both should succeed and return the same token (second sees the cached token)
909
630
  expect(body1.status).toBe('approved');
910
631
  expect(body2.status).toBe('approved');
911
- expect(body1.actorToken).toBeTruthy();
912
- // The second poll should return the same cached token
913
- expect(body2.actorToken).toBe(body1.actorToken);
632
+ expect(body1.accessToken).toBeTruthy();
633
+ expect(body2.accessToken).toBe(body1.accessToken);
914
634
 
915
635
  store.stop();
916
636
  });
917
637
  });
918
638
 
919
- // ---------------------------------------------------------------------------
920
- // Loopback IP check tests
921
- // ---------------------------------------------------------------------------
922
-
923
- describe('loopback IP check (verifyHttpActorTokenWithLocalFallback)', () => {
924
- test('succeeds with mock server returning loopback IP', () => {
925
- ensureVellumGuardianBinding('self');
926
-
927
- const req = new Request('http://localhost/v1/messages', {
928
- method: 'POST',
929
- headers: { 'Content-Type': 'application/json' },
930
- });
931
-
932
- const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
933
- expect(result.ok).toBe(true);
934
- if (result.ok && 'localFallback' in result) {
935
- expect(result.localFallback).toBe(true);
936
- expect(result.guardianContext.trustClass).toBe('guardian');
937
- }
938
- });
939
-
940
- test('succeeds with mock server returning IPv6 loopback (::1)', () => {
941
- ensureVellumGuardianBinding('self');
942
-
943
- const req = new Request('http://localhost/v1/messages', {
944
- method: 'POST',
945
- headers: { 'Content-Type': 'application/json' },
946
- });
947
-
948
- const ipv6LoopbackServer = mockServer('::1');
949
- const result = verifyHttpActorTokenWithLocalFallback(req, ipv6LoopbackServer);
950
- expect(result.ok).toBe(true);
951
- });
952
-
953
- test('succeeds with mock server returning IPv4-mapped IPv6 loopback', () => {
954
- ensureVellumGuardianBinding('self');
955
-
956
- const req = new Request('http://localhost/v1/messages', {
957
- method: 'POST',
958
- headers: { 'Content-Type': 'application/json' },
959
- });
960
-
961
- const mappedLoopbackServer = mockServer('::ffff:127.0.0.1');
962
- const result = verifyHttpActorTokenWithLocalFallback(req, mappedLoopbackServer);
963
- expect(result.ok).toBe(true);
964
- });
965
-
966
- test('returns 401 with mock server returning non-loopback IP', () => {
967
- ensureVellumGuardianBinding('self');
968
-
969
- const req = new Request('http://localhost/v1/messages', {
970
- method: 'POST',
971
- headers: { 'Content-Type': 'application/json' },
972
- });
973
-
974
- const result = verifyHttpActorTokenWithLocalFallback(req, nonLoopbackServer);
975
- expect(result.ok).toBe(false);
976
- if (!result.ok) {
977
- expect(result.status).toBe(401);
978
- expect(result.message).toContain('Non-loopback requests require actor identity');
979
- }
980
- });
981
-
982
- test('returns 401 with X-Forwarded-For header present', () => {
983
- ensureVellumGuardianBinding('self');
984
-
985
- const req = new Request('http://localhost/v1/messages', {
986
- method: 'POST',
987
- headers: {
988
- 'Content-Type': 'application/json',
989
- 'X-Forwarded-For': '10.0.0.1',
990
- },
991
- });
992
-
993
- const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
994
- expect(result.ok).toBe(false);
995
- if (!result.ok) {
996
- expect(result.status).toBe(401);
997
- expect(result.message).toContain('Proxied requests require actor identity');
998
- }
999
- });
1000
- });
1001
-
1002
639
  // ---------------------------------------------------------------------------
1003
640
  // Bootstrap loopback guard tests
1004
641
  // ---------------------------------------------------------------------------
@@ -1050,54 +687,3 @@ describe('bootstrap loopback guard', () => {
1050
687
  expect(res.status).toBe(200);
1051
688
  });
1052
689
  });
1053
-
1054
- // ---------------------------------------------------------------------------
1055
- // Utility function tests (isActorBoundGuardian, isLocalFallbackBoundGuardian)
1056
- // ---------------------------------------------------------------------------
1057
-
1058
- describe('utility functions', () => {
1059
- test('isActorBoundGuardian returns true when actor matches bound guardian', () => {
1060
- const principalId = ensureVellumGuardianBinding('self');
1061
- const { claims } = mintActorToken({
1062
- assistantId: 'self',
1063
- platform: 'macos',
1064
- deviceId: 'device-bound',
1065
- guardianPrincipalId: principalId,
1066
- });
1067
-
1068
- expect(isActorBoundGuardian(claims)).toBe(true);
1069
- });
1070
-
1071
- test('isActorBoundGuardian returns false for mismatched principal', () => {
1072
- ensureVellumGuardianBinding('self');
1073
- const { claims } = mintActorToken({
1074
- assistantId: 'self',
1075
- platform: 'macos',
1076
- deviceId: 'device-mismatch',
1077
- guardianPrincipalId: 'wrong-principal-id',
1078
- });
1079
-
1080
- expect(isActorBoundGuardian(claims)).toBe(false);
1081
- });
1082
-
1083
- test('isActorBoundGuardian returns false when no vellum binding exists', () => {
1084
- const { claims } = mintActorToken({
1085
- assistantId: 'self',
1086
- platform: 'macos',
1087
- deviceId: 'device-no-binding',
1088
- guardianPrincipalId: 'some-principal',
1089
- });
1090
-
1091
- expect(isActorBoundGuardian(claims)).toBe(false);
1092
- });
1093
-
1094
- test('isLocalFallbackBoundGuardian returns true when vellum binding exists', () => {
1095
- ensureVellumGuardianBinding('self');
1096
- expect(isLocalFallbackBoundGuardian()).toBe(true);
1097
- });
1098
-
1099
- test('isLocalFallbackBoundGuardian returns true even without binding (pre-bootstrap fallback)', () => {
1100
- // No binding — local user is inherently the guardian of their own machine
1101
- expect(isLocalFallbackBoundGuardian()).toBe(true);
1102
- });
1103
- });