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.
- package/bun.lock +4 -4
- package/package.json +4 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +0 -6
- package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -5
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
- package/src/__tests__/public-ingress-urls.test.ts +222 -0
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -0
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/__tests__/twilio-provider.test.ts +1 -1
- package/src/__tests__/twilio-routes.test.ts +4 -4
- package/src/__tests__/twitter-auth-handler.test.ts +87 -2
- package/src/calls/call-domain.ts +8 -6
- package/src/calls/twilio-config.ts +18 -3
- package/src/calls/twilio-routes.ts +10 -2
- package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
- package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
- package/src/config/defaults.ts +4 -1
- package/src/config/schema.ts +30 -6
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
- package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +49 -17
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/shared.ts +1 -0
- package/src/daemon/handlers/subagents.ts +85 -2
- package/src/daemon/handlers/twitter-auth.ts +31 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -4
- package/src/daemon/ipc-contract.ts +34 -15
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +8 -1
- package/src/inbound/public-ingress-urls.ts +112 -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 +472 -148
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -6
- package/src/memory/schema.ts +2 -6
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +174 -7
- package/src/runtime/routes/channel-routes.ts +7 -2
- package/src/runtime/routes/events-routes.ts +79 -0
- package/src/runtime/routes/run-routes.ts +43 -0
- package/src/runtime/run-orchestrator.ts +64 -7
- package/src/security/oauth-callback-registry.ts +66 -0
- package/src/security/oauth2.ts +208 -58
- package/src/subagent/manager.ts +3 -1
- 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/tools/tasks/work-item-run.ts +78 -0
- package/src/util/platform.ts +1 -1
- package/src/work-items/work-item-runner.ts +171 -0
- package/src/workspace/provider-commit-message-generator.ts +39 -23
- package/src/workspace/turn-commit.ts +6 -2
- package/src/__tests__/handlers-twilio-config.test.ts +0 -221
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
- 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.
|
|
10
|
-
*
|
|
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
|
|
118
|
-
//
|
|
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
|
+
}
|
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,106 @@ 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 a public base URL is configured (ingress.publicBaseUrl or
|
|
148
|
+
* INGRESS_PUBLIC_BASE_URL), otherwise loopback.
|
|
70
149
|
*/
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
250
|
+
return await exchangeCodeForTokens(config, code, redirectUri, codeVerifier);
|
|
251
|
+
} finally {
|
|
252
|
+
clearTimeout(timeout);
|
|
253
|
+
server.stop(true);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
168
256
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
body: new URLSearchParams(tokenBody),
|
|
173
|
-
});
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Gateway transport
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
174
260
|
|
|
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
|
-
}
|
|
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
|
-
|
|
272
|
+
const appConfig = loadConfig();
|
|
273
|
+
const redirectUri = getOAuthCallbackUrl(appConfig);
|
|
192
274
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
275
|
+
const codePromise = new Promise<string>((resolve, reject) => {
|
|
276
|
+
registerPendingCallback(state, resolve, reject);
|
|
277
|
+
});
|
|
196
278
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
: [...config.scopes];
|
|
290
|
+
const authUrl = `${config.authUrl}?${authParams}`;
|
|
291
|
+
callbacks.openUrl(authUrl);
|
|
208
292
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
/**
|
package/src/subagent/manager.ts
CHANGED
|
@@ -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
|
|
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 };
|