vellum 0.2.2 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bun.lock +68 -100
  2. package/package.json +3 -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 +6 -0
  6. package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
  7. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  8. package/src/__tests__/ipc-snapshot.test.ts +20 -0
  9. package/src/__tests__/memory-regressions.test.ts +100 -2
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
  13. package/src/__tests__/public-ingress-urls.test.ts +206 -0
  14. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  15. package/src/__tests__/tool-executor.test.ts +88 -0
  16. package/src/__tests__/turn-commit.test.ts +64 -0
  17. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  18. package/src/calls/call-domain.ts +3 -3
  19. package/src/calls/twilio-config.ts +25 -9
  20. package/src/calls/twilio-provider.ts +4 -4
  21. package/src/calls/twilio-routes.ts +10 -2
  22. package/src/calls/twilio-webhook-urls.ts +47 -0
  23. package/src/cli/map.ts +30 -6
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/schema.ts +34 -2
  26. package/src/config/system-prompt.ts +1 -1
  27. package/src/config/types.ts +1 -0
  28. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  29. package/src/daemon/computer-use-session.ts +2 -1
  30. package/src/daemon/handlers/config.ts +95 -4
  31. package/src/daemon/handlers/sessions.ts +2 -2
  32. package/src/daemon/handlers/work-items.ts +1 -1
  33. package/src/daemon/ipc-contract-inventory.json +8 -0
  34. package/src/daemon/ipc-contract.ts +39 -1
  35. package/src/daemon/ride-shotgun-handler.ts +2 -1
  36. package/src/daemon/session-agent-loop.ts +37 -2
  37. package/src/daemon/session-conflict-gate.ts +18 -109
  38. package/src/daemon/session-tool-setup.ts +7 -0
  39. package/src/inbound/public-ingress-urls.ts +106 -0
  40. package/src/memory/attachments-store.ts +0 -1
  41. package/src/memory/channel-delivery-store.ts +0 -1
  42. package/src/memory/conflict-intent.ts +114 -0
  43. package/src/memory/conversation-key-store.ts +0 -1
  44. package/src/memory/db.ts +346 -149
  45. package/src/memory/job-handlers/conflict.ts +23 -1
  46. package/src/memory/runs-store.ts +0 -3
  47. package/src/memory/schema.ts +0 -4
  48. package/src/runtime/gateway-client.ts +36 -0
  49. package/src/runtime/http-server.ts +140 -2
  50. package/src/runtime/routes/channel-routes.ts +121 -79
  51. package/src/security/oauth-callback-registry.ts +56 -0
  52. package/src/security/oauth2.ts +174 -58
  53. package/src/swarm/backend-claude-code.ts +1 -1
  54. package/src/tools/assets/search.ts +1 -36
  55. package/src/tools/browser/api-map.ts +123 -50
  56. package/src/tools/claude-code/claude-code.ts +131 -1
  57. package/src/tools/tasks/work-item-list.ts +16 -2
  58. package/src/workspace/commit-message-enrichment-service.ts +3 -3
  59. package/src/workspace/provider-commit-message-generator.ts +57 -14
  60. package/src/workspace/turn-commit.ts +6 -2
@@ -20,6 +20,7 @@ import { getCallOrchestrator, unregisterCallOrchestrator } from './call-state.js
20
20
  import { activeRelayConnections } from './relay-server.js';
21
21
  import { TwilioConversationRelayProvider } from './twilio-provider.js';
22
22
  import { getTwilioConfig } from './twilio-config.js';
23
+ import { buildTwilioVoiceWebhookUrl, buildTwilioStatusCallbackUrl } from './twilio-webhook-urls.js';
23
24
  import type { CallSession } from './types.js';
24
25
 
25
26
  const log = getLogger('call-domain');
@@ -102,12 +103,11 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
102
103
 
103
104
  log.info({ callSessionId: session.id, to: phoneNumber, task }, 'Initiating outbound call');
104
105
 
