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