vellum 0.2.8 → 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 +2 -2
- package/package.json +3 -2
- 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 +91 -11
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -16
- package/src/__tests__/oauth2-gateway-transport.test.ts +7 -1
- package/src/__tests__/public-ingress-urls.test.ts +50 -34
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -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 +2 -3
- 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 +1 -2
- package/src/config/schema.ts +2 -6
- 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/handlers/config.ts +33 -50
- 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/ipc-contract-inventory.json +4 -4
- package/src/daemon/ipc-contract.ts +25 -21
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +1 -1
- package/src/inbound/public-ingress-urls.ts +36 -30
- package/src/memory/db.ts +132 -5
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -3
- package/src/memory/schema.ts +2 -2
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +95 -10
- 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 +10 -0
- package/src/security/oauth2.ts +41 -7
- package/src/subagent/manager.ts +3 -1
- 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/__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 -47
package/src/config/schema.ts
CHANGED
|
@@ -895,9 +895,6 @@ export const CallsConfigSchema = z.object({
|
|
|
895
895
|
error: `calls.provider must be one of: ${VALID_CALL_PROVIDERS.join(', ')}`,
|
|
896
896
|
})
|
|
897
897
|
.default('twilio'),
|
|
898
|
-
webhookBaseUrl: z
|
|
899
|
-
.string({ error: 'calls.webhookBaseUrl must be a string' })
|
|
900
|
-
.default(''),
|
|
901
898
|
maxDurationSeconds: z
|
|
902
899
|
.number({ error: 'calls.maxDurationSeconds must be a number' })
|
|
903
900
|
.int('calls.maxDurationSeconds must be an integer')
|
|
@@ -934,7 +931,7 @@ export const IngressConfigSchema = z.object({
|
|
|
934
931
|
.enum(VALID_INGRESS_MODES, {
|
|
935
932
|
error: `ingress.mode must be one of: ${VALID_INGRESS_MODES.join(', ')}`,
|
|
936
933
|
})
|
|
937
|
-
.default('
|
|
934
|
+
.default('gateway_only'),
|
|
938
935
|
});
|
|
939
936
|
|
|
940
937
|
export const AssistantConfigSchema = z.object({
|
|
@@ -1175,7 +1172,6 @@ export const AssistantConfigSchema = z.object({
|
|
|
1175
1172
|
calls: CallsConfigSchema.default({
|
|
1176
1173
|
enabled: true,
|
|
1177
1174
|
provider: 'twilio',
|
|
1178
|
-
webhookBaseUrl: '',
|
|
1179
1175
|
maxDurationSeconds: 3600,
|
|
1180
1176
|
userConsultTimeoutSeconds: 120,
|
|
1181
1177
|
disclosure: {
|
|
@@ -1188,7 +1184,7 @@ export const AssistantConfigSchema = z.object({
|
|
|
1188
1184
|
}),
|
|
1189
1185
|
ingress: IngressConfigSchema.default({
|
|
1190
1186
|
publicBaseUrl: '',
|
|
1191
|
-
mode: '
|
|
1187
|
+
mode: 'gateway_only',
|
|
1192
1188
|
}),
|
|
1193
1189
|
}).superRefine((config, ctx) => {
|
|
1194
1190
|
if (config.contextWindow.targetInputTokens >= config.contextWindow.maxInputTokens) {
|
|
@@ -124,7 +124,7 @@ Tell the user: "Consent screen is configured! Almost there — just need to crea
|
|
|
124
124
|
|
|
125
125
|
> **Create OAuth Credentials**
|
|
126
126
|
>
|
|
127
|
-
> I'm about to create OAuth
|
|
127
|
+
> I'm about to create OAuth Web Application credentials for Vellum Assistant. This generates a client ID that Vellum uses to initiate the authorization flow. The redirect URI will point to the gateway's OAuth callback endpoint.
|
|
128
128
|
|
|
129
129
|
Wait for the user to approve. If they decline, explain that credentials are the final step needed and offer to try again or cancel.
|
|
130
130
|
|
|
@@ -133,8 +133,9 @@ Once approved, navigate to `https://console.cloud.google.com/apis/credentials?pr
|
|
|
133
133
|
Use `browser_click` on "+ Create Credentials" at the top, then select "OAuth client ID" from the dropdown.
|
|
134
134
|
|
|
135
135
|
Take a `browser_snapshot` and fill in:
|
|
136
|
-
1. **Application type:** Select "
|
|
137
|
-
2. **Name:** "Vellum Assistant
|
|
136
|
+
1. **Application type:** Select "Web application" from the dropdown
|
|
137
|
+
2. **Name:** "Vellum Assistant"
|
|
138
|
+
3. **Authorized redirect URIs:** Click "Add URI" and enter `${ingress.publicBaseUrl}/webhooks/oauth/callback` (e.g. `https://abc123.ngrok-free.app/webhooks/oauth/callback`). Read the `ingress.publicBaseUrl` value from the assistant's workspace config (Settings > Public Ingress) or the `INGRESS_PUBLIC_BASE_URL` environment variable.
|
|
138
139
|
|
|
139
140
|
Use `browser_click` on the "Create" button.
|
|
140
141
|
|
|
@@ -179,7 +180,7 @@ Summarize what was accomplished:
|
|
|
179
180
|
- Created a Google Cloud project (or used an existing one)
|
|
180
181
|
- Enabled the Gmail API and Google Calendar API
|
|
181
182
|
- Configured the OAuth consent screen with appropriate scopes (including calendar)
|
|
182
|
-
- Created OAuth
|
|
183
|
+
- Created OAuth Web Application credentials with gateway callback redirect URI
|
|
183
184
|
- Connected your Gmail and Google Calendar accounts
|
|
184
185
|
|
|
185
186
|
## Error Handling
|
|
@@ -85,14 +85,16 @@ Tell the user: "Permissions configured! Now let's set up the redirect URL and ge
|
|
|
85
85
|
|
|
86
86
|
Navigate to the "OAuth & Permissions" page if not already there.
|
|
87
87
|
|
|
88
|
+
The redirect URL must point to the gateway's OAuth callback endpoint. Determine the URL by reading the `ingress.publicBaseUrl` value from the assistant's workspace config (Settings > Public Ingress) or the `INGRESS_PUBLIC_BASE_URL` environment variable. The callback path is `/webhooks/oauth/callback`.
|
|
89
|
+
|
|
88
90
|
In the "Redirect URLs" section:
|
|
89
91
|
1. Click "Add New Redirect URL"
|
|
90
|
-
2. Enter `
|
|
92
|
+
2. Enter `${ingress.publicBaseUrl}/webhooks/oauth/callback` (e.g. `https://abc123.ngrok-free.app/webhooks/oauth/callback`)
|
|
91
93
|
3. Click "Add" then "Save URLs"
|
|
92
94
|
|
|
93
95
|
Take a `browser_snapshot` to confirm.
|
|
94
96
|
|
|
95
|
-
Tell the user: "Redirect URL configured."
|
|
97
|
+
Tell the user: "Redirect URL configured. Make sure your tunnel is running and `ingress.publicBaseUrl` is set in Settings so the callback can reach the gateway."
|
|
96
98
|
|
|
97
99
|
## Step 5: Extract Client ID and Client Secret
|
|
98
100
|
|
|
@@ -5,14 +5,14 @@ user-invocable: true
|
|
|
5
5
|
metadata: {"vellum": {"emoji": "\ud83e\udd16"}}
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
You are helping your user connect a Telegram bot to the Vellum Assistant gateway. When this skill is invoked, walk through each step below using only existing tools.
|
|
8
|
+
You are helping your user connect a Telegram bot to the Vellum Assistant gateway. Telegram webhooks are received exclusively by the gateway (the public ingress boundary) — they never hit the assistant runtime directly. When this skill is invoked, walk through each step below using only existing tools.
|
|
9
9
|
|
|
10
10
|
## What You Need
|
|
11
11
|
|
|
12
12
|
1. **Bot token** from Telegram's @BotFather (the user provides this)
|
|
13
|
-
2. **Gateway webhook URL**
|
|
13
|
+
2. **Gateway webhook URL** — derived from the canonical ingress setting: `${ingress.publicBaseUrl}/webhooks/telegram`. The gateway is the only publicly reachable endpoint; Telegram sends webhooks to the gateway, which validates and forwards them to the assistant runtime internally. If `ingress.publicBaseUrl` is configured (Settings UI > Public Ingress, or `INGRESS_PUBLIC_BASE_URL` env var), use it to auto-derive the webhook URL. If it is not configured, ask the user to set it before proceeding.
|
|
14
14
|
|
|
15
|
-
If the user has already provided the bot token in the conversation, use it directly. Otherwise, ask for it.
|
|
15
|
+
If the user has already provided the bot token in the conversation, use it directly. Otherwise, ask for it.
|
|
16
16
|
|
|
17
17
|
## Setup Steps
|
|
18
18
|
|
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
ReminderCancel,
|
|
20
20
|
ShareToSlackRequest,
|
|
21
21
|
SlackWebhookConfigRequest,
|
|
22
|
-
TwilioWebhookConfigRequest,
|
|
23
22
|
IngressConfigRequest,
|
|
24
23
|
VercelApiConfigRequest,
|
|
25
24
|
TwitterIntegrationConfigRequest,
|
|
@@ -90,11 +89,12 @@ export function handleModelSet(
|
|
|
90
89
|
// Suppress the file watcher callback — handleModelSet already does
|
|
91
90
|
// the full reload sequence; a redundant watcher-triggered reload
|
|
92
91
|
// would incorrectly evict sessions created after this method returns.
|
|
92
|
+
const wasSuppressed = ctx.suppressConfigReload;
|
|
93
93
|
ctx.setSuppressConfigReload(true);
|
|
94
94
|
try {
|
|
95
95
|
saveRawConfig(raw);
|
|
96
96
|
} catch (err) {
|
|
97
|
-
ctx.setSuppressConfigReload(
|
|
97
|
+
ctx.setSuppressConfigReload(wasSuppressed);
|
|
98
98
|
throw err;
|
|
99
99
|
}
|
|
100
100
|
const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
|
|
@@ -139,11 +139,12 @@ export function handleImageGenModelSet(
|
|
|
139
139
|
const raw = loadRawConfig();
|
|
140
140
|
raw.imageGenModel = msg.model;
|
|
141
141
|
|
|
142
|
+
const wasSuppressed = ctx.suppressConfigReload;
|
|
142
143
|
ctx.setSuppressConfigReload(true);
|
|
143
144
|
try {
|
|
144
145
|
saveRawConfig(raw);
|
|
145
146
|
} catch (err) {
|
|
146
|
-
ctx.setSuppressConfigReload(
|
|
147
|
+
ctx.setSuppressConfigReload(wasSuppressed);
|
|
147
148
|
throw err;
|
|
148
149
|
}
|
|
149
150
|
const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
|
|
@@ -398,41 +399,10 @@ export function handleSlackWebhookConfig(
|
|
|
398
399
|
}
|
|
399
400
|
}
|
|
400
401
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
): void {
|
|
406
|
-
try {
|
|
407
|
-
if (msg.action === 'get') {
|
|
408
|
-
const raw = loadRawConfig();
|
|
409
|
-
const webhookBaseUrl = (raw?.calls as Record<string, unknown>)?.webhookBaseUrl as string ?? '';
|
|
410
|
-
ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl, success: true });
|
|
411
|
-
} else if (msg.action === 'set') {
|
|
412
|
-
const value = (msg.webhookBaseUrl ?? '').trim().replace(/\/+$/, '');
|
|
413
|
-
const raw = loadRawConfig();
|
|
414
|
-
const calls = (raw?.calls ?? {}) as Record<string, unknown>;
|
|
415
|
-
calls.webhookBaseUrl = value || undefined;
|
|
416
|
-
const wasSuppressed = ctx.suppressConfigReload;
|
|
417
|
-
ctx.setSuppressConfigReload(true);
|
|
418
|
-
try {
|
|
419
|
-
saveRawConfig({ ...raw, calls });
|
|
420
|
-
} catch (err) {
|
|
421
|
-
ctx.setSuppressConfigReload(wasSuppressed);
|
|
422
|
-
throw err;
|
|
423
|
-
}
|
|
424
|
-
const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
|
|
425
|
-
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
|
|
426
|
-
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
|
|
427
|
-
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
|
|
428
|
-
ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: value, success: true });
|
|
429
|
-
} else {
|
|
430
|
-
ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: '', success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
|
|
431
|
-
}
|
|
432
|
-
} catch (err) {
|
|
433
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
434
|
-
ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: '', success: false, error: message });
|
|
435
|
-
}
|
|
402
|
+
function computeLocalGatewayTarget(): string {
|
|
403
|
+
const portRaw = process.env.GATEWAY_PORT || '7830';
|
|
404
|
+
const port = Number(portRaw) || 7830;
|
|
405
|
+
return `http://127.0.0.1:${port}`;
|
|
436
406
|
}
|
|
437
407
|
|
|
438
408
|
export function handleIngressConfig(
|
|
@@ -440,28 +410,29 @@ export function handleIngressConfig(
|
|
|
440
410
|
socket: net.Socket,
|
|
441
411
|
ctx: HandlerContext,
|
|
442
412
|
): void {
|
|
413
|
+
const localGatewayTarget = computeLocalGatewayTarget();
|
|
443
414
|
try {
|
|
444
415
|
if (msg.action === 'get') {
|
|
445
416
|
const raw = loadRawConfig();
|
|
446
417
|
const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
|
|
447
418
|
const publicBaseUrl = (ingress.publicBaseUrl as string) ?? '';
|
|
448
|
-
ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl, success: true });
|
|
419
|
+
ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl, localGatewayTarget, success: true });
|
|
449
420
|
} else if (msg.action === 'set') {
|
|
450
421
|
const value = (msg.publicBaseUrl ?? '').trim().replace(/\/+$/, '');
|
|
451
422
|
const raw = loadRawConfig();
|
|
452
423
|
|
|
453
|
-
// Update ingress.publicBaseUrl
|
|
424
|
+
// Update ingress.publicBaseUrl — this is the single source of truth for
|
|
425
|
+
// the canonical public ingress URL. The gateway receives this value via
|
|
426
|
+
// the INGRESS_PUBLIC_BASE_URL env var at spawn time (see hatch.ts).
|
|
427
|
+
// A gateway restart is required for the new value to take effect in
|
|
428
|
+
// inbound Twilio signature validation.
|
|
454
429
|
const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
|
|
455
430
|
ingress.publicBaseUrl = value || undefined;
|
|
456
431
|
|
|
457
|
-
// Also update calls.webhookBaseUrl for backward compat
|
|
458
|
-
const calls = (raw?.calls ?? {}) as Record<string, unknown>;
|
|
459
|
-
calls.webhookBaseUrl = value || undefined;
|
|
460
|
-
|
|
461
432
|
const wasSuppressed = ctx.suppressConfigReload;
|
|
462
433
|
ctx.setSuppressConfigReload(true);
|
|
463
434
|
try {
|
|
464
|
-
saveRawConfig({ ...raw, ingress
|
|
435
|
+
saveRawConfig({ ...raw, ingress });
|
|
465
436
|
} catch (err) {
|
|
466
437
|
ctx.setSuppressConfigReload(wasSuppressed);
|
|
467
438
|
throw err;
|
|
@@ -470,13 +441,26 @@ export function handleIngressConfig(
|
|
|
470
441
|
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
|
|
471
442
|
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
|
|
472
443
|
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
|
|
473
|
-
|
|
444
|
+
|
|
445
|
+
// Propagate to the gateway's process environment so it picks up the
|
|
446
|
+
// new URL on its next config load. For the local-deployment path the
|
|
447
|
+
// gateway runs as a child process that inherited the assistant's env,
|
|
448
|
+
// so updating process.env here ensures the value is visible when the
|
|
449
|
+
// gateway is restarted (e.g. by the self-upgrade skill or a manual
|
|
450
|
+
// `pkill -f gateway`).
|
|
451
|
+
if (value) {
|
|
452
|
+
process.env.INGRESS_PUBLIC_BASE_URL = value;
|
|
453
|
+
}
|
|
454
|
+
// When cleared, do NOT delete the env var — the original env-provided
|
|
455
|
+
// value (if any) serves as a fallback per the documented precedence model.
|
|
456
|
+
|
|
457
|
+
ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: value, localGatewayTarget, success: true });
|
|
474
458
|
} else {
|
|
475
|
-
ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
|
|
459
|
+
ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', localGatewayTarget, success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
|
|
476
460
|
}
|
|
477
461
|
} catch (err) {
|
|
478
462
|
const message = err instanceof Error ? err.message : String(err);
|
|
479
|
-
ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', success: false, error: message });
|
|
463
|
+
ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', localGatewayTarget, success: false, error: message });
|
|
480
464
|
}
|
|
481
465
|
}
|
|
482
466
|
|
|
@@ -613,7 +597,7 @@ export function handleTwitterIntegrationConfig(
|
|
|
613
597
|
type: 'twitter_integration_config_response',
|
|
614
598
|
success: false,
|
|
615
599
|
managedAvailable: false,
|
|
616
|
-
localClientConfigured:
|
|
600
|
+
localClientConfigured: !!previousClientId,
|
|
617
601
|
connected: false,
|
|
618
602
|
error: 'Failed to store client secret in secure storage',
|
|
619
603
|
});
|
|
@@ -705,7 +689,6 @@ export const configHandlers = defineHandlers({
|
|
|
705
689
|
reminder_cancel: handleReminderCancel,
|
|
706
690
|
share_to_slack: handleShareToSlack,
|
|
707
691
|
slack_webhook_config: handleSlackWebhookConfig,
|
|
708
|
-
twilio_webhook_config: handleTwilioWebhookConfig,
|
|
709
692
|
ingress_config: handleIngressConfig,
|
|
710
693
|
vercel_api_config: handleVercelApiConfig,
|
|
711
694
|
twitter_integration_config: handleTwitterIntegrationConfig,
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as net from 'node:net';
|
|
6
|
-
import type { SubagentAbortRequest, SubagentStatusRequest, SubagentMessageRequest } from '../ipc-protocol.js';
|
|
6
|
+
import type { SubagentAbortRequest, SubagentStatusRequest, SubagentMessageRequest, SubagentDetailRequest } from '../ipc-protocol.js';
|
|
7
|
+
import * as conversationStore from '../../memory/conversation-store.js';
|
|
7
8
|
import type { HandlerContext } from './shared.js';
|
|
8
9
|
import { getSubagentManager } from '../../subagent/index.js';
|
|
9
|
-
import { log, defineHandlers } from './shared.js';
|
|
10
|
+
import { log, defineHandlers, isRecord } from './shared.js';
|
|
10
11
|
|
|
11
12
|
export function handleSubagentAbort(
|
|
12
13
|
msg: SubagentAbortRequest,
|
|
@@ -120,8 +121,90 @@ export function handleSubagentMessage(
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
export function handleSubagentDetailRequest(
|
|
125
|
+
msg: SubagentDetailRequest,
|
|
126
|
+
socket: net.Socket,
|
|
127
|
+
ctx: HandlerContext,
|
|
128
|
+
): void {
|
|
129
|
+
// Ownership check: reject if the socket has no bound session.
|
|
130
|
+
const callerSessionId = ctx.socketToSession.get(socket);
|
|
131
|
+
if (!callerSessionId) {
|
|
132
|
+
log.warn({ subagentId: msg.subagentId }, 'Detail request rejected: socket has no bound session');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// If the subagent is still in memory, verify the caller owns it.
|
|
137
|
+
// After daemon restart getState() returns null — we allow the request
|
|
138
|
+
// since the conversationId itself acts as a capability token (the client
|
|
139
|
+
// only knows it because it was sent in a prior subagent_notification).
|
|
140
|
+
const manager = getSubagentManager();
|
|
141
|
+
const state = manager.getState(msg.subagentId);
|
|
142
|
+
if (state && state.config.parentSessionId !== callerSessionId) {
|
|
143
|
+
log.warn({ subagentId: msg.subagentId, callerSessionId }, 'Detail request rejected: subagent not owned by caller');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const subagentMsgs = conversationStore.getMessages(msg.conversationId);
|
|
148
|
+
|
|
149
|
+
// Extract objective from the first user message
|
|
150
|
+
let objective: string | undefined;
|
|
151
|
+
const firstUser = subagentMsgs.find(m => m.role === 'user');
|
|
152
|
+
if (firstUser) {
|
|
153
|
+
try {
|
|
154
|
+
const parsed = JSON.parse(firstUser.content);
|
|
155
|
+
if (Array.isArray(parsed)) {
|
|
156
|
+
const textBlock = parsed.find((b: Record<string, unknown>) => isRecord(b) && b.type === 'text');
|
|
157
|
+
if (textBlock && typeof textBlock.text === 'string') {
|
|
158
|
+
objective = textBlock.text;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch { /* ignore */ }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Extract events from both assistant and user messages.
|
|
165
|
+
// Subagent conversations are not consolidated, so tool_result blocks
|
|
166
|
+
// live in separate user-role messages following the assistant's tool_use.
|
|
167
|
+
const events: Array<{ type: string; content: string; toolName?: string; isError?: boolean }> = [];
|
|
168
|
+
const pendingTools = new Map<string, string>();
|
|
169
|
+
for (const m of subagentMsgs) {
|
|
170
|
+
if (m.role !== 'assistant' && m.role !== 'user') continue;
|
|
171
|
+
let content: unknown[];
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(m.content);
|
|
174
|
+
content = Array.isArray(parsed) ? parsed : [];
|
|
175
|
+
} catch { continue; }
|
|
176
|
+
|
|
177
|
+
for (const block of content) {
|
|
178
|
+
if (!isRecord(block) || typeof block.type !== 'string') continue;
|
|
179
|
+
if (m.role === 'assistant' && block.type === 'text' && typeof block.text === 'string') {
|
|
180
|
+
events.push({ type: 'text', content: block.text });
|
|
181
|
+
} else if (block.type === 'tool_use') {
|
|
182
|
+
const name = typeof block.name === 'string' ? block.name : 'unknown';
|
|
183
|
+
const input = isRecord(block.input) ? block.input as Record<string, unknown> : {};
|
|
184
|
+
const id = typeof block.id === 'string' ? block.id : '';
|
|
185
|
+
events.push({ type: 'tool_use', content: JSON.stringify(input), toolName: name });
|
|
186
|
+
if (id) pendingTools.set(id, name);
|
|
187
|
+
} else if (block.type === 'tool_result') {
|
|
188
|
+
const toolUseId = typeof block.tool_use_id === 'string' ? block.tool_use_id : '';
|
|
189
|
+
const resultContent = typeof block.content === 'string' ? block.content : '';
|
|
190
|
+
const isError = block.is_error === true;
|
|
191
|
+
const toolName = toolUseId ? pendingTools.get(toolUseId) : undefined;
|
|
192
|
+
events.push({ type: 'tool_result', content: resultContent, toolName: toolName ?? 'unknown', isError });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ctx.send(socket, {
|
|
198
|
+
type: 'subagent_detail_response',
|
|
199
|
+
subagentId: msg.subagentId,
|
|
200
|
+
objective,
|
|
201
|
+
events,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
123
205
|
export const subagentHandlers = defineHandlers({
|
|
124
206
|
subagent_abort: handleSubagentAbort,
|
|
125
207
|
subagent_status: handleSubagentStatus,
|
|
126
208
|
subagent_message: handleSubagentMessage,
|
|
209
|
+
subagent_detail_request: handleSubagentDetailRequest,
|
|
127
210
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import * as net from 'node:net';
|
|
2
|
-
import { loadRawConfig } from '../../config/loader.js';
|
|
2
|
+
import { loadRawConfig, loadConfig } from '../../config/loader.js';
|
|
3
3
|
import { getSecureKey, setSecureKey, deleteSecureKey } from '../../security/secure-keys.js';
|
|
4
4
|
import { startOAuth2Flow } from '../../security/oauth2.js';
|
|
5
|
+
import { getPublicBaseUrl } from '../../inbound/public-ingress-urls.js';
|
|
5
6
|
import { upsertCredentialMetadata, getCredentialMetadata } from '../../tools/credentials/metadata-store.js';
|
|
7
|
+
import { ConfigError } from '../../util/errors.js';
|
|
6
8
|
import type { TwitterAuthStartRequest, TwitterAuthStatusRequest } from '../ipc-protocol.js';
|
|
7
9
|
import { log, defineHandlers, type HandlerContext } from './shared.js';
|
|
8
10
|
import type { OAuth2Config } from '../../security/oauth2.js';
|
|
@@ -36,6 +38,33 @@ export async function handleTwitterAuthStart(
|
|
|
36
38
|
|
|
37
39
|
const clientSecret = getSecureKey('credential:integration:twitter:oauth_client_secret') || undefined;
|
|
38
40
|
|
|
41
|
+
// Fail fast if no public ingress URL is configured — Twitter OAuth
|
|
42
|
+
// callbacks must route through the gateway, never via loopback.
|
|
43
|
+
let config;
|
|
44
|
+
try {
|
|
45
|
+
config = loadConfig();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const detail = err instanceof ConfigError ? err.message : String(err);
|
|
48
|
+
ctx.send(socket, {
|
|
49
|
+
type: 'twitter_auth_result',
|
|
50
|
+
success: false,
|
|
51
|
+
error: `Unable to load config: ${detail}`,
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
getPublicBaseUrl(config);
|
|
58
|
+
} catch {
|
|
59
|
+
ctx.send(socket, {
|
|
60
|
+
type: 'twitter_auth_result',
|
|
61
|
+
success: false,
|
|
62
|
+
error:
|
|
63
|
+
'Set ingress.publicBaseUrl (or INGRESS_PUBLIC_BASE_URL) so OAuth callbacks can route through /webhooks/oauth/callback on the gateway.',
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
39
68
|
const oauthConfig: OAuth2Config = {
|
|
40
69
|
authUrl: 'https://twitter.com/i/oauth2/authorize',
|
|
41
70
|
tokenUrl: 'https://api.x.com/2/oauth2/token',
|
|
@@ -49,7 +78,7 @@ export async function handleTwitterAuthStart(
|
|
|
49
78
|
openUrl: (url: string) => {
|
|
50
79
|
ctx.send(socket, { type: 'open_url', url });
|
|
51
80
|
},
|
|
52
|
-
});
|
|
81
|
+
}, { callbackTransport: 'gateway' });
|
|
53
82
|
|
|
54
83
|
// Verify identity via Twitter API before persisting any tokens
|
|
55
84
|
let accountInfo: string;
|
|
@@ -76,12 +76,12 @@
|
|
|
76
76
|
"SkillsUpdateRequest",
|
|
77
77
|
"SlackWebhookConfigRequest",
|
|
78
78
|
"SubagentAbortRequest",
|
|
79
|
+
"SubagentDetailRequest",
|
|
79
80
|
"SubagentMessageRequest",
|
|
80
81
|
"SubagentStatusRequest",
|
|
81
82
|
"SuggestionRequest",
|
|
82
83
|
"TaskSubmit",
|
|
83
84
|
"TrustRulesList",
|
|
84
|
-
"TwilioWebhookConfigRequest",
|
|
85
85
|
"TwitterAuthStartRequest",
|
|
86
86
|
"TwitterAuthStatusRequest",
|
|
87
87
|
"TwitterIntegrationConfigRequest",
|
|
@@ -181,6 +181,7 @@
|
|
|
181
181
|
"SkillsListResponse",
|
|
182
182
|
"SkillsOperationResponse",
|
|
183
183
|
"SlackWebhookConfigResponse",
|
|
184
|
+
"SubagentDetailResponse",
|
|
184
185
|
"SubagentEvent",
|
|
185
186
|
"SubagentSpawned",
|
|
186
187
|
"SubagentStatusChanged",
|
|
@@ -194,7 +195,6 @@
|
|
|
194
195
|
"ToolUseStart",
|
|
195
196
|
"TraceEvent",
|
|
196
197
|
"TrustRulesListResponse",
|
|
197
|
-
"TwilioWebhookConfigResponse",
|
|
198
198
|
"TwitterAuthResult",
|
|
199
199
|
"TwitterAuthStatusResponse",
|
|
200
200
|
"TwitterIntegrationConfigResponse",
|
|
@@ -301,12 +301,12 @@
|
|
|
301
301
|
"skills_update",
|
|
302
302
|
"slack_webhook_config",
|
|
303
303
|
"subagent_abort",
|
|
304
|
+
"subagent_detail_request",
|
|
304
305
|
"subagent_message",
|
|
305
306
|
"subagent_status",
|
|
306
307
|
"suggestion_request",
|
|
307
308
|
"task_submit",
|
|
308
309
|
"trust_rules_list",
|
|
309
|
-
"twilio_webhook_config",
|
|
310
310
|
"twitter_auth_start",
|
|
311
311
|
"twitter_auth_status",
|
|
312
312
|
"twitter_integration_config",
|
|
@@ -406,6 +406,7 @@
|
|
|
406
406
|
"skills_operation_response",
|
|
407
407
|
"skills_state_changed",
|
|
408
408
|
"slack_webhook_config_response",
|
|
409
|
+
"subagent_detail_response",
|
|
409
410
|
"subagent_event",
|
|
410
411
|
"subagent_spawned",
|
|
411
412
|
"subagent_status_changed",
|
|
@@ -419,7 +420,6 @@
|
|
|
419
420
|
"tool_use_start",
|
|
420
421
|
"trace_event",
|
|
421
422
|
"trust_rules_list_response",
|
|
422
|
-
"twilio_webhook_config_response",
|
|
423
423
|
"twitter_auth_result",
|
|
424
424
|
"twitter_auth_status_response",
|
|
425
425
|
"twitter_integration_config_response",
|
|
@@ -472,12 +472,6 @@ export interface SlackWebhookConfigRequest {
|
|
|
472
472
|
webhookUrl?: string;
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
-
export interface TwilioWebhookConfigRequest {
|
|
476
|
-
type: 'twilio_webhook_config';
|
|
477
|
-
action: 'get' | 'set';
|
|
478
|
-
webhookBaseUrl?: string;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
475
|
export interface IngressConfigRequest {
|
|
482
476
|
type: 'ingress_config';
|
|
483
477
|
action: 'get' | 'set';
|
|
@@ -947,7 +941,6 @@ export type ClientMessage =
|
|
|
947
941
|
| ShareAppCloudRequest
|
|
948
942
|
| ShareToSlackRequest
|
|
949
943
|
| SlackWebhookConfigRequest
|
|
950
|
-
| TwilioWebhookConfigRequest
|
|
951
944
|
| IngressConfigRequest
|
|
952
945
|
| VercelApiConfigRequest
|
|
953
946
|
| TwitterIntegrationConfigRequest
|
|
@@ -985,11 +978,8 @@ export type ClientMessage =
|
|
|
985
978
|
| WorkItemCancelRequest
|
|
986
979
|
| SubagentAbortRequest
|
|
987
980
|
| SubagentStatusRequest
|
|
988
|
-
| SubagentMessageRequest
|
|
989
|
-
|
|
990
|
-
// ── Legacy integration IPC stubs ────────────────────────────────────
|
|
991
|
-
// The macOS Settings panel still sends these messages. Stub types keep
|
|
992
|
-
// the dispatch map happy until the client-side migration lands.
|
|
981
|
+
| SubagentMessageRequest
|
|
982
|
+
| SubagentDetailRequest;
|
|
993
983
|
|
|
994
984
|
export interface IntegrationListRequest {
|
|
995
985
|
type: 'integration_list';
|
|
@@ -1234,6 +1224,7 @@ export interface HistoryResponse {
|
|
|
1234
1224
|
label: string;
|
|
1235
1225
|
status: 'completed' | 'failed' | 'aborted';
|
|
1236
1226
|
error?: string;
|
|
1227
|
+
conversationId?: string;
|
|
1237
1228
|
};
|
|
1238
1229
|
}>;
|
|
1239
1230
|
}
|
|
@@ -1697,16 +1688,11 @@ export interface SlackWebhookConfigResponse {
|
|
|
1697
1688
|
error?: string;
|
|
1698
1689
|
}
|
|
1699
1690
|
|
|
1700
|
-
export interface TwilioWebhookConfigResponse {
|
|
1701
|
-
type: 'twilio_webhook_config_response';
|
|
1702
|
-
webhookBaseUrl: string;
|
|
1703
|
-
success: boolean;
|
|
1704
|
-
error?: string;
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
1691
|
export interface IngressConfigResponse {
|
|
1708
1692
|
type: 'ingress_config_response';
|
|
1709
1693
|
publicBaseUrl: string;
|
|
1694
|
+
/** Read-only gateway target computed from GATEWAY_PORT env var (default 7830) + loopback host. */
|
|
1695
|
+
localGatewayTarget: string;
|
|
1710
1696
|
success: boolean;
|
|
1711
1697
|
error?: string;
|
|
1712
1698
|
}
|
|
@@ -2194,7 +2180,6 @@ export type ServerMessage =
|
|
|
2194
2180
|
| GalleryInstallResponse
|
|
2195
2181
|
| ShareToSlackResponse
|
|
2196
2182
|
| SlackWebhookConfigResponse
|
|
2197
|
-
| TwilioWebhookConfigResponse
|
|
2198
2183
|
| IngressConfigResponse
|
|
2199
2184
|
| VercelApiConfigResponse
|
|
2200
2185
|
| TwitterIntegrationConfigResponse
|
|
@@ -2234,7 +2219,8 @@ export type ServerMessage =
|
|
|
2234
2219
|
| OpenTasksWindow
|
|
2235
2220
|
| SubagentSpawned
|
|
2236
2221
|
| SubagentStatusChanged
|
|
2237
|
-
| SubagentEvent
|
|
2222
|
+
| SubagentEvent
|
|
2223
|
+
| SubagentDetailResponse;
|
|
2238
2224
|
|
|
2239
2225
|
// === Subagent IPC ─────────────────────────────────────────────────────
|
|
2240
2226
|
|
|
@@ -2254,6 +2240,18 @@ export interface SubagentStatusChanged {
|
|
|
2254
2240
|
usage?: UsageStats;
|
|
2255
2241
|
}
|
|
2256
2242
|
|
|
2243
|
+
export interface SubagentDetailResponse {
|
|
2244
|
+
type: 'subagent_detail_response';
|
|
2245
|
+
subagentId: string;
|
|
2246
|
+
objective?: string;
|
|
2247
|
+
events: Array<{
|
|
2248
|
+
type: string;
|
|
2249
|
+
content: string;
|
|
2250
|
+
toolName?: string;
|
|
2251
|
+
isError?: boolean;
|
|
2252
|
+
}>;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2257
2255
|
/** Wraps any ServerMessage emitted by a subagent session for routing to the client. */
|
|
2258
2256
|
export interface SubagentEvent {
|
|
2259
2257
|
type: 'subagent_event';
|
|
@@ -2280,6 +2278,12 @@ export interface SubagentMessageRequest {
|
|
|
2280
2278
|
content: string;
|
|
2281
2279
|
}
|
|
2282
2280
|
|
|
2281
|
+
export interface SubagentDetailRequest {
|
|
2282
|
+
type: 'subagent_detail_request';
|
|
2283
|
+
subagentId: string;
|
|
2284
|
+
conversationId: string;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2283
2287
|
// === Contract schema ===
|
|
2284
2288
|
|
|
2285
2289
|
export interface IPCContractSchema {
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -316,6 +316,14 @@ export async function runDaemon(): Promise<void> {
|
|
|
316
316
|
await initializeTools();
|
|
317
317
|
log.info('Daemon startup: providers and tools initialized');
|
|
318
318
|
|
|
319
|
+
// Start the IPC socket BEFORE Qdrant so that clients can connect
|
|
320
|
+
// immediately. Qdrant startup can take 30+ seconds (binary download,
|
|
321
|
+
// /readyz polling) which previously blocked the socket from appearing.
|
|
322
|
+
log.info('Daemon startup: starting DaemonServer (IPC socket)');
|
|
323
|
+
const server = new DaemonServer();
|
|
324
|
+
await server.start();
|
|
325
|
+
log.info('Daemon startup: DaemonServer started');
|
|
326
|
+
|
|
319
327
|
// Initialize Qdrant vector store — non-fatal so the daemon stays up without it
|
|
320
328
|
const qdrantUrl = process.env.QDRANT_URL?.trim() || config.memory.qdrant.url;
|
|
321
329
|
log.info({ qdrantUrl }, 'Daemon startup: initializing Qdrant');
|
|
@@ -336,10 +344,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
336
344
|
log.warn({ err }, 'Qdrant failed to start — memory features will be unavailable');
|
|
337
345
|
}
|
|
338
346
|
|
|
339
|
-
log.info('Daemon startup: starting
|
|
340
|
-
const server = new DaemonServer();
|
|
341
|
-
await server.start();
|
|
342
|
-
log.info('Daemon startup: DaemonServer started, starting memory worker');
|
|
347
|
+
log.info('Daemon startup: starting memory worker');
|
|
343
348
|
const memoryWorker = startMemoryJobsWorker();
|
|
344
349
|
// Initialize watcher engine and register providers
|
|
345
350
|
registerWatcherProvider(gmailProvider);
|
package/src/daemon/server.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { getSubagentManager } from '../subagent/index.js';
|
|
|
39
39
|
import { tryHandlePendingCallAnswer } from '../calls/call-bridge.js';
|
|
40
40
|
import { resolveSlash } from './session-slash.js';
|
|
41
41
|
import { createUserMessage, createAssistantMessage } from '../agent/message-types.js';
|
|
42
|
+
import { registerDaemonCallbacks } from '../work-items/work-item-runner.js';
|
|
42
43
|
|
|
43
44
|
const log = getLogger('server');
|
|
44
45
|
|
|
@@ -184,6 +185,12 @@ export class DaemonServer {
|
|
|
184
185
|
|
|
185
186
|
this.evictor.start();
|
|
186
187
|
|
|
188
|
+
// Register daemon callbacks so tools can trigger work item execution
|
|
189
|
+
registerDaemonCallbacks({
|
|
190
|
+
getOrCreateSession: (conversationId) => this.getOrCreateSession(conversationId),
|
|
191
|
+
broadcast: (msg) => this.broadcast(msg),
|
|
192
|
+
});
|
|
193
|
+
|
|
187
194
|
ensureBlobDir();
|
|
188
195
|
this.blobSweepTimer = setInterval(() => {
|
|
189
196
|
sweepStaleBlobs(30 * 60 * 1000).catch((err) => {
|
|
@@ -282,7 +282,7 @@ export function createToolExecutor(
|
|
|
282
282
|
|
|
283
283
|
// Broadcast tasks_changed so connected clients (e.g. macOS Tasks window)
|
|
284
284
|
// auto-refresh when the LLM mutates the task queue via tools
|
|
285
|
-
if ((name === 'task_list_add' || name === 'task_list_update' || name === 'task_list_remove') && !result.isError) {
|
|
285
|
+
if ((name === 'task_list_add' || name === 'task_list_update' || name === 'task_list_remove' || name === 'task_queue_run') && !result.isError) {
|
|
286
286
|
broadcastToAllClients?.({ type: 'tasks_changed' });
|
|
287
287
|
}
|
|
288
288
|
|