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.
Files changed (42) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +2 -2
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
  6. package/src/__tests__/ipc-snapshot.test.ts +11 -0
  7. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  8. package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
  9. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  10. package/src/__tests__/public-ingress-urls.test.ts +206 -0
  11. package/src/__tests__/tool-executor.test.ts +88 -0
  12. package/src/__tests__/turn-commit.test.ts +64 -0
  13. package/src/calls/twilio-config.ts +17 -1
  14. package/src/calls/twilio-routes.ts +10 -2
  15. package/src/calls/twilio-webhook-urls.ts +18 -21
  16. package/src/config/defaults.ts +4 -0
  17. package/src/config/schema.ts +30 -2
  18. package/src/config/system-prompt.ts +1 -1
  19. package/src/config/types.ts +1 -0
  20. package/src/daemon/computer-use-session.ts +2 -1
  21. package/src/daemon/handlers/config.ts +51 -2
  22. package/src/daemon/handlers/sessions.ts +2 -2
  23. package/src/daemon/handlers/work-items.ts +1 -1
  24. package/src/daemon/ipc-contract-inventory.json +4 -0
  25. package/src/daemon/ipc-contract.ts +16 -1
  26. package/src/daemon/session-tool-setup.ts +7 -0
  27. package/src/inbound/public-ingress-urls.ts +106 -0
  28. package/src/memory/attachments-store.ts +0 -1
  29. package/src/memory/channel-delivery-store.ts +0 -1
  30. package/src/memory/conversation-key-store.ts +0 -1
  31. package/src/memory/db.ts +346 -149
  32. package/src/memory/runs-store.ts +0 -3
  33. package/src/memory/schema.ts +0 -4
  34. package/src/runtime/http-server.ts +84 -2
  35. package/src/security/oauth-callback-registry.ts +56 -0
  36. package/src/security/oauth2.ts +174 -58
  37. package/src/swarm/backend-claude-code.ts +1 -1
  38. package/src/tools/assets/search.ts +1 -36
  39. package/src/tools/claude-code/claude-code.ts +3 -3
  40. package/src/tools/tasks/work-item-list.ts +16 -2
  41. package/src/workspace/provider-commit-message-generator.ts +39 -23
  42. 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 { getWebhookBaseUrl } from '../calls/twilio-webhook-urls.js';
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 = getWebhookBaseUrl(loadConfig());
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
+ }
@@ -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
- // Public API
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
- * Run a full OAuth2 PKCE authorization code flow using a loopback redirect.
146
+ * Determine which callback transport to use when not explicitly specified.
147
+ * Uses gateway if ingress.publicBaseUrl is configured, otherwise loopback.
70
148
  */
71
- export async function startOAuth2Flow(
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
- const tokenBody: Record<string, string> = {
157
- grant_type: 'authorization_code',
158
- code,
159
- redirect_uri: redirectUri,
160
- client_id: config.clientId,
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
- const tokenResp = await fetch(config.tokenUrl, {
170
- method: 'POST',
171
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
172
- body: new URLSearchParams(tokenBody),
173
- });
258
+ // ---------------------------------------------------------------------------
259
+ // Gateway transport
260
+ // ---------------------------------------------------------------------------
174
261
 
175
- if (!tokenResp.ok) {
176
- const rawBody = await tokenResp.text().catch(() => '');
177
- let safeDetail: Record<string, unknown> = {};
178
- let errorCode = '';
179
- try {
180
- const parsed = JSON.parse(rawBody) as Record<string, unknown>;
181
- if (parsed.error) { safeDetail.error = String(parsed.error); errorCode = String(parsed.error); }
182
- if (parsed.error_description) safeDetail.error_description = String(parsed.error_description);
183
- } catch {
184
- safeDetail.error = '[non-JSON response]';
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
- const tokenData = await tokenResp.json() as Record<string, unknown>;
273
+ const appConfig = loadConfig();
274
+ const redirectUri = getOAuthCallbackUrl(appConfig);
192
275
 
193
- // Slack V2 OAuth returns user tokens nested under `authed_user`
194
- const authedUser = tokenData.authed_user as Record<string, unknown> | undefined;
195
- const tokenSource = authedUser?.access_token ? authedUser : tokenData;
276
+ const codePromise = new Promise<string>((resolve, reject) => {
277
+ registerPendingCallback(state, resolve, reject);
278
+ });
196
279
 
197
- const tokens: OAuth2TokenResult = {
198
- accessToken: (tokenSource.access_token as string) ?? (tokenData.access_token as string),
199
- refreshToken: (tokenSource.refresh_token as string | undefined) ?? (tokenData.refresh_token as string | undefined),
200
- expiresIn: (tokenSource.expires_in as number | undefined) ?? (tokenData.expires_in as number | undefined),
201
- scope: (tokenSource.scope as string | undefined) ?? (tokenData.scope as string | undefined),
202
- tokenType: (tokenSource.token_type as string | undefined) ?? (tokenData.token_type as string | undefined),
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
- const grantedScopes = typeof tokens.scope === 'string'
206
- ? tokens.scope.split(/[ ,]/).filter(Boolean)
207
- : [...config.scopes];
291
+ const authUrl = `${config.authUrl}?${authParams}`;
292
+ callbacks.openUrl(authUrl);
208
293
 
209
- return { tokens, grantedScopes, rawTokenResponse: tokenData };
210
- } finally {
211
- clearTimeout(timeout);
212
- server.stop(true);
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, conversationKeys } from '../../memory/schema.js';
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
- return { content: `Opened Tasks window (${label}).`, isError: false };
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 };