@vellumai/assistant 0.4.3 → 0.4.5
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/.env.example +3 -0
- package/ARCHITECTURE.md +40 -3
- package/README.md +43 -35
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +1 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
- package/src/__tests__/actor-token-service.test.ts +1099 -0
- package/src/__tests__/agent-loop.test.ts +51 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
- package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
- package/src/__tests__/call-controller.test.ts +49 -0
- package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
- package/src/__tests__/call-pointer-messages.test.ts +93 -3
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
- package/src/__tests__/callback-handoff-copy.test.ts +186 -0
- package/src/__tests__/channel-approval-routes.test.ts +133 -12
- package/src/__tests__/channel-guardian.test.ts +0 -87
- package/src/__tests__/channel-readiness-service.test.ts +10 -16
- package/src/__tests__/checker.test.ts +33 -12
- package/src/__tests__/config-schema.test.ts +4 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
- package/src/__tests__/conversation-routes.test.ts +12 -3
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/daemon-server-session-init.test.ts +4 -0
- package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
- package/src/__tests__/guardian-dispatch.test.ts +8 -0
- package/src/__tests__/guardian-outbound-http.test.ts +4 -4
- package/src/__tests__/guardian-question-mode.test.ts +200 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
- package/src/__tests__/guardian-routing-state.test.ts +525 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
- package/src/__tests__/handlers-telegram-config.test.ts +0 -83
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
- package/src/__tests__/headless-browser-navigate.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +18 -51
- package/src/__tests__/non-member-access-request.test.ts +131 -8
- package/src/__tests__/notification-decision-fallback.test.ts +129 -4
- package/src/__tests__/notification-decision-strategy.test.ts +62 -2
- package/src/__tests__/notification-guardian-path.test.ts +3 -0
- package/src/__tests__/recording-intent-handler.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +841 -39
- package/src/__tests__/send-endpoint-busy.test.ts +5 -0
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-confirmation-signals.test.ts +523 -0
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
- package/src/__tests__/tool-executor.test.ts +21 -2
- package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
- package/src/__tests__/twilio-config.test.ts +2 -13
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-decision-primitive.ts +10 -2
- package/src/approvals/guardian-request-resolvers.ts +128 -9
- package/src/calls/call-constants.ts +21 -0
- package/src/calls/call-controller.ts +9 -2
- package/src/calls/call-domain.ts +28 -7
- package/src/calls/call-pointer-message-composer.ts +154 -0
- package/src/calls/call-pointer-messages.ts +106 -27
- package/src/calls/guardian-dispatch.ts +4 -2
- package/src/calls/relay-server.ts +424 -12
- package/src/calls/twilio-config.ts +4 -11
- package/src/calls/twilio-routes.ts +1 -1
- package/src/calls/types.ts +3 -1
- package/src/cli.ts +5 -4
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
- package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
- package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
- package/src/config/bundled-skills/messaging/SKILL.md +61 -12
- package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
- package/src/config/bundled-skills/twitter/SKILL.md +3 -3
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
- package/src/config/calls-schema.ts +24 -0
- package/src/config/env.ts +22 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/schema.ts +2 -2
- package/src/config/skills.ts +11 -0
- package/src/config/system-prompt.ts +11 -1
- package/src/config/templates/SOUL.md +2 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
- package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
- package/src/daemon/call-pointer-generators.ts +59 -0
- package/src/daemon/computer-use-session.ts +2 -5
- package/src/daemon/handlers/apps.ts +76 -20
- package/src/daemon/handlers/config-channels.ts +5 -55
- package/src/daemon/handlers/config-inbox.ts +9 -3
- package/src/daemon/handlers/config-ingress.ts +28 -3
- package/src/daemon/handlers/config-telegram.ts +12 -0
- package/src/daemon/handlers/config.ts +2 -6
- package/src/daemon/handlers/pairing.ts +2 -0
- package/src/daemon/handlers/sessions.ts +48 -3
- package/src/daemon/handlers/shared.ts +17 -2
- package/src/daemon/ipc-contract/integrations.ts +1 -99
- package/src/daemon/ipc-contract/messages.ts +47 -1
- package/src/daemon/ipc-contract/notifications.ts +11 -0
- package/src/daemon/ipc-contract-inventory.json +2 -4
- package/src/daemon/lifecycle.ts +17 -0
- package/src/daemon/server.ts +14 -1
- package/src/daemon/session-agent-loop-handlers.ts +20 -0
- package/src/daemon/session-agent-loop.ts +22 -11
- package/src/daemon/session-lifecycle.ts +1 -1
- package/src/daemon/session-process.ts +11 -1
- package/src/daemon/session-runtime-assembly.ts +3 -0
- package/src/daemon/session-surfaces.ts +3 -2
- package/src/daemon/session.ts +88 -1
- package/src/daemon/tool-side-effects.ts +22 -0
- package/src/home-base/prebuilt/brain-graph.html +1483 -0
- package/src/home-base/prebuilt/index.html +40 -0
- package/src/inbound/platform-callback-registration.ts +157 -0
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/038-actor-token-records.ts +39 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +16 -0
- package/src/messaging/provider-types.ts +24 -0
- package/src/messaging/provider.ts +7 -0
- package/src/messaging/providers/gmail/adapter.ts +127 -0
- package/src/messaging/providers/sms/adapter.ts +40 -37
- package/src/notifications/adapters/macos.ts +45 -2
- package/src/notifications/broadcaster.ts +16 -0
- package/src/notifications/copy-composer.ts +39 -1
- package/src/notifications/decision-engine.ts +22 -9
- package/src/notifications/destination-resolver.ts +16 -2
- package/src/notifications/emit-signal.ts +16 -8
- package/src/notifications/guardian-question-mode.ts +419 -0
- package/src/notifications/signal.ts +14 -3
- package/src/permissions/checker.ts +13 -1
- package/src/permissions/prompter.ts +14 -0
- package/src/providers/anthropic/client.ts +20 -0
- package/src/providers/provider-send-message.ts +15 -3
- package/src/runtime/access-request-helper.ts +71 -1
- package/src/runtime/actor-token-service.ts +234 -0
- package/src/runtime/actor-token-store.ts +236 -0
- package/src/runtime/channel-approvals.ts +5 -3
- package/src/runtime/channel-readiness-service.ts +23 -64
- package/src/runtime/channel-readiness-types.ts +3 -4
- package/src/runtime/channel-retry-sweep.ts +4 -1
- package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-context-resolver.ts +82 -0
- package/src/runtime/guardian-outbound-actions.ts +0 -3
- package/src/runtime/guardian-reply-router.ts +67 -30
- package/src/runtime/guardian-vellum-migration.ts +57 -0
- package/src/runtime/http-server.ts +65 -12
- package/src/runtime/http-types.ts +13 -0
- package/src/runtime/invite-redemption-service.ts +8 -0
- package/src/runtime/local-actor-identity.ts +76 -0
- package/src/runtime/middleware/actor-token.ts +271 -0
- package/src/runtime/routes/approval-routes.ts +82 -7
- package/src/runtime/routes/brain-graph-routes.ts +222 -0
- package/src/runtime/routes/channel-readiness-routes.ts +71 -0
- package/src/runtime/routes/conversation-routes.ts +140 -52
- package/src/runtime/routes/events-routes.ts +20 -5
- package/src/runtime/routes/guardian-action-routes.ts +45 -3
- package/src/runtime/routes/guardian-approval-interception.ts +29 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
- package/src/runtime/routes/inbound-message-handler.ts +143 -2
- package/src/runtime/routes/integration-routes.ts +7 -15
- package/src/runtime/routes/pairing-routes.ts +163 -0
- package/src/runtime/routes/twilio-routes.ts +934 -0
- package/src/runtime/tool-grant-request-helper.ts +3 -1
- package/src/security/oauth2.ts +27 -2
- package/src/security/token-manager.ts +46 -10
- package/src/tools/browser/browser-execution.ts +4 -3
- package/src/tools/browser/browser-handoff.ts +10 -18
- package/src/tools/browser/browser-manager.ts +80 -25
- package/src/tools/browser/browser-screencast.ts +35 -119
- package/src/tools/permission-checker.ts +15 -4
- package/src/tools/tool-approval-handler.ts +242 -18
- package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
- package/src/daemon/handlers/config-twilio.ts +0 -1082
|
@@ -0,0 +1,1099 @@
|
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
11
|
+
|
|
12
|
+
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'actor-token-test-')));
|
|
13
|
+
|
|
14
|
+
mock.module('../util/platform.js', () => ({
|
|
15
|
+
getRootDir: () => testDir,
|
|
16
|
+
getDataDir: () => testDir,
|
|
17
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
18
|
+
normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
|
|
19
|
+
isMacOS: () => process.platform === 'darwin',
|
|
20
|
+
isLinux: () => process.platform === 'linux',
|
|
21
|
+
isWindows: () => process.platform === 'win32',
|
|
22
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
23
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
24
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
25
|
+
ensureDataDir: () => {},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
mock.module('../util/logger.js', () => ({
|
|
29
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
30
|
+
get: () => () => {},
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import { getSqlite, initializeDb, resetDb } from '../memory/db.js';
|
|
35
|
+
import {
|
|
36
|
+
createBinding,
|
|
37
|
+
getActiveBinding,
|
|
38
|
+
} from '../memory/guardian-bindings.js';
|
|
39
|
+
import {
|
|
40
|
+
hashToken,
|
|
41
|
+
initSigningKey,
|
|
42
|
+
mintActorToken,
|
|
43
|
+
verifyActorToken,
|
|
44
|
+
} from '../runtime/actor-token-service.js';
|
|
45
|
+
import {
|
|
46
|
+
createActorTokenRecord,
|
|
47
|
+
findActiveByDeviceBinding,
|
|
48
|
+
findActiveByTokenHash,
|
|
49
|
+
revokeByDeviceBinding,
|
|
50
|
+
revokeByTokenHash,
|
|
51
|
+
} from '../runtime/actor-token-store.js';
|
|
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';
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Mock server helpers for loopback IP checks
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** Creates a mock server that returns the given IP for any request. */
|
|
67
|
+
function mockServer(address: string): ServerWithRequestIP {
|
|
68
|
+
return {
|
|
69
|
+
requestIP: () => ({ address, family: 'IPv4', port: 0 }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Mock loopback server — returns 127.0.0.1 for all requests. */
|
|
74
|
+
const loopbackServer = mockServer('127.0.0.1');
|
|
75
|
+
|
|
76
|
+
/** Mock non-loopback server — returns a LAN IP for all requests. */
|
|
77
|
+
const nonLoopbackServer = mockServer('192.168.1.50');
|
|
78
|
+
|
|
79
|
+
initializeDb();
|
|
80
|
+
|
|
81
|
+
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.
|
|
87
|
+
resetDb();
|
|
88
|
+
initializeDb();
|
|
89
|
+
const db = getSqlite();
|
|
90
|
+
db.run('DELETE FROM actor_token_records');
|
|
91
|
+
db.run('DELETE FROM channel_guardian_bindings');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterAll(() => {
|
|
95
|
+
try { rmSync(testDir, { recursive: true, force: true }); } catch {}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Actor token mint/verify
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe('actor-token mint/verify', () => {
|
|
103
|
+
test('mint returns token, hash, and claims with default 90-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 90 days
|
|
119
|
+
expect(result.claims.exp).not.toBeNull();
|
|
120
|
+
const ninetyDaysMs = 90 * 24 * 60 * 60 * 1000;
|
|
121
|
+
expect(result.claims.exp! - result.claims.iat).toBe(ninetyDaysMs);
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Hash-only storage
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
describe('actor-token store (hash-only)', () => {
|
|
221
|
+
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
|
+
});
|
|
228
|
+
|
|
229
|
+
const record = createActorTokenRecord({
|
|
230
|
+
tokenHash,
|
|
231
|
+
assistantId: 'self',
|
|
232
|
+
guardianPrincipalId: 'principal-store',
|
|
233
|
+
hashedDeviceId: 'hashed-dev-store',
|
|
234
|
+
platform: 'macos',
|
|
235
|
+
issuedAt: Date.now(),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(record.tokenHash).toBe(tokenHash);
|
|
239
|
+
expect(record.status).toBe('active');
|
|
240
|
+
// Verify the record can be found by hash
|
|
241
|
+
const found = findActiveByTokenHash(tokenHash);
|
|
242
|
+
expect(found).not.toBeNull();
|
|
243
|
+
expect(found!.tokenHash).toBe(tokenHash);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('findActiveByDeviceBinding returns matching record', () => {
|
|
247
|
+
const { tokenHash } = mintActorToken({
|
|
248
|
+
assistantId: 'self',
|
|
249
|
+
platform: 'ios',
|
|
250
|
+
deviceId: 'dev-bind',
|
|
251
|
+
guardianPrincipalId: 'principal-bind',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
createActorTokenRecord({
|
|
255
|
+
tokenHash,
|
|
256
|
+
assistantId: 'self',
|
|
257
|
+
guardianPrincipalId: 'principal-bind',
|
|
258
|
+
hashedDeviceId: 'hashed-dev-bind',
|
|
259
|
+
platform: 'ios',
|
|
260
|
+
issuedAt: Date.now(),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const found = findActiveByDeviceBinding('self', 'principal-bind', 'hashed-dev-bind');
|
|
264
|
+
expect(found).not.toBeNull();
|
|
265
|
+
expect(found!.platform).toBe('ios');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
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
|
+
});
|
|
275
|
+
|
|
276
|
+
createActorTokenRecord({
|
|
277
|
+
tokenHash,
|
|
278
|
+
assistantId: 'self',
|
|
279
|
+
guardianPrincipalId: 'principal-revoke',
|
|
280
|
+
hashedDeviceId: 'hashed-dev-revoke',
|
|
281
|
+
platform: 'macos',
|
|
282
|
+
issuedAt: Date.now(),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const count = revokeByDeviceBinding('self', 'principal-revoke', 'hashed-dev-revoke');
|
|
286
|
+
expect(count).toBe(1);
|
|
287
|
+
|
|
288
|
+
// Should no longer be found as active
|
|
289
|
+
const found = findActiveByTokenHash(tokenHash);
|
|
290
|
+
expect(found).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
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
|
+
});
|
|
300
|
+
|
|
301
|
+
createActorTokenRecord({
|
|
302
|
+
tokenHash,
|
|
303
|
+
assistantId: 'self',
|
|
304
|
+
guardianPrincipalId: 'principal-single',
|
|
305
|
+
hashedDeviceId: 'hashed-dev-single',
|
|
306
|
+
platform: 'macos',
|
|
307
|
+
issuedAt: Date.now(),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(revokeByTokenHash(tokenHash)).toBe(true);
|
|
311
|
+
expect(findActiveByTokenHash(tokenHash)).toBeNull();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Guardian vellum migration
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
describe('guardian vellum migration', () => {
|
|
320
|
+
test('ensureVellumGuardianBinding creates binding when missing', () => {
|
|
321
|
+
const principalId = ensureVellumGuardianBinding('self');
|
|
322
|
+
expect(principalId).toMatch(/^vellum-principal-/);
|
|
323
|
+
|
|
324
|
+
const binding = getActiveBinding('self', 'vellum');
|
|
325
|
+
expect(binding).not.toBeNull();
|
|
326
|
+
expect(binding!.guardianExternalUserId).toBe(principalId);
|
|
327
|
+
expect(binding!.verifiedVia).toBe('startup-migration');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('ensureVellumGuardianBinding is idempotent', () => {
|
|
331
|
+
const first = ensureVellumGuardianBinding('self');
|
|
332
|
+
const second = ensureVellumGuardianBinding('self');
|
|
333
|
+
expect(first).toBe(second);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('ensureVellumGuardianBinding preserves existing bindings for other channels', () => {
|
|
337
|
+
// Create a telegram binding
|
|
338
|
+
createBinding({
|
|
339
|
+
assistantId: 'self',
|
|
340
|
+
channel: 'telegram',
|
|
341
|
+
guardianExternalUserId: 'tg-user-123',
|
|
342
|
+
guardianDeliveryChatId: 'tg-chat-456',
|
|
343
|
+
verifiedVia: 'challenge',
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Now backfill vellum
|
|
347
|
+
ensureVellumGuardianBinding('self');
|
|
348
|
+
|
|
349
|
+
// Telegram binding should still exist
|
|
350
|
+
const tgBinding = getActiveBinding('self', 'telegram');
|
|
351
|
+
expect(tgBinding).not.toBeNull();
|
|
352
|
+
expect(tgBinding!.guardianExternalUserId).toBe('tg-user-123');
|
|
353
|
+
|
|
354
|
+
// Vellum binding should also exist
|
|
355
|
+
const vBinding = getActiveBinding('self', 'vellum');
|
|
356
|
+
expect(vBinding).not.toBeNull();
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// Bootstrap idempotency (via route handler)
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
describe('bootstrap endpoint idempotency', () => {
|
|
365
|
+
test('calling bootstrap twice returns same guardianPrincipalId', async () => {
|
|
366
|
+
// We test the logic used by the bootstrap route handler directly
|
|
367
|
+
// rather than spinning up a full HTTP server.
|
|
368
|
+
const { handleGuardianBootstrap } = await import('../runtime/routes/guardian-bootstrap-routes.js');
|
|
369
|
+
|
|
370
|
+
const req1 = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: { 'Content-Type': 'application/json' },
|
|
373
|
+
body: JSON.stringify({ platform: 'macos', deviceId: 'test-device-1' }),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const res1 = await handleGuardianBootstrap(req1, loopbackServer);
|
|
377
|
+
expect(res1.status).toBe(200);
|
|
378
|
+
const body1 = await res1.json() as Record<string, unknown>;
|
|
379
|
+
expect(body1.guardianPrincipalId).toBeTruthy();
|
|
380
|
+
expect(body1.actorToken).toBeTruthy();
|
|
381
|
+
expect(body1.isNew).toBe(true);
|
|
382
|
+
|
|
383
|
+
// Second call with same device
|
|
384
|
+
const req2 = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
headers: { 'Content-Type': 'application/json' },
|
|
387
|
+
body: JSON.stringify({ platform: 'macos', deviceId: 'test-device-1' }),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const res2 = await handleGuardianBootstrap(req2, loopbackServer);
|
|
391
|
+
expect(res2.status).toBe(200);
|
|
392
|
+
const body2 = await res2.json() as Record<string, unknown>;
|
|
393
|
+
expect(body2.guardianPrincipalId).toBe(body1.guardianPrincipalId);
|
|
394
|
+
expect(body2.actorToken).toBeTruthy();
|
|
395
|
+
// New token minted (previous revoked), but same principal
|
|
396
|
+
expect(body2.actorToken).not.toBe(body1.actorToken);
|
|
397
|
+
expect(body2.isNew).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('bootstrap rejects missing fields', async () => {
|
|
401
|
+
const { handleGuardianBootstrap } = await import('../runtime/routes/guardian-bootstrap-routes.js');
|
|
402
|
+
|
|
403
|
+
const req = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
|
|
404
|
+
method: 'POST',
|
|
405
|
+
headers: { 'Content-Type': 'application/json' },
|
|
406
|
+
body: JSON.stringify({ platform: 'macos' }),
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const res = await handleGuardianBootstrap(req, loopbackServer);
|
|
410
|
+
expect(res.status).toBe(400);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test('bootstrap rejects invalid platform', async () => {
|
|
414
|
+
const { handleGuardianBootstrap } = await import('../runtime/routes/guardian-bootstrap-routes.js');
|
|
415
|
+
|
|
416
|
+
const req = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
|
|
417
|
+
method: 'POST',
|
|
418
|
+
headers: { 'Content-Type': 'application/json' },
|
|
419
|
+
body: JSON.stringify({ platform: 'android', deviceId: 'test-device' }),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const res = await handleGuardianBootstrap(req, loopbackServer);
|
|
423
|
+
expect(res.status).toBe(400);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('bootstrap with different devices returns same principal but different tokens', async () => {
|
|
427
|
+
const { handleGuardianBootstrap } = await import('../runtime/routes/guardian-bootstrap-routes.js');
|
|
428
|
+
|
|
429
|
+
const req1 = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
|
|
430
|
+
method: 'POST',
|
|
431
|
+
headers: { 'Content-Type': 'application/json' },
|
|
432
|
+
body: JSON.stringify({ platform: 'macos', deviceId: 'device-A' }),
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const res1 = await handleGuardianBootstrap(req1, loopbackServer);
|
|
436
|
+
const body1 = await res1.json() as Record<string, unknown>;
|
|
437
|
+
|
|
438
|
+
const req2 = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
|
|
439
|
+
method: 'POST',
|
|
440
|
+
headers: { 'Content-Type': 'application/json' },
|
|
441
|
+
body: JSON.stringify({ platform: 'macos', deviceId: 'device-B' }),
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const res2 = await handleGuardianBootstrap(req2, loopbackServer);
|
|
445
|
+
const body2 = await res2.json() as Record<string, unknown>;
|
|
446
|
+
|
|
447
|
+
// Same principal, different tokens
|
|
448
|
+
expect(body2.guardianPrincipalId).toBe(body1.guardianPrincipalId);
|
|
449
|
+
expect(body2.actorToken).not.toBe(body1.actorToken);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// HTTP middleware strict enforcement
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
describe('HTTP actor token middleware (strict enforcement)', () => {
|
|
458
|
+
test('rejects request without X-Actor-Token header', () => {
|
|
459
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
460
|
+
method: 'POST',
|
|
461
|
+
headers: { 'Content-Type': 'application/json' },
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const result = verifyHttpActorToken(req);
|
|
465
|
+
expect(result.ok).toBe(false);
|
|
466
|
+
if (!result.ok) {
|
|
467
|
+
expect(result.status).toBe(401);
|
|
468
|
+
expect(result.message).toContain('Missing X-Actor-Token');
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('rejects request with invalid (tampered) token', () => {
|
|
473
|
+
const { token } = mintActorToken({
|
|
474
|
+
assistantId: 'self',
|
|
475
|
+
platform: 'macos',
|
|
476
|
+
deviceId: 'device-tamper',
|
|
477
|
+
guardianPrincipalId: 'principal-tamper',
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const parts = token.split('.');
|
|
481
|
+
const tampered = parts[0] + 'XXXXXX.' + parts[1];
|
|
482
|
+
|
|
483
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
484
|
+
method: 'POST',
|
|
485
|
+
headers: { 'X-Actor-Token': tampered },
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const result = verifyHttpActorToken(req);
|
|
489
|
+
expect(result.ok).toBe(false);
|
|
490
|
+
if (!result.ok) {
|
|
491
|
+
expect(result.status).toBe(401);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('rejects request with revoked token', () => {
|
|
496
|
+
const principalId = ensureVellumGuardianBinding('self');
|
|
497
|
+
const { token, tokenHash } = mintActorToken({
|
|
498
|
+
assistantId: 'self',
|
|
499
|
+
platform: 'macos',
|
|
500
|
+
deviceId: 'device-revoked',
|
|
501
|
+
guardianPrincipalId: principalId,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
createActorTokenRecord({
|
|
505
|
+
tokenHash,
|
|
506
|
+
assistantId: 'self',
|
|
507
|
+
guardianPrincipalId: principalId,
|
|
508
|
+
hashedDeviceId: 'hashed-device-revoked',
|
|
509
|
+
platform: 'macos',
|
|
510
|
+
issuedAt: Date.now(),
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Revoke the token
|
|
514
|
+
revokeByTokenHash(tokenHash);
|
|
515
|
+
|
|
516
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
517
|
+
method: 'POST',
|
|
518
|
+
headers: { 'X-Actor-Token': token },
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const result = verifyHttpActorToken(req);
|
|
522
|
+
expect(result.ok).toBe(false);
|
|
523
|
+
if (!result.ok) {
|
|
524
|
+
expect(result.status).toBe(401);
|
|
525
|
+
expect(result.message).toContain('no longer active');
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('accepts request with valid active token and resolves guardian context', () => {
|
|
530
|
+
const principalId = ensureVellumGuardianBinding('self');
|
|
531
|
+
const { token, tokenHash } = mintActorToken({
|
|
532
|
+
assistantId: 'self',
|
|
533
|
+
platform: 'macos',
|
|
534
|
+
deviceId: 'device-valid',
|
|
535
|
+
guardianPrincipalId: principalId,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
createActorTokenRecord({
|
|
539
|
+
tokenHash,
|
|
540
|
+
assistantId: 'self',
|
|
541
|
+
guardianPrincipalId: principalId,
|
|
542
|
+
hashedDeviceId: 'hashed-device-valid',
|
|
543
|
+
platform: 'macos',
|
|
544
|
+
issuedAt: Date.now(),
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
548
|
+
method: 'POST',
|
|
549
|
+
headers: { 'X-Actor-Token': token },
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const result = verifyHttpActorToken(req);
|
|
553
|
+
expect(result.ok).toBe(true);
|
|
554
|
+
if (result.ok) {
|
|
555
|
+
expect(result.claims.assistantId).toBe('self');
|
|
556
|
+
expect(result.claims.guardianPrincipalId).toBe(principalId);
|
|
557
|
+
expect(result.guardianContext).toBeTruthy();
|
|
558
|
+
expect(result.guardianContext.trustClass).toBe('guardian');
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Local IPC fallback (verifyHttpActorTokenWithLocalFallback)
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
describe('HTTP actor token local fallback', () => {
|
|
568
|
+
test('falls back to local IPC identity when no actor token and no forwarding header', () => {
|
|
569
|
+
ensureVellumGuardianBinding('self');
|
|
570
|
+
|
|
571
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
572
|
+
method: 'POST',
|
|
573
|
+
headers: { 'Content-Type': 'application/json' },
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
|
|
577
|
+
expect(result.ok).toBe(true);
|
|
578
|
+
if (result.ok) {
|
|
579
|
+
expect(result.guardianContext.trustClass).toBe('guardian');
|
|
580
|
+
// localFallback should be true when claims are null
|
|
581
|
+
if ('localFallback' in result) {
|
|
582
|
+
expect(result.localFallback).toBe(true);
|
|
583
|
+
expect(result.claims).toBeNull();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('rejects gateway-proxied request without actor token (X-Forwarded-For present)', () => {
|
|
589
|
+
ensureVellumGuardianBinding('self');
|
|
590
|
+
|
|
591
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
592
|
+
method: 'POST',
|
|
593
|
+
headers: {
|
|
594
|
+
'Content-Type': 'application/json',
|
|
595
|
+
'X-Forwarded-For': '1.2.3.4',
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
|
|
600
|
+
expect(result.ok).toBe(false);
|
|
601
|
+
if (!result.ok) {
|
|
602
|
+
expect(result.status).toBe(401);
|
|
603
|
+
expect(result.message).toContain('Proxied requests require actor identity');
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('uses strict verification when actor token is present even with X-Forwarded-For', () => {
|
|
608
|
+
const principalId = ensureVellumGuardianBinding('self');
|
|
609
|
+
const { token, tokenHash } = mintActorToken({
|
|
610
|
+
assistantId: 'self',
|
|
611
|
+
platform: 'ios',
|
|
612
|
+
deviceId: 'device-proxied',
|
|
613
|
+
guardianPrincipalId: principalId,
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
createActorTokenRecord({
|
|
617
|
+
tokenHash,
|
|
618
|
+
assistantId: 'self',
|
|
619
|
+
guardianPrincipalId: principalId,
|
|
620
|
+
hashedDeviceId: 'hashed-device-proxied',
|
|
621
|
+
platform: 'ios',
|
|
622
|
+
issuedAt: Date.now(),
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
626
|
+
method: 'POST',
|
|
627
|
+
headers: {
|
|
628
|
+
'X-Actor-Token': token,
|
|
629
|
+
'X-Forwarded-For': '1.2.3.4',
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
|
|
634
|
+
expect(result.ok).toBe(true);
|
|
635
|
+
if (result.ok) {
|
|
636
|
+
expect(result.claims).not.toBeNull();
|
|
637
|
+
expect(result.guardianContext.trustClass).toBe('guardian');
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Local IPC identity resolution
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
describe('resolveLocalIpcGuardianContext', () => {
|
|
647
|
+
test('returns guardian context when vellum binding exists', () => {
|
|
648
|
+
ensureVellumGuardianBinding('self');
|
|
649
|
+
|
|
650
|
+
const ctx = resolveLocalIpcGuardianContext();
|
|
651
|
+
expect(ctx.trustClass).toBe('guardian');
|
|
652
|
+
expect(ctx.sourceChannel).toBe('vellum');
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test('returns fallback guardian context when no vellum binding exists', () => {
|
|
656
|
+
// No binding created — fresh DB state
|
|
657
|
+
const ctx = resolveLocalIpcGuardianContext();
|
|
658
|
+
expect(ctx.trustClass).toBe('guardian');
|
|
659
|
+
expect(ctx.sourceChannel).toBe('vellum');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test('respects custom sourceChannel parameter', () => {
|
|
663
|
+
ensureVellumGuardianBinding('self');
|
|
664
|
+
const ctx = resolveLocalIpcGuardianContext('vellum');
|
|
665
|
+
expect(ctx.sourceChannel).toBe('vellum');
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// ---------------------------------------------------------------------------
|
|
670
|
+
// Pairing actor-token flow
|
|
671
|
+
// ---------------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
describe('pairing actor-token flow', () => {
|
|
674
|
+
test('mintPairingActorToken returns actor token in approved pairing status poll', async () => {
|
|
675
|
+
// Set up a vellum guardian binding (required for pairing token mint)
|
|
676
|
+
ensureVellumGuardianBinding('self');
|
|
677
|
+
|
|
678
|
+
const { PairingStore } = await import('../daemon/pairing-store.js');
|
|
679
|
+
const { handlePairingRequest, handlePairingStatus } = await import('../runtime/routes/pairing-routes.js');
|
|
680
|
+
|
|
681
|
+
const store = new PairingStore();
|
|
682
|
+
store.start();
|
|
683
|
+
|
|
684
|
+
const pairingRequestId = 'test-pair-' + Date.now();
|
|
685
|
+
const pairingSecret = 'test-secret-123';
|
|
686
|
+
const bearerToken = 'test-bearer';
|
|
687
|
+
|
|
688
|
+
// Register a pairing request
|
|
689
|
+
store.register({
|
|
690
|
+
pairingRequestId,
|
|
691
|
+
pairingSecret,
|
|
692
|
+
gatewayUrl: 'https://gw.test',
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const ctx = {
|
|
696
|
+
pairingStore: store,
|
|
697
|
+
bearerToken,
|
|
698
|
+
featureFlagToken: undefined,
|
|
699
|
+
pairingBroadcast: () => {},
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
// iOS initiates pairing
|
|
703
|
+
const pairReq = new Request('http://localhost/v1/pairing/request', {
|
|
704
|
+
method: 'POST',
|
|
705
|
+
headers: { 'Content-Type': 'application/json' },
|
|
706
|
+
body: JSON.stringify({
|
|
707
|
+
pairingRequestId,
|
|
708
|
+
pairingSecret,
|
|
709
|
+
deviceId: 'ios-device-1',
|
|
710
|
+
deviceName: 'Test iPhone',
|
|
711
|
+
}),
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
const pairRes = await handlePairingRequest(pairReq, ctx);
|
|
715
|
+
expect(pairRes.status).toBe(200);
|
|
716
|
+
const pairBody = await pairRes.json() as Record<string, unknown>;
|
|
717
|
+
expect(pairBody.status).toBe('pending');
|
|
718
|
+
|
|
719
|
+
// macOS approves the pairing
|
|
720
|
+
store.approve(pairingRequestId, bearerToken);
|
|
721
|
+
|
|
722
|
+
// iOS polls for status — should get approved with actor token
|
|
723
|
+
const statusUrl = new URL(`http://localhost/v1/pairing/status?id=${pairingRequestId}&secret=${pairingSecret}`);
|
|
724
|
+
const statusRes = handlePairingStatus(statusUrl, ctx);
|
|
725
|
+
expect(statusRes.status).toBe(200);
|
|
726
|
+
const statusBody = await statusRes.json() as Record<string, unknown>;
|
|
727
|
+
expect(statusBody.status).toBe('approved');
|
|
728
|
+
expect(statusBody.actorToken).toBeTruthy();
|
|
729
|
+
expect(statusBody.bearerToken).toBe(bearerToken);
|
|
730
|
+
|
|
731
|
+
store.stop();
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test('approved actor token is available within 5 min TTL window', async () => {
|
|
735
|
+
ensureVellumGuardianBinding('self');
|
|
736
|
+
|
|
737
|
+
const { PairingStore } = await import('../daemon/pairing-store.js');
|
|
738
|
+
const { handlePairingRequest, handlePairingStatus } = await import('../runtime/routes/pairing-routes.js');
|
|
739
|
+
|
|
740
|
+
const store = new PairingStore();
|
|
741
|
+
store.start();
|
|
742
|
+
|
|
743
|
+
const pairingRequestId = 'test-ttl-' + Date.now();
|
|
744
|
+
const pairingSecret = 'test-secret-ttl';
|
|
745
|
+
const bearerToken = 'test-bearer-ttl';
|
|
746
|
+
|
|
747
|
+
store.register({
|
|
748
|
+
pairingRequestId,
|
|
749
|
+
pairingSecret,
|
|
750
|
+
gatewayUrl: 'https://gw.test',
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
const ctx = {
|
|
754
|
+
pairingStore: store,
|
|
755
|
+
bearerToken,
|
|
756
|
+
featureFlagToken: undefined,
|
|
757
|
+
pairingBroadcast: () => {},
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// iOS initiates pairing
|
|
761
|
+
const pairReq = new Request('http://localhost/v1/pairing/request', {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
headers: { 'Content-Type': 'application/json' },
|
|
764
|
+
body: JSON.stringify({
|
|
765
|
+
pairingRequestId,
|
|
766
|
+
pairingSecret,
|
|
767
|
+
deviceId: 'ios-device-ttl',
|
|
768
|
+
deviceName: 'TTL iPhone',
|
|
769
|
+
}),
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
await handlePairingRequest(pairReq, ctx);
|
|
773
|
+
store.approve(pairingRequestId, bearerToken);
|
|
774
|
+
|
|
775
|
+
// First poll — mints the token
|
|
776
|
+
const statusUrl = new URL(`http://localhost/v1/pairing/status?id=${pairingRequestId}&secret=${pairingSecret}`);
|
|
777
|
+
const firstRes = handlePairingStatus(statusUrl, ctx);
|
|
778
|
+
const firstBody = await firstRes.json() as Record<string, unknown>;
|
|
779
|
+
const firstToken = firstBody.actorToken as string;
|
|
780
|
+
expect(firstToken).toBeTruthy();
|
|
781
|
+
|
|
782
|
+
// Second poll — same token from cache
|
|
783
|
+
const secondRes = handlePairingStatus(statusUrl, ctx);
|
|
784
|
+
const secondBody = await secondRes.json() as Record<string, unknown>;
|
|
785
|
+
expect(secondBody.actorToken).toBe(firstToken);
|
|
786
|
+
|
|
787
|
+
store.stop();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test('approved status can recover token mint using deviceId query when transient pairing state is missing', async () => {
|
|
791
|
+
ensureVellumGuardianBinding('self');
|
|
792
|
+
|
|
793
|
+
const { PairingStore } = await import('../daemon/pairing-store.js');
|
|
794
|
+
const {
|
|
795
|
+
cleanupPairingState,
|
|
796
|
+
handlePairingRequest,
|
|
797
|
+
handlePairingStatus,
|
|
798
|
+
} = await import('../runtime/routes/pairing-routes.js');
|
|
799
|
+
|
|
800
|
+
const store = new PairingStore();
|
|
801
|
+
store.start();
|
|
802
|
+
|
|
803
|
+
const pairingRequestId = 'test-recover-' + Date.now();
|
|
804
|
+
const pairingSecret = 'test-secret-recover';
|
|
805
|
+
const bearerToken = 'test-bearer-recover';
|
|
806
|
+
const deviceId = 'ios-device-recover';
|
|
807
|
+
|
|
808
|
+
store.register({
|
|
809
|
+
pairingRequestId,
|
|
810
|
+
pairingSecret,
|
|
811
|
+
gatewayUrl: 'https://gw.test',
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
const ctx = {
|
|
815
|
+
pairingStore: store,
|
|
816
|
+
bearerToken,
|
|
817
|
+
featureFlagToken: undefined,
|
|
818
|
+
pairingBroadcast: () => {},
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// iOS initiates pairing so the request is device-bound
|
|
822
|
+
const pairReq = new Request('http://localhost/v1/pairing/request', {
|
|
823
|
+
method: 'POST',
|
|
824
|
+
headers: { 'Content-Type': 'application/json' },
|
|
825
|
+
body: JSON.stringify({
|
|
826
|
+
pairingRequestId,
|
|
827
|
+
pairingSecret,
|
|
828
|
+
deviceId,
|
|
829
|
+
deviceName: 'Recovery iPhone',
|
|
830
|
+
}),
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
const pairRes = await handlePairingRequest(pairReq, ctx);
|
|
834
|
+
expect(pairRes.status).toBe(200);
|
|
835
|
+
|
|
836
|
+
// macOS approves, then transient in-memory pairing state is lost (e.g. restart)
|
|
837
|
+
store.approve(pairingRequestId, bearerToken);
|
|
838
|
+
cleanupPairingState(pairingRequestId);
|
|
839
|
+
|
|
840
|
+
// Poll includes deviceId so token mint can recover from persisted hashedDeviceId
|
|
841
|
+
const statusUrl = new URL(
|
|
842
|
+
`http://localhost/v1/pairing/status?id=${pairingRequestId}&secret=${pairingSecret}&deviceId=${encodeURIComponent(deviceId)}`,
|
|
843
|
+
);
|
|
844
|
+
const statusRes = handlePairingStatus(statusUrl, ctx);
|
|
845
|
+
expect(statusRes.status).toBe(200);
|
|
846
|
+
const statusBody = await statusRes.json() as Record<string, unknown>;
|
|
847
|
+
|
|
848
|
+
expect(statusBody.status).toBe('approved');
|
|
849
|
+
expect(statusBody.actorToken).toBeTruthy();
|
|
850
|
+
expect(statusBody.bearerToken).toBe(bearerToken);
|
|
851
|
+
|
|
852
|
+
store.stop();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test('mintingInFlight guard prevents concurrent mints (synchronous check)', async () => {
|
|
856
|
+
ensureVellumGuardianBinding('self');
|
|
857
|
+
|
|
858
|
+
const { PairingStore } = await import('../daemon/pairing-store.js');
|
|
859
|
+
const { handlePairingRequest, handlePairingStatus } = await import('../runtime/routes/pairing-routes.js');
|
|
860
|
+
|
|
861
|
+
const store = new PairingStore();
|
|
862
|
+
store.start();
|
|
863
|
+
|
|
864
|
+
const pairingRequestId = 'test-concurrent-' + Date.now();
|
|
865
|
+
const pairingSecret = 'test-secret-conc';
|
|
866
|
+
const bearerToken = 'test-bearer-conc';
|
|
867
|
+
|
|
868
|
+
store.register({
|
|
869
|
+
pairingRequestId,
|
|
870
|
+
pairingSecret,
|
|
871
|
+
gatewayUrl: 'https://gw.test',
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
const ctx = {
|
|
875
|
+
pairingStore: store,
|
|
876
|
+
bearerToken,
|
|
877
|
+
featureFlagToken: undefined,
|
|
878
|
+
pairingBroadcast: () => {},
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
const pairReq = new Request('http://localhost/v1/pairing/request', {
|
|
882
|
+
method: 'POST',
|
|
883
|
+
headers: { 'Content-Type': 'application/json' },
|
|
884
|
+
body: JSON.stringify({
|
|
885
|
+
pairingRequestId,
|
|
886
|
+
pairingSecret,
|
|
887
|
+
deviceId: 'ios-device-conc',
|
|
888
|
+
deviceName: 'Concurrent iPhone',
|
|
889
|
+
}),
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
await handlePairingRequest(pairReq, ctx);
|
|
893
|
+
store.approve(pairingRequestId, bearerToken);
|
|
894
|
+
|
|
895
|
+
// Fire two status polls simultaneously — both synchronous so they
|
|
896
|
+
// should not double-mint
|
|
897
|
+
const statusUrl = new URL(`http://localhost/v1/pairing/status?id=${pairingRequestId}&secret=${pairingSecret}`);
|
|
898
|
+
const res1 = handlePairingStatus(statusUrl, ctx);
|
|
899
|
+
const res2 = handlePairingStatus(statusUrl, ctx);
|
|
900
|
+
|
|
901
|
+
const body1 = await res1.json() as Record<string, unknown>;
|
|
902
|
+
const body2 = await res2.json() as Record<string, unknown>;
|
|
903
|
+
|
|
904
|
+
// Both should succeed and return the same token (second sees the cached token)
|
|
905
|
+
expect(body1.status).toBe('approved');
|
|
906
|
+
expect(body2.status).toBe('approved');
|
|
907
|
+
expect(body1.actorToken).toBeTruthy();
|
|
908
|
+
// The second poll should return the same cached token
|
|
909
|
+
expect(body2.actorToken).toBe(body1.actorToken);
|
|
910
|
+
|
|
911
|
+
store.stop();
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
// Loopback IP check tests
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
|
|
919
|
+
describe('loopback IP check (verifyHttpActorTokenWithLocalFallback)', () => {
|
|
920
|
+
test('succeeds with mock server returning loopback IP', () => {
|
|
921
|
+
ensureVellumGuardianBinding('self');
|
|
922
|
+
|
|
923
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
924
|
+
method: 'POST',
|
|
925
|
+
headers: { 'Content-Type': 'application/json' },
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
|
|
929
|
+
expect(result.ok).toBe(true);
|
|
930
|
+
if (result.ok && 'localFallback' in result) {
|
|
931
|
+
expect(result.localFallback).toBe(true);
|
|
932
|
+
expect(result.guardianContext.trustClass).toBe('guardian');
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test('succeeds with mock server returning IPv6 loopback (::1)', () => {
|
|
937
|
+
ensureVellumGuardianBinding('self');
|
|
938
|
+
|
|
939
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
940
|
+
method: 'POST',
|
|
941
|
+
headers: { 'Content-Type': 'application/json' },
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const ipv6LoopbackServer = mockServer('::1');
|
|
945
|
+
const result = verifyHttpActorTokenWithLocalFallback(req, ipv6LoopbackServer);
|
|
946
|
+
expect(result.ok).toBe(true);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test('succeeds with mock server returning IPv4-mapped IPv6 loopback', () => {
|
|
950
|
+
ensureVellumGuardianBinding('self');
|
|
951
|
+
|
|
952
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
953
|
+
method: 'POST',
|
|
954
|
+
headers: { 'Content-Type': 'application/json' },
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
const mappedLoopbackServer = mockServer('::ffff:127.0.0.1');
|
|
958
|
+
const result = verifyHttpActorTokenWithLocalFallback(req, mappedLoopbackServer);
|
|
959
|
+
expect(result.ok).toBe(true);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test('returns 401 with mock server returning non-loopback IP', () => {
|
|
963
|
+
ensureVellumGuardianBinding('self');
|
|
964
|
+
|
|
965
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
966
|
+
method: 'POST',
|
|
967
|
+
headers: { 'Content-Type': 'application/json' },
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
const result = verifyHttpActorTokenWithLocalFallback(req, nonLoopbackServer);
|
|
971
|
+
expect(result.ok).toBe(false);
|
|
972
|
+
if (!result.ok) {
|
|
973
|
+
expect(result.status).toBe(401);
|
|
974
|
+
expect(result.message).toContain('Non-loopback requests require actor identity');
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test('returns 401 with X-Forwarded-For header present', () => {
|
|
979
|
+
ensureVellumGuardianBinding('self');
|
|
980
|
+
|
|
981
|
+
const req = new Request('http://localhost/v1/messages', {
|
|
982
|
+
method: 'POST',
|
|
983
|
+
headers: {
|
|
984
|
+
'Content-Type': 'application/json',
|
|
985
|
+
'X-Forwarded-For': '10.0.0.1',
|
|
986
|
+
},
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const result = verifyHttpActorTokenWithLocalFallback(req, loopbackServer);
|
|
990
|
+
expect(result.ok).toBe(false);
|
|
991
|
+
if (!result.ok) {
|
|
992
|
+
expect(result.status).toBe(401);
|
|
993
|
+
expect(result.message).toContain('Proxied requests require actor identity');
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// ---------------------------------------------------------------------------
|
|
999
|
+
// Bootstrap loopback guard tests
|
|
1000
|
+
// ---------------------------------------------------------------------------
|
|
1001
|
+
|
|
1002
|
+
describe('bootstrap loopback guard', () => {
|
|
1003
|
+
test('rejects bootstrap request with X-Forwarded-For header', async () => {
|
|
1004
|
+
const { handleGuardianBootstrap } = await import('../runtime/routes/guardian-bootstrap-routes.js');
|
|
1005
|
+
|
|
1006
|
+
const req = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
|
|
1007
|
+
method: 'POST',
|
|
1008
|
+
headers: {
|
|
1009
|
+
'Content-Type': 'application/json',
|
|
1010
|
+
'X-Forwarded-For': '10.0.0.1',
|
|
1011
|
+
},
|
|
1012
|
+
body: JSON.stringify({ platform: 'macos', deviceId: 'test-device' }),
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
const res = await handleGuardianBootstrap(req, loopbackServer);
|
|
1016
|
+
expect(res.status).toBe(403);
|
|
1017
|
+
const body = await res.json() as { error: { message: string } };
|
|
1018
|
+
expect(body.error.message).toContain('local-only');
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
test('rejects bootstrap request from non-loopback IP', async () => {
|
|
1022
|
+
const { handleGuardianBootstrap } = await import('../runtime/routes/guardian-bootstrap-routes.js');
|
|
1023
|
+
|
|
1024
|
+
const req = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
|
|
1025
|
+
method: 'POST',
|
|
1026
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1027
|
+
body: JSON.stringify({ platform: 'macos', deviceId: 'test-device' }),
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const res = await handleGuardianBootstrap(req, nonLoopbackServer);
|
|
1031
|
+
expect(res.status).toBe(403);
|
|
1032
|
+
const body = await res.json() as { error: { message: string } };
|
|
1033
|
+
expect(body.error.message).toContain('local-only');
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test('accepts bootstrap request from loopback IP', async () => {
|
|
1037
|
+
const { handleGuardianBootstrap } = await import('../runtime/routes/guardian-bootstrap-routes.js');
|
|
1038
|
+
|
|
1039
|
+
const req = new Request('http://localhost/v1/integrations/guardian/vellum/bootstrap', {
|
|
1040
|
+
method: 'POST',
|
|
1041
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1042
|
+
body: JSON.stringify({ platform: 'macos', deviceId: 'test-device-ok' }),
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
const res = await handleGuardianBootstrap(req, loopbackServer);
|
|
1046
|
+
expect(res.status).toBe(200);
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
// ---------------------------------------------------------------------------
|
|
1051
|
+
// Utility function tests (isActorBoundGuardian, isLocalFallbackBoundGuardian)
|
|
1052
|
+
// ---------------------------------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
describe('utility functions', () => {
|
|
1055
|
+
test('isActorBoundGuardian returns true when actor matches bound guardian', () => {
|
|
1056
|
+
const principalId = ensureVellumGuardianBinding('self');
|
|
1057
|
+
const { claims } = mintActorToken({
|
|
1058
|
+
assistantId: 'self',
|
|
1059
|
+
platform: 'macos',
|
|
1060
|
+
deviceId: 'device-bound',
|
|
1061
|
+
guardianPrincipalId: principalId,
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
expect(isActorBoundGuardian(claims)).toBe(true);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test('isActorBoundGuardian returns false for mismatched principal', () => {
|
|
1068
|
+
ensureVellumGuardianBinding('self');
|
|
1069
|
+
const { claims } = mintActorToken({
|
|
1070
|
+
assistantId: 'self',
|
|
1071
|
+
platform: 'macos',
|
|
1072
|
+
deviceId: 'device-mismatch',
|
|
1073
|
+
guardianPrincipalId: 'wrong-principal-id',
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
expect(isActorBoundGuardian(claims)).toBe(false);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
test('isActorBoundGuardian returns false when no vellum binding exists', () => {
|
|
1080
|
+
const { claims } = mintActorToken({
|
|
1081
|
+
assistantId: 'self',
|
|
1082
|
+
platform: 'macos',
|
|
1083
|
+
deviceId: 'device-no-binding',
|
|
1084
|
+
guardianPrincipalId: 'some-principal',
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
expect(isActorBoundGuardian(claims)).toBe(false);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
test('isLocalFallbackBoundGuardian returns true when vellum binding exists', () => {
|
|
1091
|
+
ensureVellumGuardianBinding('self');
|
|
1092
|
+
expect(isLocalFallbackBoundGuardian()).toBe(true);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
test('isLocalFallbackBoundGuardian returns true even without binding (pre-bootstrap fallback)', () => {
|
|
1096
|
+
// No binding — local user is inherently the guardian of their own machine
|
|
1097
|
+
expect(isLocalFallbackBoundGuardian()).toBe(true);
|
|
1098
|
+
});
|
|
1099
|
+
});
|