@vellumai/assistant 0.4.9 → 0.4.11

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 (116) hide show
  1. package/ARCHITECTURE.md +24 -0
  2. package/Dockerfile +1 -1
  3. package/README.md +16 -9
  4. package/package.json +1 -1
  5. package/src/__tests__/account-registry.test.ts +1 -0
  6. package/src/__tests__/actor-token-service.test.ts +1 -0
  7. package/src/__tests__/app-builder-tool-scripts.test.ts +1 -0
  8. package/src/__tests__/asset-materialize-tool.test.ts +7 -0
  9. package/src/__tests__/asset-search-tool.test.ts +7 -0
  10. package/src/__tests__/browser-fill-credential.test.ts +1 -0
  11. package/src/__tests__/call-start-guardian-guard.test.ts +1 -0
  12. package/src/__tests__/channel-approval-routes.test.ts +29 -0
  13. package/src/__tests__/channel-guardian.test.ts +2143 -1546
  14. package/src/__tests__/channel-retry-sweep.test.ts +169 -14
  15. package/src/__tests__/claude-code-tool-profiles.test.ts +1 -0
  16. package/src/__tests__/computer-use-tools.test.ts +1 -0
  17. package/src/__tests__/contacts-tools.test.ts +1 -0
  18. package/src/__tests__/conversation-attention-telegram.test.ts +1 -0
  19. package/src/__tests__/credential-policy-validate.test.ts +97 -0
  20. package/src/__tests__/credential-security-e2e.test.ts +1 -0
  21. package/src/__tests__/credential-vault-unit.test.ts +1 -0
  22. package/src/__tests__/credential-vault.test.ts +1 -0
  23. package/src/__tests__/delete-managed-skill-tool.test.ts +1 -0
  24. package/src/__tests__/file-edit-tool.test.ts +1 -0
  25. package/src/__tests__/file-read-tool.test.ts +1 -0
  26. package/src/__tests__/file-write-tool.test.ts +1 -0
  27. package/src/__tests__/followup-tools.test.ts +1 -0
  28. package/src/__tests__/gateway-only-guard.test.ts +1 -1
  29. package/src/__tests__/guardian-control-plane-policy.test.ts +5 -4
  30. package/src/__tests__/guardian-grant-minting.test.ts +3 -0
  31. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +4 -3
  32. package/src/__tests__/guardian-routing-state.test.ts +8 -0
  33. package/src/__tests__/headless-browser-interactions.test.ts +1 -0
  34. package/src/__tests__/headless-browser-navigate.test.ts +1 -0
  35. package/src/__tests__/headless-browser-read-tools.test.ts +1 -0
  36. package/src/__tests__/headless-browser-snapshot.test.ts +1 -0
  37. package/src/__tests__/host-file-edit-tool.test.ts +1 -0
  38. package/src/__tests__/host-file-read-tool.test.ts +1 -0
  39. package/src/__tests__/host-file-write-tool.test.ts +1 -0
  40. package/src/__tests__/host-shell-tool.test.ts +1 -0
  41. package/src/__tests__/lifecycle-docs-guard.test.ts +207 -0
  42. package/src/__tests__/managed-skill-lifecycle.test.ts +1 -0
  43. package/src/__tests__/media-reuse-story.e2e.test.ts +8 -0
  44. package/src/__tests__/messaging-send-tool.test.ts +1 -0
  45. package/src/__tests__/playbook-execution.test.ts +1 -0
  46. package/src/__tests__/playbook-tools.test.ts +1 -0
  47. package/src/__tests__/relay-server.test.ts +4 -0
  48. package/src/__tests__/scaffold-managed-skill-tool.test.ts +1 -0
  49. package/src/__tests__/schedule-tools.test.ts +1 -0
  50. package/src/__tests__/secret-onetime-send.test.ts +4 -0
  51. package/src/__tests__/secret-scanner-executor.test.ts +2 -0
  52. package/src/__tests__/send-notification-tool.test.ts +2 -0
  53. package/src/__tests__/shell-credential-ref.test.ts +1 -0
  54. package/src/__tests__/shell-tool-proxy-mode.test.ts +1 -0
  55. package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
  56. package/src/__tests__/skill-load-tool.test.ts +1 -0
  57. package/src/__tests__/skill-script-runner-host.test.ts +1 -0
  58. package/src/__tests__/skill-script-runner-sandbox.test.ts +1 -0
  59. package/src/__tests__/skill-script-runner.test.ts +1 -0
  60. package/src/__tests__/skill-tool-factory.test.ts +1 -0
  61. package/src/__tests__/subagent-tools.test.ts +1 -1
  62. package/src/__tests__/swarm-recursion.test.ts +1 -0
  63. package/src/__tests__/swarm-session-integration.test.ts +1 -0
  64. package/src/__tests__/swarm-tool.test.ts +1 -0
  65. package/src/__tests__/task-management-tools.test.ts +1 -0
  66. package/src/__tests__/task-tools.test.ts +1 -0
  67. package/src/__tests__/terminal-tools.test.ts +1 -0
  68. package/src/__tests__/tool-approval-handler.test.ts +2 -2
  69. package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
  70. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -0
  71. package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -0
  72. package/src/__tests__/tool-executor-shell-integration.test.ts +1 -0
  73. package/src/__tests__/tool-executor.test.ts +1 -0
  74. package/src/__tests__/trust-context-guards.test.ts +218 -0
  75. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
  76. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +6 -0
  77. package/src/__tests__/trusted-contact-multichannel.test.ts +1 -0
  78. package/src/__tests__/trusted-contact-verification.test.ts +1 -0
  79. package/src/__tests__/view-image-tool.test.ts +1 -0
  80. package/src/calls/guardian-dispatch.ts +4 -4
  81. package/src/cli/mcp.ts +183 -3
  82. package/src/config/bundled-skills/agentmail/SKILL.md +4 -4
  83. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +1 -0
  84. package/src/config/bundled-skills/phone-calls/SKILL.md +17 -119
  85. package/src/config/system-prompt.ts +4 -2
  86. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  87. package/src/daemon/computer-use-session.ts +1 -0
  88. package/src/daemon/session-agent-loop.ts +1 -1
  89. package/src/daemon/session-memory.ts +2 -2
  90. package/src/daemon/session-runtime-assembly.ts +2 -2
  91. package/src/daemon/session-tool-setup.ts +1 -1
  92. package/src/mcp/client.ts +55 -6
  93. package/src/mcp/manager.ts +9 -0
  94. package/src/mcp/mcp-oauth-provider.ts +347 -0
  95. package/src/memory/channel-delivery-store.ts +1 -0
  96. package/src/memory/db-init.ts +4 -0
  97. package/src/memory/delivery-status.ts +43 -0
  98. package/src/memory/guardian-bindings.ts +3 -3
  99. package/src/memory/migrations/127-guardian-principal-id-not-null.ts +108 -0
  100. package/src/memory/migrations/index.ts +1 -0
  101. package/src/memory/migrations/registry.ts +6 -0
  102. package/src/memory/schema.ts +1 -1
  103. package/src/runtime/actor-trust-resolver.ts +13 -4
  104. package/src/runtime/channel-retry-sweep.ts +31 -14
  105. package/src/runtime/guardian-context-resolver.ts +25 -64
  106. package/src/runtime/guardian-outbound-actions.ts +399 -108
  107. package/src/runtime/guardian-vellum-migration.ts +1 -23
  108. package/src/runtime/guardian-verification-templates.ts +66 -30
  109. package/src/runtime/local-actor-identity.ts +4 -6
  110. package/src/runtime/middleware/actor-token.ts +2 -8
  111. package/src/runtime/routes/channel-route-shared.ts +0 -1
  112. package/src/runtime/routes/inbound-message-handler.ts +3 -4
  113. package/src/runtime/tool-grant-request-helper.ts +1 -1
  114. package/src/tools/credentials/policy-validate.ts +22 -0
  115. package/src/tools/guardian-control-plane-policy.ts +2 -2
  116. package/src/tools/types.ts +1 -1