105
- const baseUrl = config.webhookBaseUrl.replace(/\/$/, '');
106
106
  const { callSid } = await provider.initiateCall({
107
107
  from: config.phoneNumber,
108
108
  to: phoneNumber,
109
- webhookUrl: `${baseUrl}/webhooks/twilio/voice?callSessionId=${session.id}`,
110
- statusCallbackUrl: `${baseUrl}/webhooks/twilio/status`,
109
+ webhookUrl: buildTwilioVoiceWebhookUrl(config.webhookBaseUrl, session.id),
110
+ statusCallbackUrl: buildTwilioStatusCallbackUrl(config.webhookBaseUrl),
111
111
  });
112
112
 
113
113
  updateCallSession(session.id, { providerCallSid: callSid });
@@ -1,5 +1,8 @@
1
1
  import { getSecureKey } from '../security/secure-keys.js';
2
2
  import { getLogger } from '../util/logger.js';
3
+ import { loadConfig } from '../config/loader.js';
4
+ import { getWebhookBaseUrl } from './twilio-webhook-urls.js';
5
+ import { getTwilioRelayUrl } from '../inbound/public-ingress-urls.js';
3
6
 
4
7
  const log = getLogger('twilio-config');
5
8
 
@@ -12,21 +15,34 @@ export interface TwilioConfig {
12
15
  }
13
16
 
