@vellumai/assistant 0.4.3 → 0.4.4
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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor-token mint/verify service.
|
|
3
|
+
*
|
|
4
|
+
* Mints HMAC-signed actor tokens bound to (assistantId, guardianPrincipalId,
|
|
5
|
+
* deviceId, platform). Only the SHA-256 hash of the token is persisted —
|
|
6
|
+
* the raw plaintext is returned to the caller once and never stored.
|
|
7
|
+
*
|
|
8
|
+
* Token format: base64url(JSON claims) + '.' + base64url(HMAC-SHA256 signature)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { getLogger } from '../util/logger.js';
|
|
16
|
+
import { getRootDir } from '../util/platform.js';
|
|
17
|
+
|
|
18
|
+
const log = getLogger('actor-token-service');
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface ActorTokenClaims {
|
|
25
|
+
/** The assistant this token is scoped to. */
|
|
26
|
+
assistantId: string;
|
|
27
|
+
/** Platform: 'macos' | 'ios' */
|
|
28
|
+
platform: string;
|
|
29
|
+
/** Opaque device identifier (hashed for storage). */
|
|
30
|
+
deviceId: string;
|
|
31
|
+
/** The guardian principal this token is bound to. */
|
|
32
|
+
guardianPrincipalId: string;
|
|
33
|
+
/** Token issuance timestamp (epoch ms). */
|
|
34
|
+
iat: number;
|
|
35
|
+
/** Token expiration timestamp (epoch ms). Null means non-expiring. */
|
|
36
|
+
exp: number | null;
|
|
37
|
+
/** Random jti (JWT ID) for uniqueness. */
|
|
38
|
+
jti: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MintResult {
|
|
42
|
+
/** The raw actor token string — returned once, never persisted. */
|
|
43
|
+
token: string;
|
|
44
|
+
/** SHA-256 hex hash of the token (for storage/lookup). */
|
|
45
|
+
tokenHash: string;
|
|
46
|
+
/** The decoded claims. */
|
|
47
|
+
claims: ActorTokenClaims;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type VerifyResult =
|
|
51
|
+
| { ok: true; claims: ActorTokenClaims }
|
|
52
|
+
| { ok: false; reason: string };
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Signing key management
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
let signingKey: Buffer | null = null;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Path to the persisted signing key file.
|
|
62
|
+
* Stored in the protected directory alongside other sensitive material.
|
|
63
|
+
*/
|
|
64
|
+
function getSigningKeyPath(): string {
|
|
65
|
+
return join(getRootDir(), 'protected', 'actor-token-signing-key');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load a signing key from disk or generate and persist a new one.
|
|
70
|
+
* Uses the same atomic-write + chmod 0o600 pattern as approved-devices-store.ts.
|
|
71
|
+
*/
|
|
72
|
+
export function loadOrCreateSigningKey(): Buffer {
|
|
73
|
+
const keyPath = getSigningKeyPath();
|
|
74
|
+
|
|
75
|
+
// Try to load existing key
|
|
76
|
+
if (existsSync(keyPath)) {
|
|
77
|
+
try {
|
|
78
|
+
const raw = readFileSync(keyPath);
|
|
79
|
+
if (raw.length === 32) {
|
|
80
|
+
log.info('Actor-token signing key loaded from disk');
|
|
81
|
+
return raw;
|
|
82
|
+
}
|
|
83
|
+
log.warn('Signing key file has unexpected length, regenerating');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
log.warn({ err }, 'Failed to read signing key file, regenerating');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Generate and persist a new key
|
|
90
|
+
const newKey = randomBytes(32);
|
|
91
|
+
const dir = dirname(keyPath);
|
|
92
|
+
if (!existsSync(dir)) {
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
const tmpPath = keyPath + '.tmp.' + process.pid;
|
|
96
|
+
writeFileSync(tmpPath, newKey, { mode: 0o600 });
|
|
97
|
+
renameSync(tmpPath, keyPath);
|
|
98
|
+
chmodSync(keyPath, 0o600);
|
|
99
|
+
|
|
100
|
+
log.info('Actor-token signing key generated and persisted');
|
|
101
|
+
return newKey;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Initialize (or reinitialize) the signing key. Called at daemon startup
|
|
106
|
+
* with a key loaded from disk via loadOrCreateSigningKey(), or by tests
|
|
107
|
+
* with a deterministic key.
|
|
108
|
+
*/
|
|
109
|
+
export function initSigningKey(key: Buffer): void {
|
|
110
|
+
signingKey = key;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getSigningKey(): Buffer {
|
|
114
|
+
if (!signingKey) {
|
|
115
|
+
throw new Error('Actor-token signing key not initialized — call initSigningKey() during startup');
|
|
116
|
+
}
|
|
117
|
+
return signingKey;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Base64url helpers
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function base64urlEncode(data: Buffer | string): string {
|
|
125
|
+
const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
|
|
126
|
+
return buf.toString('base64url');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function base64urlDecode(str: string): Buffer {
|
|
130
|
+
return Buffer.from(str, 'base64url');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Hashing
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** SHA-256 hex digest of a raw token string. */
|
|
138
|
+
export function hashToken(token: string): string {
|
|
139
|
+
return createHash('sha256').update(token).digest('hex');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Mint
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** Default TTL for actor tokens: 90 days in milliseconds. */
|
|
147
|
+
const DEFAULT_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Mint a new actor token.
|
|
151
|
+
*
|
|
152
|
+
* @param params Token claims (assistantId, platform, deviceId, guardianPrincipalId).
|
|
153
|
+
* @param ttlMs Optional TTL in milliseconds. Defaults to 90 days.
|
|
154
|
+
* Pass `null` explicitly for a non-expiring token.
|
|
155
|
+
* @returns The raw token, its hash, and the embedded claims.
|
|
156
|
+
*/
|
|
157
|
+
export function mintActorToken(params: {
|
|
158
|
+
assistantId: string;
|
|
159
|
+
platform: string;
|
|
160
|
+
deviceId: string;
|
|
161
|
+
guardianPrincipalId: string;
|
|
162
|
+
ttlMs?: number | null;
|
|
163
|
+
}): MintResult {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const effectiveTtl = params.ttlMs === undefined ? DEFAULT_TOKEN_TTL_MS : params.ttlMs;
|
|
166
|
+
const claims: ActorTokenClaims = {
|
|
167
|
+
assistantId: params.assistantId,
|
|
168
|
+
platform: params.platform,
|
|
169
|
+
deviceId: params.deviceId,
|
|
170
|
+
guardianPrincipalId: params.guardianPrincipalId,
|
|
171
|
+
iat: now,
|
|
172
|
+
exp: effectiveTtl != null ? now + effectiveTtl : null,
|
|
173
|
+
jti: randomBytes(16).toString('hex'),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const payload = base64urlEncode(JSON.stringify(claims));
|
|
177
|
+
const sig = createHmac('sha256', getSigningKey())
|
|
178
|
+
.update(payload)
|
|
179
|
+
.digest();
|
|
180
|
+
const token = payload + '.' + base64urlEncode(sig);
|
|
181
|
+
const tokenHash = hashToken(token);
|
|
182
|
+
|
|
183
|
+
return { token, tokenHash, claims };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Verify
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Verify an actor token's structural integrity and signature.
|
|
192
|
+
*
|
|
193
|
+
* Does NOT check revocation — callers must additionally verify the
|
|
194
|
+
* token hash exists in the actor-token store with status='active'.
|
|
195
|
+
*/
|
|
196
|
+
export function verifyActorToken(token: string): VerifyResult {
|
|
197
|
+
const dotIndex = token.indexOf('.');
|
|
198
|
+
if (dotIndex < 0) {
|
|
199
|
+
return { ok: false, reason: 'malformed_token' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const payload = token.slice(0, dotIndex);
|
|
203
|
+
const sigPart = token.slice(dotIndex + 1);
|
|
204
|
+
|
|
205
|
+
// Recompute HMAC
|
|
206
|
+
const expectedSig = createHmac('sha256', getSigningKey())
|
|
207
|
+
.update(payload)
|
|
208
|
+
.digest();
|
|
209
|
+
const actualSig = base64urlDecode(sigPart);
|
|
210
|
+
|
|
211
|
+
if (expectedSig.length !== actualSig.length) {
|
|
212
|
+
return { ok: false, reason: 'invalid_signature' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Constant-time comparison
|
|
216
|
+
if (!timingSafeEqual(expectedSig, actualSig)) {
|
|
217
|
+
return { ok: false, reason: 'invalid_signature' };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let claims: ActorTokenClaims;
|
|
221
|
+
try {
|
|
222
|
+
const decoded = base64urlDecode(payload).toString('utf-8');
|
|
223
|
+
claims = JSON.parse(decoded) as ActorTokenClaims;
|
|
224
|
+
} catch {
|
|
225
|
+
return { ok: false, reason: 'malformed_claims' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Expiration check
|
|
229
|
+
if (claims.exp != null && Date.now() > claims.exp) {
|
|
230
|
+
return { ok: false, reason: 'token_expired' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { ok: true, claims };
|
|
234
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash-only actor token persistence.
|
|
3
|
+
*
|
|
4
|
+
* Stores only the SHA-256 hash of each actor token alongside metadata
|
|
5
|
+
* (assistantId, guardianPrincipalId, deviceId hash, platform, status).
|
|
6
|
+
* The raw token plaintext is never stored.
|
|
7
|
+
*
|
|
8
|
+
* Uses the assistant SQLite database via drizzle-orm.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { and, eq } from 'drizzle-orm';
|
|
12
|
+
import { v4 as uuid } from 'uuid';
|
|
13
|
+
|
|
14
|
+
import { getDb } from '../memory/db.js';
|
|
15
|
+
import { actorTokenRecords } from '../memory/schema.js';
|
|
16
|
+
import { getLogger } from '../util/logger.js';
|
|
17
|
+
|
|
18
|
+
const log = getLogger('actor-token-store');
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export type ActorTokenStatus = 'active' | 'revoked';
|
|
25
|
+
|
|
26
|
+
export interface ActorTokenRecord {
|
|
27
|
+
id: string;
|
|
28
|
+
tokenHash: string;
|
|
29
|
+
assistantId: string;
|
|
30
|
+
guardianPrincipalId: string;
|
|
31
|
+
hashedDeviceId: string;
|
|
32
|
+
platform: string;
|
|
33
|
+
status: ActorTokenStatus;
|
|
34
|
+
issuedAt: number;
|
|
35
|
+
expiresAt: number | null;
|
|
36
|
+
createdAt: number;
|
|
37
|
+
updatedAt: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Operations
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Store a new actor token record (hash-only).
|
|
46
|
+
*/
|
|
47
|
+
export function createActorTokenRecord(params: {
|
|
48
|
+
tokenHash: string;
|
|
49
|
+
assistantId: string;
|
|
50
|
+
guardianPrincipalId: string;
|
|
51
|
+
hashedDeviceId: string;
|
|
52
|
+
platform: string;
|
|
53
|
+
issuedAt: number;
|
|
54
|
+
expiresAt?: number | null;
|
|
55
|
+
}): ActorTokenRecord {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const id = uuid();
|
|
59
|
+
|
|
60
|
+
const row = {
|
|
61
|
+
id,
|
|
62
|
+
tokenHash: params.tokenHash,
|
|
63
|
+
assistantId: params.assistantId,
|
|
64
|
+
guardianPrincipalId: params.guardianPrincipalId,
|
|
65
|
+
hashedDeviceId: params.hashedDeviceId,
|
|
66
|
+
platform: params.platform,
|
|
67
|
+
status: 'active' as const,
|
|
68
|
+
issuedAt: params.issuedAt,
|
|
69
|
+
expiresAt: params.expiresAt ?? null,
|
|
70
|
+
createdAt: now,
|
|
71
|
+
updatedAt: now,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
db.insert(actorTokenRecords).values(row).run();
|
|
75
|
+
log.info({ id, assistantId: params.assistantId, platform: params.platform }, 'Actor token record created');
|
|
76
|
+
|
|
77
|
+
return row;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Look up an active actor token record by its hash.
|
|
82
|
+
*/
|
|
83
|
+
export function findActiveByTokenHash(tokenHash: string): ActorTokenRecord | null {
|
|
84
|
+
const db = getDb();
|
|
85
|
+
const row = db
|
|
86
|
+
.select()
|
|
87
|
+
.from(actorTokenRecords)
|
|
88
|
+
.where(
|
|
89
|
+
and(
|
|
90
|
+
eq(actorTokenRecords.tokenHash, tokenHash),
|
|
91
|
+
eq(actorTokenRecords.status, 'active'),
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
.get();
|
|
95
|
+
|
|
96
|
+
return row ? rowToRecord(row) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Find an active token for a specific (assistantId, guardianPrincipalId, deviceId).
|
|
101
|
+
* Used for idempotent bootstrap — if an active token already exists for this
|
|
102
|
+
* device binding, we can revoke-and-remint or return the existing record.
|
|
103
|
+
*/
|
|
104
|
+
export function findActiveByDeviceBinding(
|
|
105
|
+
assistantId: string,
|
|
106
|
+
guardianPrincipalId: string,
|
|
107
|
+
hashedDeviceId: string,
|
|
108
|
+
): ActorTokenRecord | null {
|
|
109
|
+
const db = getDb();
|
|
110
|
+
const row = db
|
|
111
|
+
.select()
|
|
112
|
+
.from(actorTokenRecords)
|
|
113
|
+
.where(
|
|
114
|
+
and(
|
|
115
|
+
eq(actorTokenRecords.assistantId, assistantId),
|
|
116
|
+
eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
|
|
117
|
+
eq(actorTokenRecords.hashedDeviceId, hashedDeviceId),
|
|
118
|
+
eq(actorTokenRecords.status, 'active'),
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
.get();
|
|
122
|
+
|
|
123
|
+
return row ? rowToRecord(row) : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Revoke all active tokens for a given device binding.
|
|
128
|
+
* Called before minting a new token to ensure one-active-per-device.
|
|
129
|
+
*/
|
|
130
|
+
export function revokeByDeviceBinding(
|
|
131
|
+
assistantId: string,
|
|
132
|
+
guardianPrincipalId: string,
|
|
133
|
+
hashedDeviceId: string,
|
|
134
|
+
): number {
|
|
135
|
+
const db = getDb();
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
|
|
138
|
+
const condition = and(
|
|
139
|
+
eq(actorTokenRecords.assistantId, assistantId),
|
|
140
|
+
eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
|
|
141
|
+
eq(actorTokenRecords.hashedDeviceId, hashedDeviceId),
|
|
142
|
+
eq(actorTokenRecords.status, 'active'),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Count matching rows before the update since drizzle's bun-sqlite
|
|
146
|
+
// .run() does not expose the underlying changes count in its types.
|
|
147
|
+
const matching = db
|
|
148
|
+
.select({ id: actorTokenRecords.id })
|
|
149
|
+
.from(actorTokenRecords)
|
|
150
|
+
.where(condition)
|
|
151
|
+
.all();
|
|
152
|
+
|
|
153
|
+
if (matching.length === 0) return 0;
|
|
154
|
+
|
|
155
|
+
db.update(actorTokenRecords)
|
|
156
|
+
.set({ status: 'revoked', updatedAt: now })
|
|
157
|
+
.where(condition)
|
|
158
|
+
.run();
|
|
159
|
+
|
|
160
|
+
return matching.length;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Find all active actor token records for a given (assistantId, guardianPrincipalId).
|
|
165
|
+
* Used for multi-device guardian fanout — returns all bound devices (macOS, iOS, etc.)
|
|
166
|
+
* so notification targeting can reach every device for the same guardian identity.
|
|
167
|
+
*/
|
|
168
|
+
export function findActiveByGuardianPrincipalId(
|
|
169
|
+
assistantId: string,
|
|
170
|
+
guardianPrincipalId: string,
|
|
171
|
+
): ActorTokenRecord[] {
|
|
172
|
+
const db = getDb();
|
|
173
|
+
const rows = db
|
|
174
|
+
.select()
|
|
175
|
+
.from(actorTokenRecords)
|
|
176
|
+
.where(
|
|
177
|
+
and(
|
|
178
|
+
eq(actorTokenRecords.assistantId, assistantId),
|
|
179
|
+
eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
|
|
180
|
+
eq(actorTokenRecords.status, 'active'),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
.all();
|
|
184
|
+
|
|
185
|
+
return rows.map(rowToRecord);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Revoke a single token by its hash.
|
|
190
|
+
*/
|
|
191
|
+
export function revokeByTokenHash(tokenHash: string): boolean {
|
|
192
|
+
const db = getDb();
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
|
|
195
|
+
const condition = and(
|
|
196
|
+
eq(actorTokenRecords.tokenHash, tokenHash),
|
|
197
|
+
eq(actorTokenRecords.status, 'active'),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Check existence before update since drizzle's bun-sqlite .run()
|
|
201
|
+
// does not expose the underlying changes count in its types.
|
|
202
|
+
const existing = db
|
|
203
|
+
.select({ id: actorTokenRecords.id })
|
|
204
|
+
.from(actorTokenRecords)
|
|
205
|
+
.where(condition)
|
|
206
|
+
.get();
|
|
207
|
+
|
|
208
|
+
if (!existing) return false;
|
|
209
|
+
|
|
210
|
+
db.update(actorTokenRecords)
|
|
211
|
+
.set({ status: 'revoked', updatedAt: now })
|
|
212
|
+
.where(condition)
|
|
213
|
+
.run();
|
|
214
|
+
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Helpers
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
function rowToRecord(row: typeof actorTokenRecords.$inferSelect): ActorTokenRecord {
|
|
223
|
+
return {
|
|
224
|
+
id: row.id,
|
|
225
|
+
tokenHash: row.tokenHash,
|
|
226
|
+
assistantId: row.assistantId,
|
|
227
|
+
guardianPrincipalId: row.guardianPrincipalId,
|
|
228
|
+
hashedDeviceId: row.hashedDeviceId,
|
|
229
|
+
platform: row.platform,
|
|
230
|
+
status: row.status as ActorTokenStatus,
|
|
231
|
+
issuedAt: row.issuedAt,
|
|
232
|
+
expiresAt: row.expiresAt,
|
|
233
|
+
createdAt: row.createdAt,
|
|
234
|
+
updatedAt: row.updatedAt,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -151,11 +151,13 @@ export function handleChannelDecision(
|
|
|
151
151
|
if (
|
|
152
152
|
details &&
|
|
153
153
|
details.persistentDecisionsAllowed !== false &&
|
|
154
|
-
details.allowlistOptions?.length
|
|
155
|
-
details.scopeOptions?.length
|
|
154
|
+
details.allowlistOptions?.length
|
|
156
155
|
) {
|
|
157
156
|
const pattern = details.allowlistOptions[0].pattern;
|
|
158
|
-
|
|
157
|
+
// Non-scoped tools (web_fetch, network_request, etc.) have empty
|
|
158
|
+
// scopeOptions — default to 'everywhere' so approve_always still
|
|
159
|
+
// persists a trust rule instead of silently degrading to one-time.
|
|
160
|
+
const scope = details.scopeOptions?.length ? details.scopeOptions[0].scope : 'everywhere';
|
|
159
161
|
// Only persist executionTarget for skill-origin tools — core tools don't
|
|
160
162
|
// set it in their PolicyContext, so a persisted value would prevent the
|
|
161
163
|
// rule from ever matching on subsequent permission checks.
|
|
@@ -31,41 +31,15 @@ function hasIngressConfigured(): boolean {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function getAssistantMappedPhoneNumber(
|
|
35
|
-
smsConfig: Record<string, unknown>,
|
|
36
|
-
assistantId?: string,
|
|
37
|
-
): string | undefined {
|
|
38
|
-
if (!assistantId) return undefined;
|
|
39
|
-
const mapping = (smsConfig.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
|
|
40
|
-
return mapping[assistantId];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function hasAnyAssistantMappedPhoneNumber(smsConfig: Record<string, unknown>): boolean {
|
|
44
|
-
const mapping = (smsConfig.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
|
|
45
|
-
return Object.keys(mapping).length > 0;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function hasAnyAssistantMappedPhoneNumberSafe(): boolean {
|
|
49
|
-
try {
|
|
50
|
-
const raw = loadRawConfig();
|
|
51
|
-
const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
|
|
52
|
-
return hasAnyAssistantMappedPhoneNumber(smsConfig);
|
|
53
|
-
} catch {
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
34
|
/**
|
|
59
35
|
* Resolve SMS from-number with canonical precedence:
|
|
60
|
-
*
|
|
36
|
+
* env override -> config sms.phoneNumber -> secure key fallback.
|
|
61
37
|
*/
|
|
62
|
-
function resolveSmsPhoneNumber(
|
|
38
|
+
function resolveSmsPhoneNumber(): string {
|
|
63
39
|
try {
|
|
64
40
|
const raw = loadRawConfig();
|
|
65
41
|
const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
|
|
66
|
-
|
|
67
|
-
return mapped
|
|
68
|
-
|| getTwilioPhoneNumberEnv()
|
|
42
|
+
return getTwilioPhoneNumberEnv()
|
|
69
43
|
|| (smsConfig.phoneNumber as string)
|
|
70
44
|
|| getSecureKey('credential:twilio:phone_number')
|
|
71
45
|
|| '';
|
|
@@ -78,7 +52,7 @@ function resolveSmsPhoneNumber(assistantId?: string): string {
|
|
|
78
52
|
|
|
79
53
|
const smsProbe: ChannelProbe = {
|
|
80
54
|
channel: 'sms',
|
|
81
|
-
runLocalChecks(
|
|
55
|
+
runLocalChecks(): ReadinessCheckResult[] {
|
|
82
56
|
const results: ReadinessCheckResult[] = [];
|
|
83
57
|
|
|
84
58
|
const hasCreds = hasTwilioCredentials();
|
|
@@ -90,18 +64,14 @@ const smsProbe: ChannelProbe = {
|
|
|
90
64
|
: 'Twilio Account SID and Auth Token are not configured',
|
|
91
65
|
});
|
|
92
66
|
|
|
93
|
-
const resolvedNumber = resolveSmsPhoneNumber(
|
|
94
|
-
const hasPhone = !!resolvedNumber
|
|
67
|
+
const resolvedNumber = resolveSmsPhoneNumber();
|
|
68
|
+
const hasPhone = !!resolvedNumber;
|
|
95
69
|
results.push({
|
|
96
70
|
name: 'phone_number',
|
|
97
71
|
passed: hasPhone,
|
|
98
72
|
message: hasPhone
|
|
99
|
-
?
|
|
100
|
-
|
|
101
|
-
: 'Phone number is assigned')
|
|
102
|
-
: (context?.assistantId
|
|
103
|
-
? `No phone number assigned for assistant ${context.assistantId}`
|
|
104
|
-
: 'No phone number assigned'),
|
|
73
|
+
? 'Phone number is assigned'
|
|
74
|
+
: 'No phone number assigned',
|
|
105
75
|
});
|
|
106
76
|
|
|
107
77
|
const hasIngress = hasIngressConfigured();
|
|
@@ -115,14 +85,14 @@ const smsProbe: ChannelProbe = {
|
|
|
115
85
|
|
|
116
86
|
return results;
|
|
117
87
|
},
|
|
118
|
-
async runRemoteChecks(
|
|
88
|
+
async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
|
|
119
89
|
if (!hasTwilioCredentials()) return [];
|
|
120
90
|
|
|
121
91
|
const accountSid = getSecureKey('credential:twilio:account_sid');
|
|
122
92
|
const authToken = getSecureKey('credential:twilio:auth_token');
|
|
123
93
|
if (!accountSid || !authToken) return [];
|
|
124
94
|
|
|
125
|
-
const phoneNumber = resolveSmsPhoneNumber(
|
|
95
|
+
const phoneNumber = resolveSmsPhoneNumber();
|
|
126
96
|
if (!phoneNumber) return [];
|
|
127
97
|
|
|
128
98
|
// Only toll-free numbers need verification checks
|
|
@@ -170,18 +140,16 @@ const smsProbe: ChannelProbe = {
|
|
|
170
140
|
|
|
171
141
|
/**
|
|
172
142
|
* Resolve voice from-number with the same precedence as SMS:
|
|
173
|
-
*
|
|
143
|
+
* env override -> config sms.phoneNumber -> secure key fallback.
|
|
174
144
|
*
|
|
175
145
|
* Voice and SMS share the same Twilio phone number infrastructure, so the
|
|
176
146
|
* resolution logic is identical to resolveSmsPhoneNumber.
|
|
177
147
|
*/
|
|
178
|
-
function resolveVoicePhoneNumber(
|
|
148
|
+
function resolveVoicePhoneNumber(): string {
|
|
179
149
|
try {
|
|
180
150
|
const raw = loadRawConfig();
|
|
181
151
|
const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
|
|
182
|
-
|
|
183
|
-
return mapped
|
|
184
|
-
|| getTwilioPhoneNumberEnv()
|
|
152
|
+
return getTwilioPhoneNumberEnv()
|
|
185
153
|
|| (smsConfig.phoneNumber as string)
|
|
186
154
|
|| getSecureKey('credential:twilio:phone_number')
|
|
187
155
|
|| '';
|
|
@@ -194,7 +162,7 @@ function resolveVoicePhoneNumber(assistantId?: string): string {
|
|
|
194
162
|
|
|
195
163
|
const voiceProbe: ChannelProbe = {
|
|
196
164
|
channel: 'voice',
|
|
197
|
-
runLocalChecks(
|
|
165
|
+
runLocalChecks(): ReadinessCheckResult[] {
|
|
198
166
|
const results: ReadinessCheckResult[] = [];
|
|
199
167
|
|
|
200
168
|
const hasCreds = hasTwilioCredentials();
|
|
@@ -206,18 +174,14 @@ const voiceProbe: ChannelProbe = {
|
|
|
206
174
|
: 'Twilio Account SID and Auth Token are not configured',
|
|
207
175
|
});
|
|
208
176
|
|
|
209
|
-
const resolvedNumber = resolveVoicePhoneNumber(
|
|
210
|
-
const hasPhone = !!resolvedNumber
|
|
177
|
+
const resolvedNumber = resolveVoicePhoneNumber();
|
|
178
|
+
const hasPhone = !!resolvedNumber;
|
|
211
179
|
results.push({
|
|
212
180
|
name: 'phone_number',
|
|
213
181
|
passed: hasPhone,
|
|
214
182
|
message: hasPhone
|
|
215
|
-
?
|
|
216
|
-
|
|
217
|
-
: 'Phone number is assigned for voice calls')
|
|
218
|
-
: (context?.assistantId
|
|
219
|
-
? `No phone number assigned for assistant ${context.assistantId}`
|
|
220
|
-
: 'No phone number assigned for voice calls'),
|
|
183
|
+
? 'Phone number is assigned for voice calls'
|
|
184
|
+
: 'No phone number assigned for voice calls',
|
|
221
185
|
});
|
|
222
186
|
|
|
223
187
|
const hasIngress = hasIngressConfigured();
|
|
@@ -290,7 +254,6 @@ export class ChannelReadinessService {
|
|
|
290
254
|
async getReadiness(
|
|
291
255
|
channel?: ChannelId,
|
|
292
256
|
includeRemote?: boolean,
|
|
293
|
-
assistantId?: string,
|
|
294
257
|
): Promise<ChannelReadinessSnapshot[]> {
|
|
295
258
|
const channels = channel
|
|
296
259
|
? [channel]
|
|
@@ -304,14 +267,14 @@ export class ChannelReadinessService {
|
|
|
304
267
|
continue;
|
|
305
268
|
}
|
|
306
269
|
|
|
307
|
-
const probeContext: ChannelProbeContext = {
|
|
270
|
+
const probeContext: ChannelProbeContext = {};
|
|
308
271
|
const localChecks = probe.runLocalChecks(probeContext);
|
|
309
272
|
let remoteChecks: ReadinessCheckResult[] | undefined;
|
|
310
273
|
let remoteChecksFreshlyFetched = false;
|
|
311
274
|
let remoteChecksAffectReadiness = false;
|
|
312
275
|
let stale = false;
|
|
313
276
|
|
|
314
|
-
const cacheKey = this.snapshotCacheKey(ch
|
|
277
|
+
const cacheKey = this.snapshotCacheKey(ch);
|
|
315
278
|
const cached = this.snapshots.get(cacheKey);
|
|
316
279
|
const now = Date.now();
|
|
317
280
|
|
|
@@ -372,11 +335,7 @@ export class ChannelReadinessService {
|
|
|
372
335
|
}
|
|
373
336
|
|
|
374
337
|
/** Clear cached snapshot for a specific channel, forcing re-evaluation on next call. */
|
|
375
|
-
invalidateChannel(channel: ChannelId
|
|
376
|
-
if (assistantId) {
|
|
377
|
-
this.snapshots.delete(this.snapshotCacheKey(channel, assistantId));
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
338
|
+
invalidateChannel(channel: ChannelId): void {
|
|
380
339
|
const prefix = `${channel}::`;
|
|
381
340
|
for (const key of this.snapshots.keys()) {
|
|
382
341
|
if (key.startsWith(prefix)) {
|
|
@@ -401,8 +360,8 @@ export class ChannelReadinessService {
|
|
|
401
360
|
};
|
|
402
361
|
}
|
|
403
362
|
|
|
404
|
-
private snapshotCacheKey(channel: ChannelId
|
|
405
|
-
return `${channel}
|
|
363
|
+
private snapshotCacheKey(channel: ChannelId): string {
|
|
364
|
+
return `${channel}::__default__`;
|
|
406
365
|
}
|
|
407
366
|
}
|
|
408
367
|
|
|
@@ -22,10 +22,9 @@ export interface ChannelReadinessSnapshot {
|
|
|
22
22
|
remoteChecks?: ReadinessCheckResult[];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** Optional probe context for
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
25
|
+
/** Optional probe context for readiness checks. */
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
27
|
+
export interface ChannelProbeContext {}
|
|
29
28
|
|
|
30
29
|
/** Probe interface that channels implement to provide readiness checks. */
|
|
31
30
|
export interface ChannelProbe {
|