@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.
- package/ARCHITECTURE.md +77 -38
- package/README.md +10 -12
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +108 -522
- package/src/__tests__/channel-approval-routes.test.ts +92 -239
- package/src/__tests__/channel-approval.test.ts +100 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
- package/src/__tests__/conversation-routes.test.ts +11 -4
- package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
- package/src/__tests__/mcp-health-check.test.ts +65 -0
- package/src/__tests__/permission-types.test.ts +33 -0
- package/src/__tests__/scan-result-store.test.ts +121 -0
- package/src/__tests__/session-agent-loop.test.ts +120 -0
- package/src/__tests__/session-approval-overrides.test.ts +205 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
- package/src/amazon/client.ts +8 -5
- package/src/approvals/guardian-decision-primitive.ts +14 -9
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/calls/call-controller.ts +2 -2
- package/src/calls/twilio-routes.ts +2 -2
- package/src/cli/mcp.ts +3 -3
- package/src/cli.ts +24 -0
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
- package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
- package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +49 -14
- package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
- package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
- package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
- package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
- package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
- package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
- package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
- package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/approval-generators.ts +6 -3
- package/src/daemon/handlers/config-ingress.ts +2 -6
- package/src/daemon/handlers/guardian-actions.ts +1 -1
- package/src/daemon/handlers/sessions.ts +4 -1
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +32 -0
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-handler.ts +24 -0
- package/src/daemon/ipc-validate.ts +1 -1
- package/src/daemon/lifecycle.ts +6 -8
- package/src/daemon/server.ts +8 -3
- package/src/daemon/session-agent-loop.ts +19 -1
- package/src/daemon/session-attachments.ts +2 -1
- package/src/daemon/session-history.ts +2 -2
- package/src/daemon/session-process.ts +5 -9
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/daemon/session-tool-setup.ts +216 -69
- package/src/daemon/session.ts +24 -1
- package/src/events/domain-events.ts +1 -1
- package/src/events/tool-domain-event-publisher.ts +5 -10
- package/src/influencer/client.ts +8 -7
- package/src/messaging/providers/gmail/client.ts +33 -1
- package/src/messaging/providers/gmail/mime-builder.ts +5 -1
- package/src/messaging/providers/sms/adapter.ts +3 -7
- package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
- package/src/messaging/providers/whatsapp/adapter.ts +3 -7
- package/src/notifications/adapters/sms.ts +2 -2
- package/src/notifications/adapters/telegram.ts +2 -2
- package/src/permissions/prompter.ts +2 -0
- package/src/permissions/types.ts +11 -1
- package/src/runtime/approval-conversation-turn.ts +4 -0
- package/src/runtime/auth/__tests__/context.test.ts +130 -0
- package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
- package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
- package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
- package/src/runtime/auth/__tests__/policy.test.ts +29 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
- package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
- package/src/runtime/auth/__tests__/subject.test.ts +149 -0
- package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
- package/src/runtime/auth/context.ts +62 -0
- package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
- package/src/runtime/auth/external-assistant-id.ts +69 -0
- package/src/runtime/auth/index.ts +37 -0
- package/src/runtime/auth/middleware.ts +127 -0
- package/src/runtime/auth/policy.ts +17 -0
- package/src/runtime/auth/route-policy.ts +261 -0
- package/src/runtime/auth/scopes.ts +64 -0
- package/src/runtime/auth/subject.ts +68 -0
- package/src/runtime/auth/token-service.ts +275 -0
- package/src/runtime/auth/types.ts +79 -0
- package/src/runtime/channel-approval-parser.ts +11 -5
- package/src/runtime/channel-approval-types.ts +1 -1
- package/src/runtime/channel-approvals.ts +22 -1
- package/src/runtime/guardian-action-followup-executor.ts +2 -2
- package/src/runtime/guardian-context-resolver.ts +15 -0
- package/src/runtime/guardian-decision-types.ts +23 -6
- package/src/runtime/guardian-outbound-actions.ts +4 -22
- package/src/runtime/guardian-reply-router.ts +5 -3
- package/src/runtime/http-server.ts +210 -182
- package/src/runtime/http-types.ts +11 -1
- package/src/runtime/local-actor-identity.ts +25 -0
- package/src/runtime/pending-interactions.ts +1 -0
- package/src/runtime/routes/approval-routes.ts +42 -59
- package/src/runtime/routes/channel-route-shared.ts +9 -41
- package/src/runtime/routes/channel-routes.ts +0 -2
- package/src/runtime/routes/conversation-routes.ts +39 -49
- package/src/runtime/routes/events-routes.ts +15 -22
- package/src/runtime/routes/guardian-action-routes.ts +46 -51
- package/src/runtime/routes/guardian-approval-interception.ts +6 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
- package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +39 -45
- package/src/runtime/routes/pairing-routes.ts +9 -9
- package/src/runtime/routes/secret-routes.ts +90 -45
- package/src/runtime/routes/surface-action-routes.ts +12 -2
- package/src/runtime/routes/trust-rules-routes.ts +13 -0
- package/src/runtime/routes/twilio-routes.ts +3 -3
- package/src/runtime/session-approval-overrides.ts +86 -0
- package/src/security/keychain-to-encrypted-migration.ts +8 -1
- package/src/skills/frontmatter.ts +44 -1
- package/src/tools/permission-checker.ts +226 -74
- package/src/runtime/actor-token-service.ts +0 -234
- package/src/runtime/middleware/actor-token.ts +0 -265
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for
|
|
3
|
-
* guardian bootstrap endpoint idempotency,
|
|
4
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
// state
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
253
|
+
expect(body2.accessToken).toBeTruthy();
|
|
396
254
|
// New token minted (previous revoked), but same principal
|
|
397
|
-
expect(body2.
|
|
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.
|
|
308
|
+
expect(body2.accessToken).not.toBe(body1.accessToken);
|
|
451
309
|
});
|
|
452
|
-
});
|
|
453
310
|
|
|
454
|
-
|
|
455
|
-
|
|
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/
|
|
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
|
|
578
|
-
expect(
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
678
|
-
test('
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
912
|
-
|
|
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
|
-
});
|