vellum 0.2.7 → 0.2.9

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 (76) hide show
  1. package/bun.lock +4 -4
  2. package/package.json +4 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +0 -6
  6. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  7. package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
  8. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +17 -5
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  13. package/src/__tests__/public-ingress-urls.test.ts +222 -0
  14. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  15. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  16. package/src/__tests__/tool-executor.test.ts +88 -0
  17. package/src/__tests__/turn-commit.test.ts +64 -0
  18. package/src/__tests__/twilio-provider.test.ts +1 -1
  19. package/src/__tests__/twilio-routes.test.ts +4 -4
  20. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  21. package/src/calls/call-domain.ts +8 -6
  22. package/src/calls/twilio-config.ts +18 -3
  23. package/src/calls/twilio-routes.ts +10 -2
  24. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  25. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  26. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  27. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  28. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  29. package/src/config/defaults.ts +4 -1
  30. package/src/config/schema.ts +30 -6
  31. package/src/config/system-prompt.ts +1 -1
  32. package/src/config/types.ts +1 -0
  33. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  34. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  35. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  36. package/src/daemon/computer-use-session.ts +2 -1
  37. package/src/daemon/handlers/config.ts +49 -17
  38. package/src/daemon/handlers/sessions.ts +2 -2
  39. package/src/daemon/handlers/shared.ts +1 -0
  40. package/src/daemon/handlers/subagents.ts +85 -2
  41. package/src/daemon/handlers/twitter-auth.ts +31 -2
  42. package/src/daemon/handlers/work-items.ts +1 -1
  43. package/src/daemon/ipc-contract-inventory.json +8 -4
  44. package/src/daemon/ipc-contract.ts +34 -15
  45. package/src/daemon/lifecycle.ts +9 -4
  46. package/src/daemon/server.ts +7 -0
  47. package/src/daemon/session-tool-setup.ts +8 -1
  48. package/src/inbound/public-ingress-urls.ts +112 -0
  49. package/src/memory/attachments-store.ts +0 -1
  50. package/src/memory/channel-delivery-store.ts +0 -1
  51. package/src/memory/conversation-key-store.ts +0 -1
  52. package/src/memory/db.ts +472 -148
  53. package/src/memory/llm-usage-store.ts +0 -1
  54. package/src/memory/runs-store.ts +51 -6
  55. package/src/memory/schema.ts +2 -6
  56. package/src/runtime/gateway-client.ts +7 -1
  57. package/src/runtime/http-server.ts +174 -7
  58. package/src/runtime/routes/channel-routes.ts +7 -2
  59. package/src/runtime/routes/events-routes.ts +79 -0
  60. package/src/runtime/routes/run-routes.ts +43 -0
  61. package/src/runtime/run-orchestrator.ts +64 -7
  62. package/src/security/oauth-callback-registry.ts +66 -0
  63. package/src/security/oauth2.ts +208 -58
  64. package/src/subagent/manager.ts +3 -1
  65. package/src/swarm/backend-claude-code.ts +1 -1
  66. package/src/tools/assets/search.ts +1 -36
  67. package/src/tools/claude-code/claude-code.ts +3 -3
  68. package/src/tools/tasks/work-item-list.ts +16 -2
  69. package/src/tools/tasks/work-item-run.ts +78 -0
  70. package/src/util/platform.ts +1 -1
  71. package/src/work-items/work-item-runner.ts +171 -0
  72. package/src/workspace/provider-commit-message-generator.ts +39 -23
  73. package/src/workspace/turn-commit.ts +6 -2
  74. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  75. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  76. package/src/calls/twilio-webhook-urls.ts +0 -50
@@ -3,11 +3,14 @@
3
3
  *
4
4
  * A "run" wraps a single agent-loop execution, tracking its state through:
5
5
  * running → needs_confirmation → running → completed | failed
6
+ * running → needs_secret → running → completed | failed
6
7
  *
7
8
  * When a tool needs permission, the orchestrator intercepts the
8
9
  * confirmation_request from the session's prompter and records it in
9
- * the run store. The web UI can then poll the run status and submit
10
- * a decision via the /decision endpoint.
10
+ * the run store. Similarly, when a tool needs a secret (e.g.
11
+ * credential_store prompt), the orchestrator intercepts the
12
+ * secret_request and records it. The client can then poll the run
13
+ * status and submit a decision or secret via the respective endpoints.
11
14
  */
12
15
 
13
16
  import * as runsStore from '../memory/runs-store.js';