14
17
  export function getTwilioConfig(): TwilioConfig {
15
- const accountSid = getSecureKey('twilio_account_sid');
16
- const authToken = getSecureKey('twilio_auth_token');
17
- const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('twilio_phone_number') || '';
18
- const webhookBaseUrl = process.env.TWILIO_WEBHOOK_BASE_URL || '';
19
- const wssBaseUrl = process.env.TWILIO_WSS_BASE_URL || '';
18
+ const accountSid = getSecureKey('credential:twilio:account_sid');
19
+ const authToken = getSecureKey('credential:twilio:auth_token');
20
+ const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('credential:twilio:phone_number') || '';
21
+ const config = loadConfig();
22
+ const webhookBaseUrl = getWebhookBaseUrl(config);
23
+
24
+ // In gateway_only mode, ignore TWILIO_WSS_BASE_URL and always use the
25
+ // centralized relay URL derived from the public ingress base URL.
26
+ let wssBaseUrl: string;
27
+ if (config.ingress.mode === 'gateway_only') {
28
+ if (process.env.TWILIO_WSS_BASE_URL) {
29
+ log.warn('TWILIO_WSS_BASE_URL env var is ignored in gateway-only mode. Relay URL is derived from ingress.publicBaseUrl.');
30
+ }
31
+ try {
32
+ wssBaseUrl = getTwilioRelayUrl(config);
33
+ } catch {
34
+ wssBaseUrl = '';
35
+ }
36
+ } else {
37
+ wssBaseUrl = process.env.TWILIO_WSS_BASE_URL || '';
38
+ }
20
39
 
21
40
  if (!accountSid || !authToken) {
22
- throw new Error('Twilio credentials not configured. Set twilio_account_sid and twilio_auth_token via the credential_store tool.');
41
+ throw new Error('Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.');
23
42
  }
24
43
  if (!phoneNumber) {
25
44
  throw new Error('TWILIO_PHONE_NUMBER not configured.');
26
45
  }
27
- if (!webhookBaseUrl) {
28
- throw new Error('TWILIO_WEBHOOK_BASE_URL not configured.');
29
- }
30
46
 
31
47
  log.debug('Twilio config loaded successfully');
32
48
 
@@ -17,11 +17,11 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
17
17
  // ── Credential helpers ──────────────────────────────────────────────
18
18
 
19
19
  private getCredentials(): { accountSid: string; authToken: string } {
20
- const accountSid = getSecureKey('twilio_account_sid');
21
- const authToken = getSecureKey('twilio_auth_token');
20
+ const accountSid = getSecureKey('credential:twilio:account_sid');
21
+ const authToken = getSecureKey('credential:twilio:auth_token');
22
22
  if (!accountSid || !authToken) {
23
23
  throw new Error(
24
- 'Twilio credentials not configured. Set twilio_account_sid and twilio_auth_token via the credential_store tool.',
24
+ 'Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.',
25
25
  );
26
26
  }
27
27
  return { accountSid, authToken };
@@ -134,7 +134,7 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
134
134
  * HTTP server webhook middleware) can check availability independently.
135
135
  */
136
136
  static getAuthToken(): string | null {
137
- return getSecureKey('twilio_auth_token') ?? null;
137
+ return getSecureKey('credential:twilio:auth_token') ?? null;
138
138
  }
139
139
 
140
140
  /**
@@ -22,6 +22,8 @@ import type { CallStatus } from './types.js';
22
22
  import { logDeadLetterEvent } from './call-recovery.js';
23
23
  import { isTerminalState } from './call-state-machine.js';
24
24
  import { getTwilioConfig } from './twilio-config.js';
25
+ import { loadConfig } from '../config/loader.js';
26
+ import { getTwilioRelayUrl } from '../inbound/public-ingress-urls.js';
25
27
 
26
28
  const log = getLogger('twilio-routes');
27
29
 
@@ -125,8 +127,14 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
125
127
  log.info({ callSessionId, callSid }, 'Stored CallSid from voice webhook');
126
128
  }
127
129
 
128
- const config = getTwilioConfig();
129
- const relayUrl = resolveRelayUrl(config.wssBaseUrl, config.webhookBaseUrl);
130
+ const twilioConfig = getTwilioConfig();
131
+ let relayUrl: string;
132
+ try {
133
+ relayUrl = getTwilioRelayUrl(loadConfig());
134
+ } catch {
135
+ // Fallback to legacy resolution when ingress is not configured
136
+ relayUrl = resolveRelayUrl(twilioConfig.wssBaseUrl, twilioConfig.webhookBaseUrl);
137
+ }
130
138
  const welcomeGreeting = process.env.CALL_WELCOME_GREETING ?? 'Hello, how can I help you today?';
131
139
 
132
140
  const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Twilio webhook URL helpers.
3
+ *
4
+ * This module is a thin backward-compat wrapper that delegates to the
5
+ * centralized URL builders in inbound/public-ingress-urls.ts.
6
+ */
7
+
8
+ import {
9
+ getPublicBaseUrl,
10
+ type IngressConfig,
11
+ } from '../inbound/public-ingress-urls.js';
12
+
13
+ /**
14
+ * Resolve the webhook base URL from config, falling back to the
15
+ * TWILIO_WEBHOOK_BASE_URL environment variable with a deprecation warning.
16
+ * Throws if neither source provides a value.
17
+ *
18
+ * @deprecated Use `getPublicBaseUrl` from `inbound/public-ingress-urls.ts` instead.
19
+ */
20
+ export function getWebhookBaseUrl(config: { calls: { webhookBaseUrl?: string }; ingress?: { publicBaseUrl?: string } }): string {
21
+ return getPublicBaseUrl(config as IngressConfig);
22
+ }
23
+
24
+ /**
25
+ * Trim whitespace and strip trailing slash from a URL string.
26
+ */
27
+ export function normalizeBaseUrl(url: string): string {
28
+ return url.trim().replace(/\/+$/, '');
29
+ }
30
+
31
+ /**
32
+ * Build the Twilio voice webhook URL for a given call session.
33
+ *
34
+ * @deprecated Use `getTwilioVoiceWebhookUrl` from `inbound/public-ingress-urls.ts` instead.
35
+ */
36
+ export function buildTwilioVoiceWebhookUrl(baseUrl: string, callSessionId: string): string {
37
+ return `${normalizeBaseUrl(baseUrl)}/webhooks/twilio/voice?callSessionId=${callSessionId}`;
38
+ }
39
+
40
+ /**
41
+ * Build the Twilio status callback URL.
42
+ *
43
+ * @deprecated Use `getTwilioStatusCallbackUrl` from `inbound/public-ingress-urls.ts` instead.
44
+ */
45
+ export function buildTwilioStatusCallbackUrl(baseUrl: string): string {
46
+ return `${normalizeBaseUrl(baseUrl)}/webhooks/twilio/status`;
47
+ }
package/src/cli/map.ts CHANGED
@@ -15,9 +15,20 @@ import {
15
15
  serialize,
16
16
  createMessageParser,
17
17
  } from '../daemon/ipc-protocol.js';
18
+ import { parse as parseTld } from 'tldts';
18
19
  import { loadRecording } from '../tools/browser/recording-store.js';
19
20
  import { analyzeApiMap, saveApiMap, printApiMapTable } from '../tools/browser/api-map.js';
20
21
 
22
+ /**
23
+ * Extract the registrable base domain from a hostname.
24
+ * e.g. "open.spotify.com" → "spotify.com", "connect.garmin.com" → "garmin.com"
25
+ * Falls back to the input if tldts can't parse it.
26
+ */
27
+ function getBaseDomain(domain: string): string {
28
+ const result = parseTld(domain);
29
+ return result.domain ?? domain;
30
+ }
31
+
21
32
  // ---------------------------------------------------------------------------
22
33
  // Helpers
23
34
  // ---------------------------------------------------------------------------
@@ -95,8 +106,12 @@ interface LearnResult {
95
106
  recordingPath?: string;
96
107
  }
97
108
 
98
- async function startLearnSession(domain: string, durationSeconds: number): Promise<LearnResult> {
99
- await ensureChromeWithCDP(domain);
109
+ async function startLearnSession(
110
+ navigateDomain: string,
111
+ recordDomain: string,
112
+ durationSeconds: number,
113
+ ): Promise<LearnResult> {
114
+ await ensureChromeWithCDP(navigateDomain);
100
115
 
101
116
  return new Promise((resolve, reject) => {
102
117
  const socketPath = getSocketPath();
@@ -123,7 +138,8 @@ async function startLearnSession(domain: string, durationSeconds: number): Promi
123
138
  durationSeconds,
124
139
  intervalSeconds: 5,
125
140
  mode: 'learn',
126
- targetDomain: domain,
141
+ targetDomain: recordDomain,
142
+ navigateDomain,
127
143
  autoNavigate: true,
128
144
  } as unknown as import('../daemon/ipc-protocol.js').ClientMessage),
129
145
  );
@@ -196,11 +212,19 @@ export function registerMapCommand(program: Command): void {
196
212
  const duration = parseInt(opts.duration, 10);
197
213
 
198
214
  try {
199
- // 1. Start learn session (launches Chrome + auto-navigates)
215
+ // Split into navigation domain (what Chrome browses) and recording domain (network filter).
216
+ // e.g. "open.spotify.com" → navigate open.spotify.com, record *.spotify.com
217
+ const navigateDomain = domain;
218
+ const recordDomain = getBaseDomain(domain);
219
+
200
220
  if (!json) {
201
- console.log(`Starting API map session for ${domain} (${duration}s)...`);
221
+ if (navigateDomain !== recordDomain) {
222
+ console.log(`Starting API map session: navigating ${navigateDomain}, recording *.${recordDomain} (${duration}s)...`);
223
+ } else {
224
+ console.log(`Starting API map session for ${domain} (${duration}s)...`);
225
+ }
202
226
  }
203
- const result = await startLearnSession(domain, duration);
227
+ const result = await startLearnSession(navigateDomain, recordDomain, duration);
204
228
 
205
229
  if (!result.recordingId) {
206
230
  outputError('Recording completed but no recording ID returned');
@@ -217,6 +217,7 @@ export const DEFAULT_CONFIG: AssistantConfig = {
217
217
  calls: {
218
218
  enabled: true,
219
219
  provider: 'twilio' as const,
220
+ webhookBaseUrl: '',
220
221
  maxDurationSeconds: 3600,
221
222
  userConsultTimeoutSeconds: 120,
222
223
  disclosure: {
@@ -227,4 +228,8 @@ export const DEFAULT_CONFIG: AssistantConfig = {
227
228
  denyCategories: [],
228
229
  },
229
230
  },
231
+ ingress: {
232
+ publicBaseUrl: '',
233
+ mode: 'compat' as const,
234
+ },
230
235
  };
@@ -9,6 +9,7 @@ const VALID_SANDBOX_BACKENDS = ['native', 'docker'] as const;
9
9
  const VALID_DOCKER_NETWORKS = ['none', 'bridge'] as const;
10
10
  const VALID_PERMISSIONS_MODES = ['legacy', 'strict'] as const;
11
11
  const VALID_CALL_PROVIDERS = ['twilio'] as const;
12
+ const VALID_INGRESS_MODES = ['gateway_only', 'compat'] as const;
12
13
 
13
14
  export const TimeoutConfigSchema = z.object({
14
15
  shellMaxTimeoutSec: z
@@ -780,8 +781,19 @@ export const WorkspaceGitConfigSchema = z.object({
780
781
  .int().positive().default(2000),
781
782
  backoffMaxMs: z.number({ error: 'workspaceGit.commitMessageLLM.breaker.backoffMaxMs must be a number' })
782
783
  .int().positive().default(60000),
783
- }).default({}),
784
- }).default({}),
784
+ }).default({ openAfterFailures: 3, backoffBaseMs: 2000, backoffMaxMs: 60000 }),
785
+ }).default({
786
+ enabled: false,
787
+ useConfiguredProvider: true,
788
+ providerFastModelOverrides: {},
789
+ timeoutMs: 600,
790
+ maxTokens: 120,
791
+ temperature: 0.2,
792
+ maxFilesInPrompt: 30,
793
+ maxDiffBytes: 12000,
794
+ minRemainingTurnBudgetMs: 1000,
795
+ breaker: { openAfterFailures: 3, backoffBaseMs: 2000, backoffMaxMs: 60000 },
796
+ }),
785
797
  });