@@ -0,0 +1,347 @@
1
+ /**
2
+ * OAuthClientProvider implementation for MCP servers.
3
+ *
4
+ * Uses secure-keys (OS keychain / encrypted file store) for persistent
5
+ * credential storage and a loopback HTTP server for the browser callback.
6
+ */
7
+
8
+ import { createServer, type Server } from 'node:http';
9
+
10
+ import type { OAuthClientProvider, OAuthDiscoveryState } from '@modelcontextprotocol/sdk/client/auth.js';
11
+ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
12
+
13
+ import {
14
+ deleteSecureKeyAsync,
15
+ getSecureKeyAsync,
16
+ setSecureKeyAsync,
17
+ } from '../security/secure-keys.js';
18
+ import { getLogger } from '../util/logger.js';
19
+ import { isLinux, isMacOS } from '../util/platform.js';
20
+
21
+ const log = getLogger('mcp-oauth');
22
+
23
+ const CALLBACK_PATH = '/oauth/callback';
24
+ const CALLBACK_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
25
+
26
+ // Keychain key helpers
27
+ function tokensKey(serverId: string): string { return `mcp:${serverId}:tokens`; }
28
+ function clientInfoKey(serverId: string): string { return `mcp:${serverId}:client_info`; }
29
+ function discoveryKey(serverId: string): string { return `mcp:${serverId}:discovery`; }
30
+
31
+ export interface McpOAuthCallbackResult {
32
+ /** Resolves with the authorization code when the callback is received. */
33
+ codePromise: Promise<string>;
34
+ }
35
+
36
+ export class McpOAuthProvider implements OAuthClientProvider {
37
+ private readonly serverId: string;
38
+ private readonly serverUrl: string;
39
+ private readonly interactive: boolean;
40
+ private _codeVerifier: string | undefined;
41
+ private _redirectUrl: string | undefined;
42
+ private _codePromise: Promise<string> | null = null;
43
+ private callbackServer: Server | null = null;
44
+ private callbackTimeout: ReturnType<typeof setTimeout> | null = null;
45
+
46
+ /**
47
+ * @param interactive When true (e.g. `mcp auth` CLI), opens browser for OAuth.
48
+ * When false (daemon), logs a message instead.
49
+ */
50
+ constructor(serverId: string, serverUrl: string, interactive = false) {
51
+ this.serverId = serverId;
52
+ this.serverUrl = serverUrl;
53
+ this.interactive = interactive;
54
+ }
55
+
56
+ // --- redirectUrl ---
57
+
58
+ get redirectUrl(): string | undefined {
59
+ return this._redirectUrl;
60
+ }
61
+
62
+ // --- clientMetadata ---
63
+
64
+ get clientMetadata(): OAuthClientMetadata {
65
+ return {
66
+ client_name: 'Vellum Assistant',
67
+ redirect_uris: this._redirectUrl ? [this._redirectUrl] : [],
68
+ token_endpoint_auth_method: 'none',
69
+ grant_types: ['authorization_code', 'refresh_token'],
70
+ response_types: ['code'],
71
+ };
72
+ }
73
+
74
+ // --- Tokens ---
75
+
76
+ async tokens(): Promise<OAuthTokens | undefined> {
77
+ const raw = await getSecureKeyAsync(tokensKey(this.serverId));
78
+ if (!raw) return undefined;
79
+ try {
80
+ return JSON.parse(raw) as OAuthTokens;
81
+ } catch {
82
+ log.warn({ serverId: this.serverId }, 'Failed to parse stored OAuth tokens');
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
88
+ await setSecureKeyAsync(tokensKey(this.serverId), JSON.stringify(tokens));
89
+ log.info({ serverId: this.serverId }, 'OAuth tokens saved');
90
+ }
91
+
92
+ // --- Client Information ---
93
+
94
+ async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
95
+ const raw = await getSecureKeyAsync(clientInfoKey(this.serverId));
96
+ if (!raw) return undefined;
97
+ try {
98
+ return JSON.parse(raw) as OAuthClientInformationMixed;
99
+ } catch {
100
+ log.warn({ serverId: this.serverId }, 'Failed to parse stored client information');
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
106
+ await setSecureKeyAsync(clientInfoKey(this.serverId), JSON.stringify(info));
107
+ log.info({ serverId: this.serverId }, 'OAuth client information saved');
108
+ }
109
+
110
+ // --- Code Verifier (in-memory, ephemeral) ---
111
+
112
+ async saveCodeVerifier(verifier: string): Promise<void> {
113
+ this._codeVerifier = verifier;
114
+ }
115
+
116
+ async codeVerifier(): Promise<string> {
117
+ if (!this._codeVerifier) {
118
+ throw new Error('No code verifier available — OAuth flow not started');
119
+ }
120
+ return this._codeVerifier;
121
+ }
122
+
123
+ // --- Discovery State ---
124
+
125
+ async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
126
+ const raw = await getSecureKeyAsync(discoveryKey(this.serverId));
127
+ if (!raw) return undefined;
128
+ try {
129
+ return JSON.parse(raw) as OAuthDiscoveryState;
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
136
+ await setSecureKeyAsync(discoveryKey(this.serverId), JSON.stringify(state));
137
+ }
138
+
139
+ // --- Redirect to Authorization ---
140
+
141
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
142
+ const url = authorizationUrl.toString();
143
+
144
+ if (!this.interactive) {
145
+ // Daemon mode — don't open browser, just log guidance
146
+ log.info({ serverId: this.serverId }, 'OAuth required but running in non-interactive mode');
147
+ return;
148
+ }
149
+
150
+ log.info({ serverId: this.serverId }, 'Opening browser for OAuth authorization');
151
+ console.log(`[MCP] Opening browser for OAuth authorization of "${this.serverId}"...`);
152
+
153
+ try {
154
+ const { execFile } = await import('node:child_process');
155
+ const onError = (err: Error | null) => {
156
+ if (err) {
157
+ log.warn({ err }, 'Failed to open browser');
158
+ console.log(`[MCP] Please open this URL in your browser:\n${url}`);
159
+ }
160
+ };
161
+ if (isMacOS()) {
162
+ execFile('open', [url], onError);
163
+ } else if (isLinux()) {
164
+ execFile('xdg-open', [url], onError);
165
+ } else {
166
+ log.warn('Unsupported platform for browser open — please visit the URL manually');
167
+ console.log(`[MCP] Please open this URL in your browser:\n${url}`);
168
+ }
169
+ } catch (err) {
170
+ log.warn({ err }, 'Failed to open browser');
171
+ console.log(`[MCP] Please open this URL in your browser:\n${url}`);
172
+ }
173
+ }
174
+
175
+ // --- Invalidate Credentials ---
176
+
177
+ async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): Promise<void> {
178
+ log.info({ serverId: this.serverId, scope }, 'Invalidating OAuth credentials');
179
+
180
+ if (scope === 'all' || scope === 'tokens') {
181
+ await deleteSecureKeyAsync(tokensKey(this.serverId));
182
+ }
183
+ if (scope === 'all' || scope === 'client') {
184
+ await deleteSecureKeyAsync(clientInfoKey(this.serverId));
185
+ }
186
+ if (scope === 'all' || scope === 'verifier') {
187
+ this._codeVerifier = undefined;
188
+ }
189
+ if (scope === 'all' || scope === 'discovery') {
190
+ await deleteSecureKeyAsync(discoveryKey(this.serverId));
191
+ }
192
+ }
193
+
194
+ // --- Callback Server ---
195
+
196
+ /**
197
+ * Start a loopback HTTP server to receive the OAuth callback.
198
+ * Returns a promise that resolves with the authorization code.
199
+ */
200
+ startCallbackServer(): Promise<McpOAuthCallbackResult> {
201
+ return new Promise((resolveSetup, rejectSetup) => {
202
+ let settled = false;
203
+ let listening = false;
204
+ let codeResolve: (code: string) => void;
205
+ let codeReject: (err: Error) => void;
206
+
207
+ const codePromise = new Promise<string>((resolve, reject) => {
208
+ codeResolve = resolve;
209
+ codeReject = reject;
210
+ });
211
+ this._codePromise = codePromise;
212
+
213
+ const server = createServer((req, res) => {
214
+ if (settled) {
215
+ res.writeHead(400, { 'Content-Type': 'text/html' });
216
+ res.end(renderPage('Authorization already completed', false));
217
+ return;
218
+ }
219
+
220
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
221
+ if (url.pathname !== CALLBACK_PATH) {
222
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
223
+ res.end('Not found');
224
+ return;
225
+ }
226
+
227
+ const code = url.searchParams.get('code');
228
+ const error = url.searchParams.get('error');
229
+
230
+ settled = true;
231
+
232
+ if (error) {
233
+ const errorDesc = url.searchParams.get('error_description') ?? error;
234
+ res.writeHead(200, { 'Content-Type': 'text/html' });
235
+ res.end(renderPage(`Authorization failed: ${errorDesc}`, false));
236
+ cleanup();
237
+ codeReject(new Error(`MCP OAuth authorization denied: ${error}`));
238
+ return;
239
+ }
240
+
241
+ if (!code) {
242
+ res.writeHead(400, { 'Content-Type': 'text/html' });
243
+ res.end(renderPage('Missing authorization code', false));
244
+ cleanup();
245
+ codeReject(new Error('MCP OAuth callback missing authorization code'));
246
+ return;
247
+ }
248
+
249
+ res.writeHead(200, { 'Content-Type': 'text/html' });
250
+ res.end(renderPage('Authorization successful! You can close this tab.', true));
251
+ cleanup();
252
+ codeResolve(code);
253
+ });
254
+
255
+ this.callbackServer = server;
256
+
257
+ const timeout = setTimeout(() => {
258
+ if (!settled) {
259
+ settled = true;
260
+ cleanup();
261
+ codeReject(new Error('MCP OAuth callback timed out'));
262
+ }
263
+ }, CALLBACK_TIMEOUT_MS);
264
+ if (typeof timeout === 'object' && 'unref' in timeout) timeout.unref();
265
+ this.callbackTimeout = timeout;
266
+
267
+ const cleanup = () => {
268
+ if (this.callbackTimeout) {
269
+ clearTimeout(this.callbackTimeout);
270
+ this.callbackTimeout = null;
271
+ }
272
+ if (this.callbackServer) {
273
+ this.callbackServer.close();
274
+ this.callbackServer = null;
275
+ }
276
+ };
277
+
278
+ server.listen(0, '127.0.0.1', () => {
279
+ const addr = server.address() as { port: number };
280
+ this._redirectUrl = `http://127.0.0.1:${addr.port}${CALLBACK_PATH}`;
281
+ listening = true;
282
+ log.info({ serverId: this.serverId, redirectUrl: this._redirectUrl }, 'OAuth callback server started');
283
+ resolveSetup({ codePromise });
284
+ });
285
+
286
+ server.on('error', (err) => {
287
+ const message = `MCP OAuth callback server error: ${err.message}`;
288
+ if (!listening) {
289
+ settled = true;
290
+ cleanup();
291
+ rejectSetup(new Error(message));
292
+ } else if (!settled) {
293
+ settled = true;
294
+ cleanup();
295
+ codeReject(new Error(message));
296
+ }
297
+ });
298
+ });
299
+ }
300
+
301
+ /** Returns the code promise from the running callback server. */
302
+ waitForCode(): Promise<string> {
303
+ if (!this._codePromise) {
304
+ throw new Error('Callback server not started — call startCallbackServer() first');
305
+ }
306
+ return this._codePromise;
307
+ }
308
+
309
+ /** Stop the callback server if it's still running. */
310
+ stopCallbackServer(): void {
311
+ if (this.callbackTimeout) {
312
+ clearTimeout(this.callbackTimeout);
313
+ this.callbackTimeout = null;
314
+ }
315
+ if (this.callbackServer) {
316
+ this.callbackServer.close();
317
+ this.callbackServer = null;
318
+ }
319
+ }
320
+ }
321
+
322
+ // --- Static helpers ---
323
+
324
+ /**
325
+ * Delete all OAuth credentials for a given MCP server.
326
+ * Used by `mcp remove` for cleanup.
327
+ */
328
+ export async function deleteMcpOAuthCredentials(serverId: string): Promise<void> {
329
+ await Promise.all([
330
+ deleteSecureKeyAsync(tokensKey(serverId)),
331
+ deleteSecureKeyAsync(clientInfoKey(serverId)),
332
+ deleteSecureKeyAsync(discoveryKey(serverId)),
333
+ ]);
334
+ log.info({ serverId }, 'OAuth credentials deleted');
335
+ }
336
+
337
+ // --- HTML rendering ---
338
+
339
+ function escapeHtml(s: string): string {
340
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
341
+ }
342
+
343
+ function renderPage(message: string, success: boolean): string {
344
+ const title = success ? 'Authorization Successful' : 'Authorization Failed';
345
+ const color = success ? '#4CAF50' : '#f44336';
346
+ return `<!DOCTYPE html><html><head><title>${escapeHtml(title)}</title><style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5}div{text-align:center;padding:2rem;background:white;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,0.1)}h1{color:${color}}</style></head><body><div><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p></div></body></html>`;
347
+ }
@@ -33,6 +33,7 @@ export {
33
33
  getDeadLetterEvents,
34
34
  getRetryableEvents,
35
35
  markProcessed,
36
+ markRetryableFailure,
36
37
  recordProcessingFailure,
37
38
  replayDeadLetters,
38
39
  } from './delivery-status.js';