@@ -113,11 +116,10 @@ export class RunOrchestrator {
113
116
  };
114
117
 
115
118
 
116
- // Hook into session to intercept confirmation_request events.
117
- // When the prompter sends a confirmation_request, we record it in the
118
- // run store so the web UI can poll and submit a decision.
119
- // Do NOT set hasNoClient — run sessions have a client (the HTTP caller)
120
- // and confirmations are handled via the /runs/:id/decision endpoint.
119
+ // Hook into session to intercept confirmation_request and secret_request events.
120
+ // When the prompter sends one of these, we record it in the run store so
121
+ // the client can poll and submit a decision/secret via the respective endpoint.
122
+ // Do NOT set hasNoClient — run sessions have a client (the HTTP caller).
121
123
  let lastError: string | null = null;
122
124
  session.updateClient((msg: ServerMessage) => {
123
125
  if (msg.type === 'confirmation_request') {
@@ -138,6 +140,21 @@ export class RunOrchestrator {
138
140
  prompterRequestId: msg.requestId,
139
141
  session,
140
142
  });
143
+ } else if (msg.type === 'secret_request') {
144
+ runsStore.setRunSecret(run.id, {
145
+ requestId: msg.requestId,
146
+ service: msg.service,
147
+ field: msg.field,
148
+ label: msg.label,
149
+ description: msg.description,
150
+ placeholder: msg.placeholder,
151
+ purpose: msg.purpose,
152
+ allowOneTimeSend: msg.allowOneTimeSend,
153
+ });
154
+ this.pending.set(run.id, {
155
+ prompterRequestId: msg.requestId,
156
+ session,
157
+ });
141
158
  }
142
159
  // Mirror every outbound message to the assistant-events hub so SSE
143
160
  // subscribers receive the same payload parity as IPC clients.
@@ -236,4 +253,44 @@ export class RunOrchestrator {
236
253
  // the client doesn't mistakenly treat the decision as accepted.
237
254
  return 'no_pending_decision';
238
255
  }
256
+
257
+ /**
258
+ * Submit a secret value for a pending secret request.
259
+ *
260
+ * Returns:
261
+ * - `'applied'` – secret was forwarded to the session
262
+ * - `'run_not_found'` – no run exists with the given ID
263
+ * - `'no_pending_secret'` – run exists but is not awaiting a secret
264
+ */
265
+ submitSecret(
266
+ runId: string,
267
+ value?: string,
268
+ delivery?: 'store' | 'transient_send',
269
+ ): 'applied' | 'run_not_found' | 'no_pending_secret' {
270
+ const pendingState = this.pending.get(runId);
271
+ if (pendingState) {
272
+ runsStore.clearRunSecret(runId);
273
+ pendingState.session.handleSecretResponse(
274
+ pendingState.prompterRequestId,
275
+ value,
276
+ delivery,
277
+ );
278
+ this.pending.delete(runId);
279
+ return 'applied';
280
+ }
281
+
282
+ const run = runsStore.getRun(runId);
283
+ if (!run) return 'run_not_found';
284
+
285
+ if (run.status === 'needs_secret') {
286
+ runsStore.failRun(runId, 'Secret prompter timed out (no active handler)');
287
+ return 'applied';
288
+ }
289
+
290
+ if (run.status === 'completed' || run.status === 'failed') {
291
+ return 'applied';
292
+ }
293
+
294
+ return 'no_pending_secret';
295
+ }
239
296
  }
@@ -0,0 +1,66 @@
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
+ // Clear any existing entry for this state to prevent timer leaks and
23
+ // cross-callback timeouts when the same state is registered twice.
24
+ const existing = pendingCallbacks.get(state);
25
+ if (existing) {
26
+ clearTimeout(existing.timer);
27
+ existing.reject(new Error('OAuth callback superseded by new registration'));
28
+ pendingCallbacks.delete(state);
29
+ }
30
+
31
+ const timer = setTimeout(() => {
32
+ const entry = pendingCallbacks.get(state);
33
+ if (entry) {
34
+ pendingCallbacks.delete(state);
35
+ entry.reject(new Error('OAuth callback timed out'));
36
+ }
37
+ }, ttlMs);
38
+
39
+ pendingCallbacks.set(state, { resolve, reject, timer });
40
+ }
41
+
42
+ export function consumeCallback(state: string, code: string): boolean {
43
+ const entry = pendingCallbacks.get(state);
44
+ if (!entry) return false;
45
+ clearTimeout(entry.timer);
46
+ pendingCallbacks.delete(state);
47
+ entry.resolve(code);
48
+ return true;
49
+ }
50
+
51
+ export function consumeCallbackError(state: string, error: string): boolean {
52
+ const entry = pendingCallbacks.get(state);
53
+ if (!entry) return false;
54
+ clearTimeout(entry.timer);
55
+ pendingCallbacks.delete(state);
56
+ entry.reject(new Error(error));
57
+ return true;
58
+ }
59
+
60
+ export function clearAllCallbacks(): void {
61
+ for (const entry of pendingCallbacks.values()) {
62
+ clearTimeout(entry.timer);
63
+ entry.reject(new Error('OAuth callback registry cleared'));
64
+ }
65
+ pendingCallbacks.clear();
66
+ }
@@ -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,106 @@ 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 a public base URL is configured (ingress.publicBaseUrl or
148
+ * INGRESS_PUBLIC_BASE_URL), otherwise loopback.
70
149
  */