786
798
 
787
799
  export const AgentHeartbeatConfigSchema = z.object({
@@ -883,6 +895,9 @@ export const CallsConfigSchema = z.object({
883
895
  error: `calls.provider must be one of: ${VALID_CALL_PROVIDERS.join(', ')}`,
884
896
  })
885
897
  .default('twilio'),
898
+ webhookBaseUrl: z
899
+ .string({ error: 'calls.webhookBaseUrl must be a string' })
900
+ .default(''),
886
901
  maxDurationSeconds: z
887
902
  .number({ error: 'calls.maxDurationSeconds must be a number' })
888
903
  .int('calls.maxDurationSeconds must be an integer')
@@ -911,6 +926,17 @@ export const SkillsConfigSchema = z.object({
911
926
  allowBundled: z.array(z.string()).nullable().default(null),
912
927
  });
913
928
 
929
+ export const IngressConfigSchema = z.object({
930
+ publicBaseUrl: z
931
+ .string({ error: 'ingress.publicBaseUrl must be a string' })
932
+ .default(''),
933
+ mode: z
934
+ .enum(VALID_INGRESS_MODES, {
935
+ error: `ingress.mode must be one of: ${VALID_INGRESS_MODES.join(', ')}`,
936
+ })
937
+ .default('compat'),
938
+ });
939
+
914
940
  export const AssistantConfigSchema = z.object({
915
941
  provider: z
916
942
  .enum(VALID_PROVIDERS, {
@@ -1149,6 +1175,7 @@ export const AssistantConfigSchema = z.object({
1149
1175
  calls: CallsConfigSchema.default({
1150
1176
  enabled: true,
1151
1177
  provider: 'twilio',
1178
+ webhookBaseUrl: '',
1152
1179
  maxDurationSeconds: 3600,
1153
1180
  userConsultTimeoutSeconds: 120,
1154
1181
  disclosure: {
@@ -1159,6 +1186,10 @@ export const AssistantConfigSchema = z.object({
1159
1186
  denyCategories: [],
1160
1187
  },
1161
1188
  }),
1189
+ ingress: IngressConfigSchema.default({
1190
+ publicBaseUrl: '',
1191
+ mode: 'compat',
1192
+ }),
1162
1193
  }).superRefine((config, ctx) => {
1163
1194
  if (config.contextWindow.targetInputTokens >= config.contextWindow.maxInputTokens) {
1164
1195
  ctx.addIssue({
@@ -1219,3 +1250,4 @@ export type WorkspaceGitConfig = z.infer<typeof WorkspaceGitConfigSchema>;
1219
1250
  export type CallsConfig = z.infer<typeof CallsConfigSchema>;
1220
1251
  export type CallsDisclosureConfig = z.infer<typeof CallsDisclosureConfigSchema>;
1221
1252
  export type CallsSafetyConfig = z.infer<typeof CallsSafetyConfigSchema>;
1253
+ export type IngressConfig = z.infer<typeof IngressConfigSchema>;
@@ -218,7 +218,7 @@ function buildTaskScheduleReminderRoutingSection(): string {
218
218
  '',
219
219
  'You can create ad-hoc work items by providing just a `title` to `task_list_add` — no existing task template is needed. A lightweight template is auto-created behind the scenes. For reusable task definitions with templates and input schemas, use `task_save` first.',
220
220
  '',
221
- '**IMPORTANT:** When you call `task_list_show`, the Tasks window opens automatically on the client. Do NOT also create a separate surface/UI (via `ui_show` or `app_create`) to display the task queue. Doing so causes duplicate Task Queue windows. Just call `task_list_show` and let the native window handle the presentation.',
221
+ '**IMPORTANT:** When you call `task_list_show`, the Tasks window opens automatically on the client AND the tool returns the current task list. Present a brief summary of the tasks in your chat response so the user can see them inline. Do NOT also create a separate surface/UI (via `ui_show` or `app_create`) to display the task queue that causes duplicate windows.',
222
222
  '',
223
223
  '### Schedules (schedule_create / schedule_list / schedule_update / schedule_delete)',
224
224
  'For recurring automated jobs that run on a recurrence schedule (cron or RRULE). Use ONLY when the user explicitly wants:',
@@ -34,4 +34,5 @@ export type {
34
34
  CallsConfig,
35
35
  CallsDisclosureConfig,
36
36
  CallsSafetyConfig,
37
+ IngressConfig,
37
38
  } from './schema.js';
@@ -98,8 +98,4 @@ Summarize what was done:
98
98
  - Bot commands registered: /new
99
99
  - Credentials stored securely in the vault
100
100
 
101
- Remind the user that the gateway needs these environment variables set to match:
102
- - `TELEGRAM_BOT_TOKEN` — the bot token
103
- - `TELEGRAM_WEBHOOK_SECRET` — the generated secret
104
-
105
- The values are stored in the credential vault and can be retrieved for gateway configuration.
101
+ The gateway automatically detects credentials from the vault and will begin accepting Telegram webhooks shortly. No manual environment variable configuration is needed.
@@ -15,6 +15,7 @@ import { AgentLoop } from '../agent/loop.js';
15
15
  import { ToolExecutor } from '../tools/executor.js';
16
16
  import { PermissionPrompter } from '../permissions/prompter.js';
17
17
  import { SecretPrompter } from '../permissions/secret-prompter.js';
18
+ import type { UserDecision } from '../permissions/types.js';
18
19
  import { allUiSurfaceTools } from '../tools/ui-surface/definitions.js';
19
20
  import { allComputerUseTools } from '../tools/computer-use/definitions.js';
20
21
  import { registerSkillTools } from '../tools/registry.js';
@@ -893,7 +894,7 @@ export class ComputerUseSession {
893
894
 
894
895
  handleConfirmationResponse(
895
896
  requestId: string,
896
- decision: 'allow' | 'always_allow' | 'deny',
897
+ decision: UserDecision,
897
898
  selectedPattern?: string,
898
899
  selectedScope?: string,
899
900
  ): void {
@@ -19,6 +19,8 @@ import type {
19
19
  ReminderCancel,
20
20
  ShareToSlackRequest,
21
21
  SlackWebhookConfigRequest,
22
+ TwilioWebhookConfigRequest,
23
+ IngressConfigRequest,
22
24
  VercelApiConfigRequest,
23
25
  TwitterIntegrationConfigRequest,
24
26
  } from '../ipc-protocol.js';
@@ -164,9 +166,9 @@ export function handleAddTrustRule(
164
166
  ): void {
165
167
  try {
166
168
  addRule(msg.toolName, msg.pattern, msg.scope, msg.decision);
167
- log.info({ tool: msg.toolName, pattern: msg.pattern, scope: msg.scope, decision: msg.decision }, 'Trust rule added via client');
169
+ log.info({ toolName: msg.toolName, pattern: msg.pattern, scope: msg.scope, decision: msg.decision }, 'Trust rule added via client');
168
170
  } catch (err) {
169
- log.error({ err }, 'Failed to add trust rule');
171
+ log.error({ err, toolName: msg.toolName, pattern: msg.pattern, scope: msg.scope }, 'Failed to add trust rule via client');
170
172
  }
171
173
  }
172
174
 
@@ -396,6 +398,88 @@ export function handleSlackWebhookConfig(
396
398
  }
397
399
  }
398
400
 
401
+ export function handleTwilioWebhookConfig(
402
+ msg: TwilioWebhookConfigRequest,
403
+ socket: net.Socket,
404
+ ctx: HandlerContext,
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
+ }
436
+ }
437
+
438
+ export function handleIngressConfig(
439
+ msg: IngressConfigRequest,
440
+ socket: net.Socket,
441
+ ctx: HandlerContext,
442
+ ): void {
443
+ try {
444
+ if (msg.action === 'get') {
445
+ const raw = loadRawConfig();
446
+ const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
447
+ const publicBaseUrl = (ingress.publicBaseUrl as string) ?? '';
448
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl, success: true });
449
+ } else if (msg.action === 'set') {
450
+ const value = (msg.publicBaseUrl ?? '').trim().replace(/\/+$/, '');
451
+ const raw = loadRawConfig();
452
+
453
+ // Update ingress.publicBaseUrl
454
+ const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
455
+ ingress.publicBaseUrl = value || undefined;
456
+
457
+ // Also update calls.webhookBaseUrl for backward compat
458
+ const calls = (raw?.calls ?? {}) as Record<string, unknown>;
459
+ calls.webhookBaseUrl = value || undefined;
460
+
461
+ const wasSuppressed = ctx.suppressConfigReload;
462
+ ctx.setSuppressConfigReload(true);
463
+ try {
464
+ saveRawConfig({ ...raw, ingress, calls });
465
+ } catch (err) {
466
+ ctx.setSuppressConfigReload(wasSuppressed);
467
+ throw err;
468
+ }
469
+ const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
470
+ if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
471
+ const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
472
+ ctx.debounceTimers.set('__suppress_reset__', resetTimer);
473
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: value, success: true });
474
+ } else {
475
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
476
+ }
477
+ } catch (err) {
478
+ const message = err instanceof Error ? err.message : String(err);
479
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', success: false, error: message });
480
+ }
481
+ }
482
+
399
483
  export function handleVercelApiConfig(
400
484
  msg: VercelApiConfigRequest,
401
485
  socket: net.Socket,
@@ -503,6 +587,7 @@ export function handleTwitterIntegrationConfig(
503
587
  });
504
588
  return;
505
589
  }
590
+ const previousClientId = getSecureKey('credential:integration:twitter:oauth_client_id');
506
591
  const storedId = setSecureKey('credential:integration:twitter:oauth_client_id', msg.clientId);
507
592
  if (!storedId) {
508
593
  ctx.send(socket, {
@@ -518,8 +603,12 @@ export function handleTwitterIntegrationConfig(
518
603
  if (msg.clientSecret) {
519
604
  const storedSecret = setSecureKey('credential:integration:twitter:oauth_client_secret', msg.clientSecret);
520
605
  if (!storedSecret) {
521
- // Roll back the already-persisted client ID to avoid inconsistent OAuth state
522
- deleteSecureKey('credential:integration:twitter:oauth_client_id');
606
+ // Roll back the client ID to its previous value to avoid inconsistent OAuth state
607
+ if (previousClientId) {
608
+ setSecureKey('credential:integration:twitter:oauth_client_id', previousClientId);
609
+ } else {
610
+ deleteSecureKey('credential:integration:twitter:oauth_client_id');
611
+ }
523
612
  ctx.send(socket, {
524
613
  type: 'twitter_integration_config_response',
525
614
  success: false,
@@ -616,6 +705,8 @@ export const configHandlers = defineHandlers({
616
705
  reminder_cancel: handleReminderCancel,
617
706
  share_to_slack: handleShareToSlack,
618
707
  slack_webhook_config: handleSlackWebhookConfig,
708
+ twilio_webhook_config: handleTwilioWebhookConfig,
709
+ ingress_config: handleIngressConfig,
619
710
  vercel_api_config: handleVercelApiConfig,
620
711
  twitter_integration_config: handleTwitterIntegrationConfig,
621
712
  env_vars_request: (_msg, socket, ctx) => handleEnvVarsRequest(socket, ctx),
@@ -145,7 +145,7 @@ export function handleConfirmationResponse(
145
145
  ctx.touchSession(sessionId);
146
146
  session.handleConfirmationResponse(
147
147
  msg.requestId,
148
- msg.decision as 'allow' | 'always_allow' | 'deny',
148
+ msg.decision,
149
149
  msg.selectedPattern,
150
150
  msg.selectedScope,
151
151
  );
@@ -158,7 +158,7 @@ export function handleConfirmationResponse(
158
158
  if (cuSession.hasPendingConfirmation(msg.requestId)) {
159
159
  cuSession.handleConfirmationResponse(
160
160
  msg.requestId,
161
- msg.decision as 'allow' | 'always_allow' | 'deny',
161
+ msg.decision,
162
162
  msg.selectedPattern,
163
163
  msg.selectedScope,
164
164
  );
@@ -426,8 +426,8 @@ export async function handleWorkItemRunTask(
426
426
  // Execute task asynchronously — lazily create a session inside the callback
427
427
  // using the conversationId provided by runTask, so the session references
428
428
  // the conversation that was actually inserted into the database.
429
+ let session: Awaited<ReturnType<typeof ctx.getOrCreateSession>> | null = null;
429
430
  try {
430
- let session: Awaited<ReturnType<typeof ctx.getOrCreateSession>> | null = null;
431
431
  const result = await runTask(
432
432
  { taskId: workItem.taskId, workingDir: process.cwd(), approvedTools },
433
433
  async (conversationId, message, taskRunId) => {
@@ -32,6 +32,7 @@
32
32
  "HistoryRequest",
33
33
  "HomeBaseGetRequest",
34
34
  "ImageGenModelSetRequest",
35
+ "IngressConfigRequest",
35
36
  "IntegrationConnectRequest",
36
37
  "IntegrationDisconnectRequest",
37
38
  "IntegrationListRequest",
@@ -80,6 +81,7 @@
80
81
  "SuggestionRequest",
81
82
  "TaskSubmit",
82
83
  "TrustRulesList",
84
+ "TwilioWebhookConfigRequest",
83
85
  "TwitterAuthStartRequest",
84
86
  "TwitterAuthStatusRequest",
85
87
  "TwitterIntegrationConfigRequest",
@@ -141,6 +143,7 @@
141
143
  "GetSigningIdentityRequest",
142
144
  "HistoryResponse",
143
145
  "HomeBaseGetResponse",
146
+ "IngressConfigResponse",
144
147
  "IntegrationConnectResult",
145
148
  "IntegrationListResponse",
146
149
  "IpcBlobProbeResult",
@@ -191,6 +194,7 @@
191
194
  "ToolUseStart",
192
195
  "TraceEvent",
193
196
  "TrustRulesListResponse",
197
+ "TwilioWebhookConfigResponse",
194
198
  "TwitterAuthResult",
195
199
  "TwitterAuthStatusResponse",
196
200
  "TwitterIntegrationConfigResponse",
@@ -253,6 +257,7 @@
253
257
  "history_request",
254
258
  "home_base_get",
255
259
  "image_gen_model_set",
260
+ "ingress_config",
256
261
  "integration_connect",
257
262
  "integration_disconnect",
258
263
  "integration_list",
@@ -301,6 +306,7 @@
301
306
  "suggestion_request",
302
307
  "task_submit",
303
308
  "trust_rules_list",
309
+ "twilio_webhook_config",
304
310
  "twitter_auth_start",
305
311
  "twitter_auth_status",
306
312
  "twitter_integration_config",
@@ -362,6 +368,7 @@
362
368
  "get_signing_identity",
363
369
  "history_response",
364
370
  "home_base_get_response",
371
+ "ingress_config_response",
365
372
  "integration_connect_result",
366
373
  "integration_list_response",
367
374
  "ipc_blob_probe_result",
@@ -412,6 +419,7 @@
412
419
  "tool_use_start",
413
420
  "trace_event",
414
421
  "trust_rules_list_response",
422
+ "twilio_webhook_config_response",
415
423
  "twitter_auth_result",
416
424
  "twitter_auth_status_response",
417
425
  "twitter_integration_config_response",