@@ -33,6 +33,7 @@ import {
33
33
  migrateGuardianBootstrapToken,
34
34
  migrateGuardianDeliveryConversationIndex,
35
35
  migrateGuardianPrincipalIdColumns,
36
+ migrateGuardianPrincipalIdNotNull,
36
37
  migrateGuardianVerificationPurpose,
37
38
  migrateGuardianVerificationSessions,
38
39
  migrateMessagesFtsBackfill,
@@ -185,5 +186,8 @@ export function initializeDb(): void {
185
186
  // 30. Backfill guardianPrincipalId for existing bindings and requests, expire unresolvable pending requests
186
187
  migrateBackfillGuardianPrincipalId(database);
187
188
 
189
+ // 31. Enforce NOT NULL on channel_guardian_bindings.guardian_principal_id
190
+ migrateGuardianPrincipalIdNotNull(database);
191
+
188
192
  validateMigrationState(database);
189
193
  }
@@ -107,6 +107,49 @@ export function recordProcessingFailure(eventId: string, err: unknown): void {
107
107
  }
108
108
  }
109
109
 
110
+ /**
111
+ * Mark an event as failed with a specific error message, bypassing error
112
+ * classification. Use this when the failure reason is known and the event
113
+ * should remain retryable (up to max attempts).
114
+ */
115
+ export function markRetryableFailure(eventId: string, errorMessage: string): void {
116
+ const db = getDb();
117
+ const now = Date.now();
118
+
119
+ const row = db
120
+ .select({ attempts: channelInboundEvents.processingAttempts })
121
+ .from(channelInboundEvents)
122
+ .where(eq(channelInboundEvents.id, eventId))
123
+ .get();
124
+
125
+ const attempts = (row?.attempts ?? 0) + 1;
126
+
127
+ if (attempts >= RETRY_MAX_ATTEMPTS) {
128
+ db.update(channelInboundEvents)
129
+ .set({
130
+ processingStatus: 'dead_letter',
131
+ processingAttempts: attempts,
132
+ lastProcessingError: errorMessage,
133
+ retryAfter: null,
134
+ updatedAt: now,
135
+ })
136
+ .where(eq(channelInboundEvents.id, eventId))
137
+ .run();
138
+ } else {
139
+ const delay = retryDelayForAttempt(attempts);
140
+ db.update(channelInboundEvents)
141
+ .set({
142
+ processingStatus: 'failed',
143
+ processingAttempts: attempts,
144
+ lastProcessingError: errorMessage,
145
+ retryAfter: now + delay,
146
+ updatedAt: now,
147
+ })
148
+ .where(eq(channelInboundEvents.id, eventId))
149
+ .run();
150
+ }
151
+ }
152
+
110
153
  /** Fetch events eligible for automatic retry (failed + past their backoff). */