71
- export async function startOAuth2Flow(
150
+ function detectTransport(): 'loopback' | 'gateway' {
151
+ try {
152
+ const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
153
+ const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
154
+ const appConfig = loadConfig();
155
+ getPublicBaseUrl(appConfig); // throws if no public URL configured
156
+ return 'gateway';
157
+ } catch {
158
+ log.debug('No public base URL configured for transport auto-detection, defaulting to loopback');
159
+ }
160
+ return 'loopback';
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Loopback transport
165
+ // ---------------------------------------------------------------------------
166
+
167
+ async function runLoopbackFlow(
72
168
  config: OAuth2Config,
73
169
  callbacks: OAuth2FlowCallbacks,
170
+ codeVerifier: string,
171
+ codeChallenge: string,
172
+ state: string,
74
173
  ): Promise<OAuth2FlowResult> {
75
- const codeVerifier = generateCodeVerifier();
76
- const codeChallenge = generateCodeChallenge(codeVerifier);
77
- const state = generateState();
78
-
79
174
  let resolveCode: (value: { code: string; returnedState: string }) => void;
80
175
  let rejectCode: (reason: Error) => void;
81
176
 
@@ -84,7 +179,6 @@ export async function startOAuth2Flow(
84
179
  rejectCode = reject;
85
180
  });
86
181
 
87
- /** How long to wait for the user to complete the OAuth consent flow. */
88
182
  const FLOW_TIMEOUT_MS = 120_000;
89
183
 
90
184
  const timeout = setTimeout(() => {
@@ -153,64 +247,120 @@ export async function startOAuth2Flow(
153
247
  throw new Error('OAuth2 state mismatch — possible CSRF attack');
154
248
  }
155
249
 
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
- }
250
+ return await exchangeCodeForTokens(config, code, redirectUri, codeVerifier);
251
+ } finally {
252
+ clearTimeout(timeout);
253
+ server.stop(true);
254
+ }
255
+ }
168
256
 
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
- });
257
+ // ---------------------------------------------------------------------------
258
+ // Gateway transport
259
+ // ---------------------------------------------------------------------------
174
260
 
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
- }
261
+ async function runGatewayFlow(
262
+ config: OAuth2Config,
263
+ callbacks: OAuth2FlowCallbacks,
264
+ codeVerifier: string,
265
+ codeChallenge: string,
266
+ state: string,
267
+ ): Promise<OAuth2FlowResult> {
268
+ const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
269
+ const { getOAuthCallbackUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
270
+ const { registerPendingCallback } = require('./oauth-callback-registry.js') as typeof import('./oauth-callback-registry.js');
190
271
 
191
- const tokenData = await tokenResp.json() as Record<string, unknown>;
272
+ const appConfig = loadConfig();
273
+ const redirectUri = getOAuthCallbackUrl(appConfig);
192
274
 
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;
275
+ const codePromise = new Promise<string>((resolve, reject) => {
276
+ registerPendingCallback(state, resolve, reject);
277
+ });
196
278
 
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
- };
279
+ const usePKCE = !config.clientSecret;
280
+ const authParams = new URLSearchParams({
281
+ ...config.extraParams,
282
+ client_id: config.clientId,
283
+ redirect_uri: redirectUri,
284
+ response_type: 'code',
285
+ scope: config.scopes.join(' '),
286
+ state,
287
+ ...(usePKCE ? { code_challenge: codeChallenge, code_challenge_method: 'S256' } : {}),
288
+ });
204
289
 
205
- const grantedScopes = typeof tokens.scope === 'string'
206
- ? tokens.scope.split(/[ ,]/).filter(Boolean)
207
- : [...config.scopes];
290
+ const authUrl = `${config.authUrl}?${authParams}`;
291
+ callbacks.openUrl(authUrl);
208
292
 
209
- return { tokens, grantedScopes, rawTokenResponse: tokenData };
210
- } finally {
211
- clearTimeout(timeout);
212
- server.stop(true);
293
+ const code = await codePromise;
294
+
295
+ return await exchangeCodeForTokens(config, code, redirectUri, codeVerifier);
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Public API
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /**
303
+ * Run a full OAuth2 authorization code flow with PKCE support.
304
+ *
305
+ * Supports two callback transports:
306
+ * - loopback (default): local HTTP server on 127.0.0.1
307
+ * - gateway: callback via the gateway's OAuth route + in-memory registry
308
+ *
309
+ * Transport is auto-detected based on ingress.publicBaseUrl config unless
310
+ * explicitly specified via options.callbackTransport.
311
+ */
312
+ export async function startOAuth2Flow(
313
+ config: OAuth2Config,
314
+ callbacks: OAuth2FlowCallbacks,
315
+ options?: OAuth2FlowOptions,
316
+ ): Promise<OAuth2FlowResult> {
317
+ const codeVerifier = generateCodeVerifier();
318
+ const codeChallenge = generateCodeChallenge(codeVerifier);
319
+ const state = generateState();
320
+
321
+ // In gateway_only mode, enforce gateway transport and require a public ingress URL
322
+ let ingressMode: string | undefined;
323
+ try {
324
+ const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
325
+ ingressMode = loadConfig().ingress.mode;
326
+ } catch {
327
+ // Fail closed: if config can't be loaded (e.g., malformed config.json), default to the
328
+ // most restrictive mode to prevent loopback fallback from creating a fail-open path.
329
+ log.warn('Failed to load config for OAuth ingress mode detection; defaulting to gateway_only (fail closed)');
330
+ ingressMode = 'gateway_only';
213
331
  }
332
+
333
+ if (ingressMode === 'gateway_only') {
334
+ // Verify a public ingress URL is configured; fail fast with actionable error if not
335
+ let hasPublicUrl = false;
336
+ try {
337
+ const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
338
+ const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
339
+ getPublicBaseUrl(loadConfig());
340
+ hasPublicUrl = true;
341
+ } catch {
342
+ // No public URL configured
343
+ }
344
+
345
+ if (!hasPublicUrl) {
346
+ throw new Error(
347
+ 'OAuth requires a public ingress URL in gateway-only mode. Set ingress.publicBaseUrl or INGRESS_PUBLIC_BASE_URL so OAuth callbacks can route through the gateway.',
348
+ );
349
+ }
350
+
351
+ // In gateway_only mode, always use gateway transport — never fall back to loopback
352
+ log.debug({ transport: 'gateway' }, 'OAuth2 flow starting (gateway_only mode)');
353
+ return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
354
+ }
355
+
356
+ const transport = options?.callbackTransport ?? detectTransport();
357
+ log.debug({ transport }, 'OAuth2 flow starting');
358
+
359
+ if (transport === 'gateway') {
360
+ return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
361
+ }
362
+
363
+ return runLoopbackFlow(config, callbacks, codeVerifier, codeChallenge, state);
214
364
  }
215
365
 
216
366
  /**
@@ -57,6 +57,7 @@ export interface SubagentNotificationInfo {
57
57
  label: string;
58
58
  status: 'completed' | 'failed' | 'aborted';
59
59
  error?: string;
60
+ conversationId?: string;
60
61
  }
61
62
 
62
63
  export type ParentNotifyCallback = (
@@ -299,7 +300,7 @@ export class SubagentManager {
299
300
  managed.state.config.parentSessionId,
300
301
  message,
301
302
  managed.parentSendToClient,
302
- { subagentId, label, status: 'aborted' },
303
+ { subagentId, label, status: 'aborted', conversationId: managed.state.conversationId },
303
304
  );
304
305
  } catch (err) {
305
306
  log.error({ subagentId, err }, 'Failed to notify parent about abort');
@@ -497,6 +498,7 @@ export class SubagentManager {
497
498
  subagentId: config.id,
498
499
  label: config.label,
499
500
  status: outcome,
501
+ conversationId: managed.state.conversationId,
500
502
  ...(outcome === 'failed' ? { error: managed.state.error ?? 'Unknown error' } : {}),
501
503
  };
502
504
 
@@ -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 };