vellum 0.2.7 → 0.2.8
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/bun.lock +2 -2
- package/package.json +2 -2
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
- package/src/__tests__/ipc-snapshot.test.ts +11 -0
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
- package/src/__tests__/public-ingress-urls.test.ts +206 -0
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/calls/twilio-config.ts +17 -1
- package/src/calls/twilio-routes.ts +10 -2
- package/src/calls/twilio-webhook-urls.ts +18 -21
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +30 -2
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +51 -2
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +16 -1
- package/src/daemon/session-tool-setup.ts +7 -0
- package/src/inbound/public-ingress-urls.ts +106 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +346 -149
- package/src/memory/runs-store.ts +0 -3
- package/src/memory/schema.ts +0 -4
- package/src/runtime/http-server.ts +84 -2
- package/src/security/oauth-callback-registry.ts +56 -0
- package/src/security/oauth2.ts +174 -58
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/claude-code/claude-code.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/workspace/provider-commit-message-generator.ts +39 -23
- package/src/workspace/turn-commit.ts +6 -2
|
@@ -12,7 +12,7 @@ import { ConfigError, IngressBlockedError } from '../util/errors.js';
|
|
|
12
12
|
import { getLogger } from '../util/logger.js';
|
|
13
13
|
import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
|
|
14
14
|
import { loadConfig } from '../config/loader.js';
|
|
15
|
-
import {
|
|
15
|
+
import { getPublicBaseUrl } from '../inbound/public-ingress-urls.js';
|
|
16
16
|
import type { RunOrchestrator } from './run-orchestrator.js';
|
|
17
17
|
|
|
18
18
|
// Route handlers — grouped by domain
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
} from '../calls/twilio-routes.js';
|
|
66
66
|
import { RelayConnection, activeRelayConnections } from '../calls/relay-server.js';
|
|
67
67
|
import type { RelayWebSocketData } from '../calls/relay-server.js';
|
|
68
|
+
import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
|
|
68
69
|
|
|
69
70
|
// Re-export shared types so existing consumers don't need to update imports
|
|
70
71
|
export type {
|
|
@@ -137,6 +138,35 @@ const GATEWAY_SUBPATH_MAP: Record<string, string> = {
|
|
|
137
138
|
'connect-action': 'connect-action',
|
|
138
139
|
};
|
|
139
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Direct Twilio webhook subpaths that are blocked in gateway_only mode.
|
|
143
|
+
* Internal forwarding endpoints (gateway→runtime) are unaffected.
|
|
144
|
+
*/
|
|
145
|
+
const GATEWAY_ONLY_BLOCKED_SUBPATHS = new Set(['voice-webhook', 'status', 'connect-action']);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if a request origin is from localhost / loopback.
|
|
149
|
+
*/
|
|
150
|
+
function isLoopbackOrigin(req: Request): boolean {
|
|
151
|
+
const origin = req.headers.get('origin');
|
|
152
|
+
// No origin header (e.g., server-initiated or same-origin) — allow
|
|
153
|
+
if (!origin) return true;
|
|
154
|
+
try {
|
|
155
|
+
const url = new URL(origin);
|
|
156
|
+
const host = url.hostname;
|
|
157
|
+
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if a hostname is a loopback address.
|
|
165
|
+
*/
|
|
166
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
167
|
+
return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost';
|
|
168
|
+
}
|
|
169
|
+
|
|
140
170
|
/**
|
|
141
171
|
* Validate a Twilio webhook request's X-Twilio-Signature header.
|
|
142
172
|
*
|
|
@@ -186,7 +216,7 @@ async function validateTwilioWebhook(
|
|
|
186
216
|
// used to compute the HMAC-SHA1 signature.
|
|
187
217
|
let publicBaseUrl: string | undefined;
|
|
188
218
|
try {
|
|
189
|
-
publicBaseUrl =
|
|
219
|
+
publicBaseUrl = getPublicBaseUrl(loadConfig());
|
|
190
220
|
} catch {
|
|
191
221
|
// No webhook base URL configured — fall back to using req.url as-is
|
|
192
222
|
}
|
|
@@ -289,6 +319,19 @@ export class RuntimeHttpServer {
|
|
|
289
319
|
}, 30_000);
|
|
290
320
|
}
|
|
291
321
|
|
|
322
|
+
// Startup guard: log gateway-only mode warnings
|
|
323
|
+
try {
|
|
324
|
+
const config = loadConfig();
|
|
325
|
+
if (config.ingress.mode === 'gateway_only') {
|
|
326
|
+
log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
|
|
327
|
+
if (!isLoopbackHost(this.hostname)) {
|
|
328
|
+
log.warn('gateway-only mode is enabled but RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// Config loading may fail during startup — don't block server start
|
|
333
|
+
}
|
|
334
|
+
|
|
292
335
|
log.info({ port: this.port, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
|
|
293
336
|
}
|
|
294
337
|
|
|
@@ -327,6 +370,15 @@ export class RuntimeHttpServer {
|
|
|
327
370
|
// WebSocket upgrade for ConversationRelay — before auth check because
|
|
328
371
|
// Twilio WebSocket connections don't use bearer tokens.
|
|
329
372
|
if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
373
|
+
// In gateway_only mode, only allow relay connections from localhost
|
|
374
|
+
const config = loadConfig();
|
|
375
|
+
if (config.ingress.mode === 'gateway_only' && !isLoopbackOrigin(req)) {
|
|
376
|
+
return Response.json(
|
|
377
|
+
{ error: 'Direct relay access disabled in gateway-only mode', code: 'GATEWAY_ONLY' },
|
|
378
|
+
{ status: 403 },
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
330
382
|
const wsUrl = new URL(req.url);
|
|
331
383
|
const callSessionId = wsUrl.searchParams.get('callSessionId');
|
|
332
384
|
if (!callSessionId) {
|
|
@@ -356,6 +408,15 @@ export class RuntimeHttpServer {
|
|
|
356
408
|
if (resolvedTwilioSubpath && req.method === 'POST') {
|
|
357
409
|
const twilioSubpath = resolvedTwilioSubpath;
|
|
358
410
|
|
|
411
|
+
// In gateway_only mode, block direct Twilio webhook routes
|
|
412
|
+
const ingressConfig = loadConfig();
|
|
413
|
+
if (ingressConfig.ingress.mode === 'gateway_only' && GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
|
|
414
|
+
return Response.json(
|
|
415
|
+
{ error: 'Direct webhook access disabled in gateway-only mode. Use the gateway.', code: 'GATEWAY_ONLY' },
|
|
416
|
+
{ status: 410 },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
359
420
|
// Validate Twilio request signature before dispatching
|
|
360
421
|
const validation = await validateTwilioWebhook(req);
|
|
361
422
|
if (validation instanceof Response) return validation;
|
|
@@ -628,6 +689,27 @@ export class RuntimeHttpServer {
|
|
|
628
689
|
return await handleConnectAction(fakeReq);
|
|
629
690
|
}
|
|
630
691
|
|
|
692
|
+
// ── Internal OAuth callback endpoint (gateway → runtime) ──
|
|
693
|
+
if (endpoint === 'internal/oauth/callback' && req.method === 'POST') {
|
|
694
|
+
const json = await req.json() as { state: string; code?: string; error?: string };
|
|
695
|
+
if (!json.state) {
|
|
696
|
+
return Response.json({ error: 'Missing state parameter' }, { status: 400 });
|
|
697
|
+
}
|
|
698
|
+
if (json.error) {
|
|
699
|
+
const consumed = consumeCallbackError(json.state, json.error);
|
|
700
|
+
return consumed
|
|
701
|
+
? Response.json({ ok: true })
|
|
702
|
+
: Response.json({ error: 'Unknown state' }, { status: 404 });
|
|
703
|
+
}
|
|
704
|
+
if (json.code) {
|
|
705
|
+
const consumed = consumeCallback(json.state, json.code);
|
|
706
|
+
return consumed
|
|
707
|
+
? Response.json({ ok: true })
|
|
708
|
+
: Response.json({ error: 'Unknown state' }, { status: 404 });
|
|
709
|
+
}
|
|
710
|
+
return Response.json({ error: 'Missing code or error parameter' }, { status: 400 });
|
|
711
|
+
}
|
|
712
|
+
|
|
631
713
|
return Response.json({ error: 'Not found', source: 'runtime' }, { status: 404 });
|
|
632
714
|
} catch (err) {
|
|
633
715
|
if (err instanceof IngressBlockedError) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory registry for pending OAuth callback states.
|
|
3
|
+
* Used by the gateway-routed OAuth flow to resolve authorization codes
|
|
4
|
+
* back to the runtime code that initiated the OAuth handshake.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface PendingCallback {
|
|
8
|
+
resolve: (code: string) => void;
|
|
9
|
+
reject: (error: Error) => void;
|
|
10
|
+
timer: ReturnType<typeof setTimeout>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const pendingCallbacks = new Map<string, PendingCallback>();
|
|
14
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
15
|
+
|
|
16
|
+
export function registerPendingCallback(
|
|
17
|
+
state: string,
|
|
18
|
+
resolve: (code: string) => void,
|
|
19
|
+
reject: (error: Error) => void,
|
|
20
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
21
|
+
): void {
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
const entry = pendingCallbacks.get(state);
|
|
24
|
+
if (entry) {
|
|
25
|
+
pendingCallbacks.delete(state);
|
|
26
|
+
entry.reject(new Error('OAuth callback timed out'));
|
|
27
|
+
}
|
|
28
|
+
}, ttlMs);
|
|
29
|
+
|
|
30
|
+
pendingCallbacks.set(state, { resolve, reject, timer });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function consumeCallback(state: string, code: string): boolean {
|
|
34
|
+
const entry = pendingCallbacks.get(state);
|
|
35
|
+
if (!entry) return false;
|
|
36
|
+
clearTimeout(entry.timer);
|
|
37
|
+
pendingCallbacks.delete(state);
|
|
38
|
+
entry.resolve(code);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function consumeCallbackError(state: string, error: string): boolean {
|
|
43
|
+
const entry = pendingCallbacks.get(state);
|
|
44
|
+
if (!entry) return false;
|
|
45
|
+
clearTimeout(entry.timer);
|
|
46
|
+
pendingCallbacks.delete(state);
|
|
47
|
+
entry.reject(new Error(error));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clearAllCallbacks(): void {
|
|
52
|
+
for (const entry of pendingCallbacks.values()) {
|
|
53
|
+
clearTimeout(entry.timer);
|
|
54
|
+
}
|
|
55
|
+
pendingCallbacks.clear();
|
|
56
|
+
}
|
package/src/security/oauth2.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* General-purpose OAuth2 Authorization Code flow with PKCE.
|
|
3
3
|
*
|
|
4
|
+
* Supports two callback transports:
|
|
5
|
+
* - loopback: spins up a local HTTP server on 127.0.0.1 (default when no public URL configured)
|
|
6
|
+
* - gateway: uses the gateway's OAuth callback route + in-memory registry (when ingress.publicBaseUrl is set)
|
|
7
|
+
*
|
|
4
8
|
* Moved from integrations/oauth2.ts. Types that were in integrations/types.ts
|
|
5
9
|
* are now inlined here since the integration framework is removed.
|
|
6
10
|
*/
|
|
@@ -39,6 +43,11 @@ export interface OAuth2FlowCallbacks {
|
|
|
39
43
|
openUrl: (url: string) => void;
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
export interface OAuth2FlowOptions {
|
|
47
|
+
/** Which callback transport to use. When omitted, auto-detected from config. */
|
|
48
|
+
callbackTransport?: 'loopback' | 'gateway';
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
export interface OAuth2FlowResult {
|
|
43
52
|
tokens: OAuth2TokenResult;
|
|
44
53
|
grantedScopes: string[];
|
|
@@ -62,20 +71,107 @@ function generateState(): string {
|
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
// ---------------------------------------------------------------------------
|
|
65
|
-
//
|
|
74
|
+
// Token exchange (shared between transports)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
async function exchangeCodeForTokens(
|
|
78
|
+
config: OAuth2Config,
|
|
79
|
+
code: string,
|
|
80
|
+
redirectUri: string,
|
|
81
|
+
codeVerifier: string,
|
|
82
|
+
): Promise<OAuth2FlowResult> {
|
|
83
|
+
const usePKCE = !config.clientSecret;
|
|
84
|
+
|
|
85
|
+
const tokenBody: Record<string, string> = {
|
|
86
|
+
grant_type: 'authorization_code',
|
|
87
|
+
code,
|
|
88
|
+
redirect_uri: redirectUri,
|
|
89
|
+
client_id: config.clientId,
|
|
90
|
+
};
|
|
91
|
+
if (usePKCE) {
|
|
92
|
+
tokenBody.code_verifier = codeVerifier;
|
|
93
|
+
}
|
|
94
|
+
if (config.clientSecret) {
|
|
95
|
+
tokenBody.client_secret = config.clientSecret;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tokenResp = await fetch(config.tokenUrl, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
101
|
+
body: new URLSearchParams(tokenBody),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!tokenResp.ok) {
|
|
105
|
+
const rawBody = await tokenResp.text().catch(() => '');
|
|
106
|
+
let safeDetail: Record<string, unknown> = {};
|
|
107
|
+
let errorCode = '';
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(rawBody) as Record<string, unknown>;
|
|
110
|
+
if (parsed.error) { safeDetail.error = String(parsed.error); errorCode = String(parsed.error); }
|
|
111
|
+
if (parsed.error_description) safeDetail.error_description = String(parsed.error_description);
|
|
112
|
+
} catch {
|
|
113
|
+
safeDetail.error = '[non-JSON response]';
|
|
114
|
+
}
|
|
115
|
+
log.error({ status: tokenResp.status, ...safeDetail }, 'OAuth2 token exchange failed');
|
|
116
|
+
const detail = errorCode ? `HTTP ${tokenResp.status}: ${errorCode}` : `HTTP ${tokenResp.status}`;
|
|
117
|
+
throw new Error(`OAuth2 token exchange failed (${detail})`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const tokenData = await tokenResp.json() as Record<string, unknown>;
|
|
121
|
+
|
|
122
|
+
// Slack V2 OAuth returns user tokens nested under `authed_user`
|
|
123
|
+
const authedUser = tokenData.authed_user as Record<string, unknown> | undefined;
|
|
124
|
+
const tokenSource = authedUser?.access_token ? authedUser : tokenData;
|
|
125
|
+
|
|
126
|
+
const tokens: OAuth2TokenResult = {
|
|
127
|
+
accessToken: (tokenSource.access_token as string) ?? (tokenData.access_token as string),
|
|
128
|
+
refreshToken: (tokenSource.refresh_token as string | undefined) ?? (tokenData.refresh_token as string | undefined),
|
|
129
|
+
expiresIn: (tokenSource.expires_in as number | undefined) ?? (tokenData.expires_in as number | undefined),
|
|
130
|
+
scope: (tokenSource.scope as string | undefined) ?? (tokenData.scope as string | undefined),
|
|
131
|
+
tokenType: (tokenSource.token_type as string | undefined) ?? (tokenData.token_type as string | undefined),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const grantedScopes = typeof tokens.scope === 'string'
|
|
135
|
+
? tokens.scope.split(/[ ,]/).filter(Boolean)
|
|
136
|
+
: [...config.scopes];
|
|
137
|
+
|
|
138
|
+
return { tokens, grantedScopes, rawTokenResponse: tokenData };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Transport auto-detection
|
|
66
143
|
// ---------------------------------------------------------------------------
|
|
67
144
|
|
|
68
145
|
/**
|
|
69
|
-
*
|
|
146
|
+
* Determine which callback transport to use when not explicitly specified.
|
|
147
|
+
* Uses gateway if ingress.publicBaseUrl is configured, otherwise loopback.
|
|
70
148
|
*/
|
|
71
|
-
|
|
149
|
+
function detectTransport(): 'loopback' | 'gateway' {
|
|
150
|
+
try {
|
|
151
|
+
// Dynamic import avoided — loadConfig is synchronous and already used elsewhere.
|
|
152
|
+
const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
|
|
153
|
+
const appConfig = loadConfig();
|
|
154
|
+
if (appConfig.ingress?.publicBaseUrl) {
|
|
155
|
+
return 'gateway';
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Config loading failed — fall back to loopback
|
|
159
|
+
log.debug('Config not available for transport auto-detection, defaulting to loopback');
|
|
160
|
+
}
|
|
161
|
+
return 'loopback';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Loopback transport
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
async function runLoopbackFlow(
|
|
72
169
|
config: OAuth2Config,
|
|
73
170
|
callbacks: OAuth2FlowCallbacks,
|
|
171
|
+
codeVerifier: string,
|
|
172
|
+
codeChallenge: string,
|
|
173
|
+
state: string,
|
|
74
174
|
): Promise<OAuth2FlowResult> {
|
|
75
|
-
const codeVerifier = generateCodeVerifier();
|
|
76
|
-
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
77
|
-
const state = generateState();
|
|
78
|
-
|
|
79
175
|
let resolveCode: (value: { code: string; returnedState: string }) => void;
|
|
80
176
|
let rejectCode: (reason: Error) => void;
|
|
81
177
|
|
|
@@ -84,7 +180,6 @@ export async function startOAuth2Flow(
|
|
|
84
180
|
rejectCode = reject;
|
|
85
181
|
});
|
|
86
182
|
|
|
87
|
-
/** How long to wait for the user to complete the OAuth consent flow. */
|
|
88
183
|
const FLOW_TIMEOUT_MS = 120_000;
|
|
89
184
|
|
|
90
185
|
const timeout = setTimeout(() => {
|
|
@@ -153,64 +248,85 @@ export async function startOAuth2Flow(
|
|
|
153
248
|
throw new Error('OAuth2 state mismatch — possible CSRF attack');
|
|
154
249
|
}
|
|
155
250
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (usePKCE) {
|
|
163
|
-
tokenBody.code_verifier = codeVerifier;
|
|
164
|
-
}
|
|
165
|
-
if (config.clientSecret) {
|
|
166
|
-
tokenBody.client_secret = config.clientSecret;
|
|
167
|
-
}
|
|
251
|
+
return await exchangeCodeForTokens(config, code, redirectUri, codeVerifier);
|
|
252
|
+
} finally {
|
|
253
|
+
clearTimeout(timeout);
|
|
254
|
+
server.stop(true);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
168
257
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
body: new URLSearchParams(tokenBody),
|
|
173
|
-
});
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Gateway transport
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
174
261
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
log.error({ status: tokenResp.status, ...safeDetail }, 'OAuth2 token exchange failed');
|
|
187
|
-
const detail = errorCode ? `HTTP ${tokenResp.status}: ${errorCode}` : `HTTP ${tokenResp.status}`;
|
|
188
|
-
throw new Error(`OAuth2 token exchange failed (${detail})`);
|
|
189
|
-
}
|
|
262
|
+
async function runGatewayFlow(
|
|
263
|
+
config: OAuth2Config,
|
|
264
|
+
callbacks: OAuth2FlowCallbacks,
|
|
265
|
+
codeVerifier: string,
|
|
266
|
+
codeChallenge: string,
|
|
267
|
+
state: string,
|
|
268
|
+
): Promise<OAuth2FlowResult> {
|
|
269
|
+
const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
|
|
270
|
+
const { getOAuthCallbackUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
|
|
271
|
+
const { registerPendingCallback } = require('./oauth-callback-registry.js') as typeof import('./oauth-callback-registry.js');
|
|
190
272
|
|
|
191
|
-
|
|
273
|
+
const appConfig = loadConfig();
|
|
274
|
+
const redirectUri = getOAuthCallbackUrl(appConfig);
|
|
192
275
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
276
|
+
const codePromise = new Promise<string>((resolve, reject) => {
|
|
277
|
+
registerPendingCallback(state, resolve, reject);
|
|
278
|
+
});
|
|
196
279
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
280
|
+
const usePKCE = !config.clientSecret;
|
|
281
|
+
const authParams = new URLSearchParams({
|
|
282
|
+
...config.extraParams,
|
|
283
|
+
client_id: config.clientId,
|
|
284
|
+
redirect_uri: redirectUri,
|
|
285
|
+
response_type: 'code',
|
|
286
|
+
scope: config.scopes.join(' '),
|
|
287
|
+
state,
|
|
288
|
+
...(usePKCE ? { code_challenge: codeChallenge, code_challenge_method: 'S256' } : {}),
|
|
289
|
+
});
|
|
204
290
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
: [...config.scopes];
|
|
291
|
+
const authUrl = `${config.authUrl}?${authParams}`;
|
|
292
|
+
callbacks.openUrl(authUrl);
|
|
208
293
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
294
|
+
const code = await codePromise;
|
|
295
|
+
|
|
296
|
+
return await exchangeCodeForTokens(config, code, redirectUri, codeVerifier);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Public API
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Run a full OAuth2 authorization code flow with PKCE support.
|
|
305
|
+
*
|
|
306
|
+
* Supports two callback transports:
|
|
307
|
+
* - loopback (default): local HTTP server on 127.0.0.1
|
|
308
|
+
* - gateway: callback via the gateway's OAuth route + in-memory registry
|
|
309
|
+
*
|
|
310
|
+
* Transport is auto-detected based on ingress.publicBaseUrl config unless
|
|
311
|
+
* explicitly specified via options.callbackTransport.
|
|
312
|
+
*/
|
|
313
|
+
export async function startOAuth2Flow(
|
|
314
|
+
config: OAuth2Config,
|
|
315
|
+
callbacks: OAuth2FlowCallbacks,
|
|
316
|
+
options?: OAuth2FlowOptions,
|
|
317
|
+
): Promise<OAuth2FlowResult> {
|
|
318
|
+
const codeVerifier = generateCodeVerifier();
|
|
319
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
320
|
+
const state = generateState();
|
|
321
|
+
|
|
322
|
+
const transport = options?.callbackTransport ?? detectTransport();
|
|
323
|
+
log.debug({ transport }, 'OAuth2 flow starting');
|
|
324
|
+
|
|
325
|
+
if (transport === 'gateway') {
|
|
326
|
+
return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
|
|
213
327
|
}
|
|
328
|
+
|
|
329
|
+
return runLoopbackFlow(config, callbacks, codeVerifier, codeChallenge, state);
|
|
214
330
|
}
|
|
215
331
|
|
|
216
332
|
/**
|
|
@@ -117,7 +117,7 @@ export function createClaudeCodeBackend(): SwarmWorkerBackend {
|
|
|
117
117
|
|
|
118
118
|
const parts: string[] = [`[${message.subtype}] (${message.num_turns} turns, ${(message.duration_ms / 1000).toFixed(1)}s)`];
|
|
119
119
|
if (errors.length > 0) parts.push(`Errors: ${errors.join('; ')}`);
|
|
120
|
-
if (denials.length > 0) parts.push(`Permission denied: ${denials.map(d => d.tool_name).join(', ')}`);
|
|
120
|
+
if (denials.length > 0) parts.push(`Permission denied: ${denials.map((d: { tool_name: string }) => d.tool_name).join(', ')}`);
|
|
121
121
|
resultText += `\n${parts.join('\n')}`;
|
|
122
122
|
}
|
|
123
123
|
}
|
|
@@ -13,7 +13,7 @@ import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
|
|
|
13
13
|
import type { ToolDefinition } from '../../providers/types.js';
|
|
14
14
|
import { registerTool } from '../registry.js';
|
|
15
15
|
import { getDb } from '../../memory/db.js';
|
|
16
|
-
import { attachments, messageAttachments, messages, conversations
|
|
16
|
+
import { attachments, messageAttachments, messages, conversations } from '../../memory/schema.js';
|
|
17
17
|
import type { StoredAttachment } from '../../memory/attachments-store.js';
|
|
18
18
|
import { isAttachmentVisible, type AttachmentContext } from '../../daemon/media-visibility-policy.js';
|
|
19
19
|
import { getConversationThreadType } from '../../memory/conversation-store.js';
|
|
@@ -103,25 +103,6 @@ function isAttachmentVisibleFromContext(attachmentId: string, currentContext: At
|
|
|
103
103
|
);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
// Assistant ID lookup
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Derive the assistant ID that owns a conversation via the conversation_keys
|
|
112
|
-
* table. Returns null when no mapping exists (e.g. native macOS app sessions
|
|
113
|
-
* that use the implicit 'local-assistant' identity).
|
|
114
|
-
*/
|
|
115
|
-
function getAssistantIdForConversation(conversationId: string): string | null {
|
|
116
|
-
const db = getDb();
|
|
117
|
-
const row = db
|
|
118
|
-
.select({ assistantId: conversationKeys.assistantId })
|
|
119
|
-
.from(conversationKeys)
|
|
120
|
-
.where(eq(conversationKeys.conversationId, conversationId))
|
|
121
|
-
.get();
|
|
122
|
-
return row?.assistantId ?? null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
106
|
// ---------------------------------------------------------------------------
|
|
126
107
|
// Search logic
|
|
127
108
|
// ---------------------------------------------------------------------------
|
|
@@ -131,8 +112,6 @@ export interface AssetSearchParams {
|
|
|
131
112
|
filename?: string;
|
|
132
113
|
recency?: string;
|
|
133
114
|
conversation_id?: string;
|
|
134
|
-
/** Tenant boundary — when set, only attachments belonging to this assistant are returned. */
|
|
135
|
-
assistant_id?: string;
|
|
136
115
|
limit?: number;
|
|
137
116
|
}
|
|
138
117
|
|
|
@@ -163,11 +142,6 @@ export function searchAttachments(params: AssetSearchParams): StoredAttachment[]
|
|
|
163
142
|
}
|
|
164
143
|
}
|
|
165
144
|
|
|
166
|
-
// Tenant boundary — restrict to the active assistant's attachments
|
|
167
|
-
if (params.assistant_id) {
|
|
168
|
-
conditions.push(eq(attachments.assistantId, params.assistant_id));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
145
|
// Conversation scope — join through message_attachments + messages
|
|
172
146
|
if (params.conversation_id) {
|
|
173
147
|
const linkedIds = db
|
|
@@ -207,11 +181,6 @@ export function searchAttachments(params: AssetSearchParams): StoredAttachment[]
|
|
|
207
181
|
bindValues.push(Date.now() - offsetMs);
|
|
208
182
|
}
|
|
209
183
|
}
|
|
210
|
-
if (params.assistant_id) {
|
|
211
|
-
whereParts.push(`a.assistant_id = ?`);
|
|
212
|
-
bindValues.push(params.assistant_id);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
184
|
const limit = Math.min(params.limit ?? DEFAULT_LIMIT, MAX_RESULTS);
|
|
216
185
|
const stmt = raw.prepare(
|
|
217
186
|
`SELECT a.id, a.original_filename, a.mime_type, a.size_bytes, a.kind, a.thumbnail_base64, a.created_at
|
|
@@ -347,9 +316,6 @@ class AssetSearchTool implements Tool {
|
|
|
347
316
|
}
|
|
348
317
|
|
|
349
318
|
try {
|
|
350
|
-
// Scope results to the active assistant to prevent cross-tenant leaks
|
|
351
|
-
const assistantId = getAssistantIdForConversation(context.conversationId) ?? undefined;
|
|
352
|
-
|
|
353
319
|
// Over-fetch with MAX_RESULTS so visibility filtering doesn't
|
|
354
320
|
// under-fill the caller's requested limit.
|
|
355
321
|
const results = searchAttachments({
|
|
@@ -357,7 +323,6 @@ class AssetSearchTool implements Tool {
|
|
|
357
323
|
filename,
|
|
358
324
|
recency,
|
|
359
325
|
conversation_id: conversationId,
|
|
360
|
-
assistant_id: assistantId,
|
|
361
326
|
limit: MAX_RESULTS,
|
|
362
327
|
});
|
|
363
328
|
|
|
@@ -224,6 +224,7 @@ export const claudeCodeTool: Tool = {
|
|
|
224
224
|
|
|
225
225
|
// Declared outside try so the catch block can emit a final tool_complete on error.
|
|
226
226
|
let lastSubToolName: string | null = null;
|
|
227
|
+
let activeToolUseId: string | null = null;
|
|
227
228
|
|
|
228
229
|
try {
|
|
229
230
|
const conversation = query({ prompt, options: queryOptions });
|
|
@@ -235,8 +236,6 @@ export const claudeCodeTool: Tool = {
|
|
|
235
236
|
const toolUseIdInfo = new Map<string, { name: string; inputSummary: string }>();
|
|
236
237
|
// Track tool_use_ids that we've already emitted tool_start for (to avoid duplicates).
|
|
237
238
|
const emittedToolUseIds = new Set<string>();
|
|
238
|
-
// Track the currently active tool_use_id from tool_progress events.
|
|
239
|
-
let activeToolUseId: string | null = null;
|
|
240
239
|
|
|
241
240
|
for await (const message of conversation) {
|
|
242
241
|
switch (message.type) {
|
|
@@ -379,7 +378,7 @@ export const claudeCodeTool: Tool = {
|
|
|
379
378
|
parts.push(`Errors: ${errors.join('; ')}`);
|
|
380
379
|
}
|
|
381
380
|
if (denials.length > 0) {
|
|
382
|
-
const denialSummary = denials.map(d => `${d.tool_name}`).join(', ');
|
|
381
|
+
const denialSummary = denials.map((d: { tool_name: string }) => `${d.tool_name}`).join(', ');
|
|
383
382
|
parts.push(`Permission denied: ${denialSummary}`);
|
|
384
383
|
}
|
|
385
384
|
resultText += `\n\n${parts.join('\n')}`;
|
|
@@ -406,6 +405,7 @@ export const claudeCodeTool: Tool = {
|
|
|
406
405
|
context.onOutput?.(JSON.stringify({
|
|
407
406
|
subType: 'tool_complete',
|
|
408
407
|
subToolName: lastSubToolName,
|
|
408
|
+
subToolId: activeToolUseId,
|
|
409
409
|
subToolIsError: true,
|
|
410
410
|
}));
|
|
411
411
|
lastSubToolName = null;
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { ToolContext, ToolExecutionResult } from '../types.js';
|
|
2
|
-
import { listWorkItems, type WorkItemStatus } from '../../work-items/work-item-store.js';
|
|
2
|
+
import { listWorkItems, type WorkItem, type WorkItemStatus } from '../../work-items/work-item-store.js';
|
|
3
|
+
|
|
4
|
+
const PRIORITY_LABELS: Record<number, string> = { 0: 'High', 1: 'Medium', 2: 'Low' };
|
|
5
|
+
|
|
6
|
+
function formatTaskList(items: WorkItem[]): string {
|
|
7
|
+
const lines: string[] = [];
|
|
8
|
+
for (const item of items) {
|
|
9
|
+
const priority = PRIORITY_LABELS[item.priorityTier] ?? 'Medium';
|
|
10
|
+
const status = item.status.replace(/_/g, ' ');
|
|
11
|
+
lines.push(`- [${priority}] ${item.title} (${status})`);
|
|
12
|
+
}
|
|
13
|
+
return lines.join('\n');
|
|
14
|
+
}
|
|
3
15
|
|
|
4
16
|
export async function executeTaskListShow(
|
|
5
17
|
input: Record<string, unknown>,
|
|
@@ -33,7 +45,9 @@ export async function executeTaskListShow(
|
|
|
33
45
|
? `${count} ${Array.isArray(statusFilter) ? 'matching' : statusFilter} item${count === 1 ? '' : 's'}`
|
|
34
46
|
: `${count} item${count === 1 ? '' : 's'}`;
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
const taskList = formatTaskList(items);
|
|
49
|
+
|
|
50
|
+
return { content: `Opened Tasks window (${label}).\n\nCurrent tasks:\n${taskList}`, isError: false };
|
|
37
51
|
} catch (err) {
|
|
38
52
|
const msg = err instanceof Error ? err.message : String(err);
|
|
39
53
|
return { content: `Error: ${msg}`, isError: true };
|