111
154
  export function getRetryableEvents(limit = 20): Array<{
112
155
  id: string;
@@ -23,7 +23,7 @@ export interface GuardianBinding {
23
23
  channel: string;
24
24
  guardianExternalUserId: string;
25
25
  guardianDeliveryChatId: string;
26
- guardianPrincipalId: string | null;
26
+ guardianPrincipalId: string;
27
27
  status: BindingStatus;
28
28
  verifiedAt: number;
29
29
  verifiedVia: string;
@@ -62,7 +62,7 @@ export function createBinding(params: {
62
62
  channel: string;
63
63
  guardianExternalUserId: string;
64
64
  guardianDeliveryChatId: string;
65
- guardianPrincipalId?: string | null;
65
+ guardianPrincipalId: string;
66
66
  verifiedVia?: string;
67
67
  metadataJson?: string | null;
68
68
  }): GuardianBinding {
@@ -76,7 +76,7 @@ export function createBinding(params: {
76
76
  channel: params.channel,
77
77
  guardianExternalUserId: params.guardianExternalUserId,
78
78
  guardianDeliveryChatId: params.guardianDeliveryChatId,
79
- guardianPrincipalId: params.guardianPrincipalId ?? null,
79
+ guardianPrincipalId: params.guardianPrincipalId,
80
80
  status: 'active' as const,
81
81
  verifiedAt: now,
82
82
  verifiedVia: params.verifiedVia ?? 'challenge',
@@ -0,0 +1,108 @@
1
+ import { type DrizzleDb, getSqliteFrom } from '../db-connection.js';
2
+ import { withCrashRecovery } from './validate-migration-state.js';
3
+
4
+ /**
5
+ * Enforce NOT NULL on channel_guardian_bindings.guardian_principal_id.
6
+ *
7
+ * Migration 125 added the column as nullable, and migration 126 backfilled
8
+ * existing rows. This migration:
9
+ *
10
+ * 1. Backfills any remaining null guardian_principal_id rows with
11
+ * guardian_external_user_id as a sensible default (same fallback
12
+ * strategy used by migration 126).
13
+ * 2. Rebuilds the table to add a NOT NULL constraint on the column
14
+ * (SQLite does not support ALTER COLUMN).
15
+ *
16
+ * Idempotent: checks the DDL before rebuilding; skips if the column
17
+ * already has NOT NULL.
18
+ */
19
+ export function migrateGuardianPrincipalIdNotNull(database: DrizzleDb): void {
20
+ withCrashRecovery(database, 'migration_guardian_principal_id_not_null_v1', () => {
21
+ const raw = getSqliteFrom(database);
22
+
23
+ // Guard: table must exist
24
+ const tableExists = raw.query(
25
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_guardian_bindings'`,
26
+ ).get();
27
+ if (!tableExists) return;
28
+
29
+ // Guard: column must exist (added by migration 125)
30
+ const colExists = raw.query(
31
+ `SELECT 1 FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
32
+ ).get();
33
+ if (!colExists) return;
34
+
35
+ // Check if the column already has NOT NULL (idempotency)
36
+ const colInfo = raw.query(
37
+ `SELECT "notnull" FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
38
+ ).get() as { notnull: number } | null;
39
+ if (colInfo && colInfo.notnull === 1) return;
40
+
41
+ raw.exec('PRAGMA foreign_keys = OFF');
42
+ try {
43
+ raw.exec('BEGIN');
44
+
45
+ // Backfill any remaining null rows before adding the constraint
46
+ raw.exec(/*sql*/ `
47
+ UPDATE channel_guardian_bindings
48
+ SET guardian_principal_id = guardian_external_user_id,
49
+ updated_at = ${Date.now()}
50
+ WHERE guardian_principal_id IS NULL
51
+ AND guardian_external_user_id IS NOT NULL
52
+ `);
53
+
54
+ // For any rows where even guardian_external_user_id is null (shouldn't
55
+ // happen but defensive), use 'unknown' as a placeholder
56
+ raw.exec(/*sql*/ `
57
+ UPDATE channel_guardian_bindings
58
+ SET guardian_principal_id = 'unknown',
59
+ updated_at = ${Date.now()}
60
+ WHERE guardian_principal_id IS NULL
61
+ `);
62
+
63
+ // Rebuild the table with NOT NULL on guardian_principal_id
64
+ raw.exec(/*sql*/ `
65
+ CREATE TABLE channel_guardian_bindings_new (
66
+ id TEXT PRIMARY KEY,
67
+ assistant_id TEXT NOT NULL,
68
+ channel TEXT NOT NULL,
69
+ guardian_external_user_id TEXT NOT NULL,
70
+ guardian_delivery_chat_id TEXT NOT NULL,
71
+ guardian_principal_id TEXT NOT NULL,
72
+ status TEXT NOT NULL DEFAULT 'active',
73
+ verified_at INTEGER NOT NULL,
74
+ verified_via TEXT NOT NULL DEFAULT 'challenge',
75
+ metadata_json TEXT,
76
+ created_at INTEGER NOT NULL,
77
+ updated_at INTEGER NOT NULL
78
+ )
79
+ `);
80
+
81
+ raw.exec(/*sql*/ `
82
+ INSERT INTO channel_guardian_bindings_new
83
+ SELECT id, assistant_id, channel, guardian_external_user_id,
84
+ guardian_delivery_chat_id, guardian_principal_id,
85
+ status, verified_at, verified_via, metadata_json,
86
+ created_at, updated_at
87
+ FROM channel_guardian_bindings
88
+ `);
89
+
90
+ raw.exec(/*sql*/ `DROP TABLE channel_guardian_bindings`);
91
+ raw.exec(/*sql*/ `ALTER TABLE channel_guardian_bindings_new RENAME TO channel_guardian_bindings`);
92
+
93
+ // Recreate the unique index for active bindings
94
+ raw.exec(/*sql*/ `
95
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_bindings_active
96
+ ON channel_guardian_bindings(assistant_id, channel)
97
+ WHERE status = 'active'
98
+ `);
99
+
100
+ raw.exec('COMMIT');
101
+ } catch (e) {
102
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
103
+ throw e;
104
+ } finally {
105
+ raw.exec('PRAGMA foreign_keys = ON');
106
+ }
107
+ });
108
+ }
@@ -68,6 +68,7 @@ export { migrateCanonicalGuardianDeliveriesDestinationIndex } from './123-canoni
68
68
  export { migrateVoiceInviteDisplayMetadata } from './124-voice-invite-display-metadata.js';
69
69
  export { migrateGuardianPrincipalIdColumns } from './125-guardian-principal-id-columns.js';
70
70
  export { migrateBackfillGuardianPrincipalId } from './126-backfill-guardian-principal-id.js';
71
+ export { migrateGuardianPrincipalIdNotNull } from './127-guardian-principal-id-not-null.js';
71
72
  export {
72
73
  MIGRATION_REGISTRY,
73
74
  type MigrationRegistryEntry,
@@ -100,6 +100,12 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
100
100
  version: 15,
101
101
  description: 'Backfill guardianPrincipalId for existing channel_guardian_bindings and canonical_guardian_requests rows, expire unresolvable pending requests',
102
102
  },
103
+ {
104
+ key: 'migration_guardian_principal_id_not_null_v1',
105
+ version: 16,
106
+ dependsOn: ['migration_backfill_guardian_principal_id_v3'],
107
+ description: 'Enforce NOT NULL on channel_guardian_bindings.guardian_principal_id after backfill',
108
+ },
103
109
  ];
104
110
 
105
111
  export interface MigrationValidationResult {
@@ -637,7 +637,7 @@ export const channelGuardianBindings = sqliteTable('channel_guardian_bindings',
637
637
  channel: text('channel').notNull(),
638
638
  guardianExternalUserId: text('guardian_external_user_id').notNull(),
639
639
  guardianDeliveryChatId: text('guardian_delivery_chat_id').notNull(),
640
- guardianPrincipalId: text('guardian_principal_id'),
640
+ guardianPrincipalId: text('guardian_principal_id').notNull(),
641
641
  status: text('status').notNull().default('active'),
642
642
  verifiedAt: integer('verified_at').notNull(),
643
643
  verifiedVia: text('verified_via').notNull().default('challenge'),
@@ -20,6 +20,8 @@ import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
20
20
  import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
21
21
  import { getGuardianBinding } from './channel-guardian-service.js';
22
22
 
23
+ export type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
24
+
23
25
  // ---------------------------------------------------------------------------
24
26
  // Types
25
27
  // ---------------------------------------------------------------------------
@@ -35,8 +37,8 @@ export interface ActorTrustContext {
35
37
  guardianExternalUserId: string;
36
38
  guardianDeliveryChatId: string | null;
37
39
  } | null;
38
- /** Canonical principal ID from the guardian binding. Nullable for backward compatibility — M5 will make this required. */
39
- guardianPrincipalId?: string | null;
40
+ /** Canonical principal ID from the guardian binding. */
41
+ guardianPrincipalId?: string;
40
42
  /** Ingress member record, if any, for this sender. */
41
43
  memberRecord: IngressMember | null;
42
44
  /** Trust classification. */
@@ -184,7 +186,7 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
184
186
  return {
185
187
  canonicalSenderId,
186
188
  guardianBindingMatch,
187
- guardianPrincipalId: binding?.guardianPrincipalId ?? undefined,
189
+ guardianPrincipalId: binding?.guardianPrincipalId,
188
190
  memberRecord,
189
191
  trustClass,
190
192
  actorMetadata: {
@@ -203,17 +205,24 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
203
205
  /**
204
206
  * Convert an ActorTrustContext into the runtime trust context shape used by
205
207
  * sessions/tooling.
208
+ *
209
+ * This is the single canonical conversion from resolved trust to runtime
210
+ * context. The guardianExternalUserId is canonicalized to handle phone-
211
+ * channel formatting variance (e.g. stored binding vs E.164).
206
212
  */
207
213
  export function toGuardianRuntimeContextFromTrust(
208
214
  ctx: ActorTrustContext,
209
215
  conversationExternalId: string,
210
216
  ): GuardianRuntimeContext {
217
+ const canonicalGuardianExternalUserId = ctx.guardianBindingMatch?.guardianExternalUserId
218
+ ? canonicalizeInboundIdentity(ctx.actorMetadata.channel, ctx.guardianBindingMatch.guardianExternalUserId) ?? undefined
219
+ : undefined;
211
220
  return {
212
221
  sourceChannel: ctx.actorMetadata.channel,
213
222
  trustClass: ctx.trustClass,
214
223
  guardianChatId: ctx.guardianBindingMatch?.guardianDeliveryChatId ??
215
224
  (ctx.trustClass === 'guardian' ? conversationExternalId : undefined),
216
- guardianExternalUserId: ctx.guardianBindingMatch?.guardianExternalUserId,
225
+ guardianExternalUserId: canonicalGuardianExternalUserId,
217
226
  guardianPrincipalId: ctx.guardianPrincipalId,
218
227
  requesterIdentifier: ctx.actorMetadata.identifier,
219
228
  requesterDisplayName: ctx.actorMetadata.displayName,