@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.
- package/ARCHITECTURE.md +24 -0
- package/Dockerfile +1 -1
- package/README.md +16 -9
- package/package.json +1 -1
- package/src/__tests__/account-registry.test.ts +1 -0
- package/src/__tests__/actor-token-service.test.ts +1 -0
- package/src/__tests__/app-builder-tool-scripts.test.ts +1 -0
- package/src/__tests__/asset-materialize-tool.test.ts +7 -0
- package/src/__tests__/asset-search-tool.test.ts +7 -0
- package/src/__tests__/browser-fill-credential.test.ts +1 -0
- package/src/__tests__/call-start-guardian-guard.test.ts +1 -0
- package/src/__tests__/channel-approval-routes.test.ts +29 -0
- package/src/__tests__/channel-guardian.test.ts +2143 -1546
- package/src/__tests__/channel-retry-sweep.test.ts +169 -14
- package/src/__tests__/claude-code-tool-profiles.test.ts +1 -0
- package/src/__tests__/computer-use-tools.test.ts +1 -0
- package/src/__tests__/contacts-tools.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +1 -0
- package/src/__tests__/credential-policy-validate.test.ts +97 -0
- package/src/__tests__/credential-security-e2e.test.ts +1 -0
- package/src/__tests__/credential-vault-unit.test.ts +1 -0
- package/src/__tests__/credential-vault.test.ts +1 -0
- package/src/__tests__/delete-managed-skill-tool.test.ts +1 -0
- package/src/__tests__/file-edit-tool.test.ts +1 -0
- package/src/__tests__/file-read-tool.test.ts +1 -0
- package/src/__tests__/file-write-tool.test.ts +1 -0
- package/src/__tests__/followup-tools.test.ts +1 -0
- package/src/__tests__/gateway-only-guard.test.ts +1 -1
- package/src/__tests__/guardian-control-plane-policy.test.ts +5 -4
- package/src/__tests__/guardian-grant-minting.test.ts +3 -0
- package/src/__tests__/guardian-principal-id-roundtrip.test.ts +4 -3
- package/src/__tests__/guardian-routing-state.test.ts +8 -0
- package/src/__tests__/headless-browser-interactions.test.ts +1 -0
- package/src/__tests__/headless-browser-navigate.test.ts +1 -0
- package/src/__tests__/headless-browser-read-tools.test.ts +1 -0
- package/src/__tests__/headless-browser-snapshot.test.ts +1 -0
- package/src/__tests__/host-file-edit-tool.test.ts +1 -0
- package/src/__tests__/host-file-read-tool.test.ts +1 -0
- package/src/__tests__/host-file-write-tool.test.ts +1 -0
- package/src/__tests__/host-shell-tool.test.ts +1 -0
- package/src/__tests__/lifecycle-docs-guard.test.ts +207 -0
- package/src/__tests__/managed-skill-lifecycle.test.ts +1 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +8 -0
- package/src/__tests__/messaging-send-tool.test.ts +1 -0
- package/src/__tests__/playbook-execution.test.ts +1 -0
- package/src/__tests__/playbook-tools.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +4 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +1 -0
- package/src/__tests__/schedule-tools.test.ts +1 -0
- package/src/__tests__/secret-onetime-send.test.ts +4 -0
- package/src/__tests__/secret-scanner-executor.test.ts +2 -0
- package/src/__tests__/send-notification-tool.test.ts +2 -0
- package/src/__tests__/shell-credential-ref.test.ts +1 -0
- package/src/__tests__/shell-tool-proxy-mode.test.ts +1 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
- package/src/__tests__/skill-load-tool.test.ts +1 -0
- package/src/__tests__/skill-script-runner-host.test.ts +1 -0
- package/src/__tests__/skill-script-runner-sandbox.test.ts +1 -0
- package/src/__tests__/skill-script-runner.test.ts +1 -0
- package/src/__tests__/skill-tool-factory.test.ts +1 -0
- package/src/__tests__/subagent-tools.test.ts +1 -1
- package/src/__tests__/swarm-recursion.test.ts +1 -0
- package/src/__tests__/swarm-session-integration.test.ts +1 -0
- package/src/__tests__/swarm-tool.test.ts +1 -0
- package/src/__tests__/task-management-tools.test.ts +1 -0
- package/src/__tests__/task-tools.test.ts +1 -0
- package/src/__tests__/terminal-tools.test.ts +1 -0
- package/src/__tests__/tool-approval-handler.test.ts +2 -2
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -0
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +1 -0
- package/src/__tests__/tool-executor.test.ts +1 -0
- package/src/__tests__/trust-context-guards.test.ts +218 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +6 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +1 -0
- package/src/__tests__/trusted-contact-verification.test.ts +1 -0
- package/src/__tests__/view-image-tool.test.ts +1 -0
- package/src/calls/guardian-dispatch.ts +4 -4
- package/src/cli/mcp.ts +183 -3
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -4
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +1 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +17 -119
- package/src/config/system-prompt.ts +4 -2
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/computer-use-session.ts +1 -0
- package/src/daemon/session-agent-loop.ts +1 -1
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-runtime-assembly.ts +2 -2
- package/src/daemon/session-tool-setup.ts +1 -1
- package/src/mcp/client.ts +55 -6
- package/src/mcp/manager.ts +9 -0
- package/src/mcp/mcp-oauth-provider.ts +347 -0
- package/src/memory/channel-delivery-store.ts +1 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/delivery-status.ts +43 -0
- package/src/memory/guardian-bindings.ts +3 -3
- package/src/memory/migrations/127-guardian-principal-id-not-null.ts +108 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +6 -0
- package/src/memory/schema.ts +1 -1
- package/src/runtime/actor-trust-resolver.ts +13 -4
- package/src/runtime/channel-retry-sweep.ts +31 -14
- package/src/runtime/guardian-context-resolver.ts +25 -64
- package/src/runtime/guardian-outbound-actions.ts +399 -108
- package/src/runtime/guardian-vellum-migration.ts +1 -23
- package/src/runtime/guardian-verification-templates.ts +66 -30
- package/src/runtime/local-actor-identity.ts +4 -6
- package/src/runtime/middleware/actor-token.ts +2 -8
- package/src/runtime/routes/channel-route-shared.ts +0 -1
- package/src/runtime/routes/inbound-message-handler.ts +3 -4
- package/src/runtime/tool-grant-request-helper.ts +1 -1
- package/src/tools/credentials/policy-validate.ts +22 -0
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
}
|
package/src/memory/db-init.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 {
|
package/src/memory/schema.ts
CHANGED
|
@@ -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.
|
|
39
|
-
guardianPrincipalId?: string
|
|
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
|
|
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:
|
|
225
|
+
guardianExternalUserId: canonicalGuardianExternalUserId,
|
|
217
226
|
guardianPrincipalId: ctx.guardianPrincipalId,
|
|
218
227
|
requesterIdentifier: ctx.actorMetadata.identifier,
|
|
219
228
|
requesterDisplayName: ctx.actorMetadata.displayName,
|