@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.
Files changed (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. 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
- const scope = details.scopeOptions[0].scope;
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
- * assistant mapping -> env override -> config sms.phoneNumber -> secure key fallback.
36
+ * env override -> config sms.phoneNumber -> secure key fallback.
61
37
  */
62
- function resolveSmsPhoneNumber(assistantId?: string): string {
38
+ function resolveSmsPhoneNumber(): string {
63
39
  try {
64
40
  const raw = loadRawConfig();
65
41
  const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
66
- const mapped = getAssistantMappedPhoneNumber(smsConfig, assistantId);
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(context?: ChannelProbeContext): ReadinessCheckResult[] {
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(context?.assistantId);
94
- const hasPhone = !!resolvedNumber || (!context?.assistantId && hasAnyAssistantMappedPhoneNumberSafe());
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
- ? (context?.assistantId && !resolvedNumber
100
- ? `Assistant ${context.assistantId} has no direct mapping, but SMS phone numbers are assigned`
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(context?: ChannelProbeContext): Promise<ReadinessCheckResult[]> {
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(context?.assistantId);
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
- * assistant mapping -> env override -> config sms.phoneNumber -> secure key fallback.
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(assistantId?: string): string {
148
+ function resolveVoicePhoneNumber(): string {
179
149
  try {
180
150
  const raw = loadRawConfig();
181
151
  const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
182
- const mapped = getAssistantMappedPhoneNumber(smsConfig, assistantId);
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(context?: ChannelProbeContext): ReadinessCheckResult[] {
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(context?.assistantId);
210
- const hasPhone = !!resolvedNumber || (!context?.assistantId && hasAnyAssistantMappedPhoneNumberSafe());
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
- ? (context?.assistantId && !resolvedNumber
216
- ? `Assistant ${context.assistantId} has no direct mapping, but phone numbers are assigned`
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 = { assistantId };
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, assistantId);
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, assistantId?: string): void {
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, assistantId?: string): string {
405
- return `${channel}::${assistantId ?? '__default__'}`;
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 assistant-scoped readiness checks. */
26
- export interface ChannelProbeContext {
27
- assistantId?: string;
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 {