vellum 0.2.12 → 0.2.14

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 (209) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +171 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +402 -5
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +28 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +96 -8
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +97 -0
  94. package/src/calls/elevenlabs-config.ts +31 -0
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +50 -6
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +114 -0
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +26 -2
  116. package/src/config/schema.ts +178 -9
  117. package/src/config/types.ts +3 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/defaults.ts +11 -0
  160. package/src/permissions/prompter.ts +0 -4
  161. package/src/permissions/shell-identity.ts +227 -0
  162. package/src/permissions/trust-store.ts +76 -53
  163. package/src/permissions/types.ts +0 -19
  164. package/src/permissions/workspace-policy.ts +114 -0
  165. package/src/providers/retry.ts +12 -37
  166. package/src/runtime/assistant-event-hub.ts +41 -4
  167. package/src/runtime/channel-approval-parser.ts +60 -0
  168. package/src/runtime/channel-approval-types.ts +71 -0
  169. package/src/runtime/channel-approvals.ts +145 -0
  170. package/src/runtime/gateway-client.ts +16 -0
  171. package/src/runtime/http-server.ts +29 -9
  172. package/src/runtime/routes/call-routes.ts +52 -2
  173. package/src/runtime/routes/channel-routes.ts +296 -16
  174. package/src/runtime/routes/conversation-routes.ts +12 -5
  175. package/src/runtime/routes/events-routes.ts +97 -28
  176. package/src/runtime/routes/run-routes.ts +2 -7
  177. package/src/runtime/run-orchestrator.ts +0 -3
  178. package/src/schedule/recurrence-engine.ts +26 -2
  179. package/src/schedule/recurrence-types.ts +1 -1
  180. package/src/schedule/schedule-store.ts +12 -3
  181. package/src/security/secret-scanner.ts +7 -0
  182. package/src/tasks/ephemeral-permissions.ts +0 -2
  183. package/src/tasks/task-scheduler.ts +2 -1
  184. package/src/tools/calls/call-start.ts +8 -0
  185. package/src/tools/execution-target.ts +21 -0
  186. package/src/tools/execution-timeout.ts +49 -0
  187. package/src/tools/executor.ts +6 -135
  188. package/src/tools/network/web-search.ts +9 -32
  189. package/src/tools/policy-context.ts +29 -0
  190. package/src/tools/schedule/update.ts +8 -1
  191. package/src/tools/terminal/parser.ts +16 -18
  192. package/src/tools/types.ts +4 -11
  193. package/src/twitter/oauth-client.ts +102 -0
  194. package/src/twitter/router.ts +101 -0
  195. package/src/util/debounce.ts +88 -0
  196. package/src/util/network-info.ts +47 -0
  197. package/src/util/platform.ts +29 -4
  198. package/src/util/promise-guard.ts +37 -0
  199. package/src/util/retry.ts +98 -0
  200. package/src/util/truncate.ts +1 -1
  201. package/src/workspace/git-service.ts +129 -112
  202. package/src/tools/contacts/contact-merge.ts +0 -55
  203. package/src/tools/contacts/contact-search.ts +0 -58
  204. package/src/tools/contacts/contact-upsert.ts +0 -64
  205. package/src/tools/playbooks/index.ts +0 -4
  206. package/src/tools/playbooks/playbook-create.ts +0 -96
  207. package/src/tools/playbooks/playbook-delete.ts +0 -52
  208. package/src/tools/playbooks/playbook-list.ts +0 -74
  209. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -2,12 +2,17 @@ import * as net from 'node:net';
2
2
  import { getConfig, loadRawConfig, saveRawConfig } from '../../config/loader.js';
3
3
  import { initializeProviders } from '../../providers/registry.js';
4
4
  import { addRule, removeRule, updateRule, getAllRules, acceptStarterBundle } from '../../permissions/trust-store.js';
5
+ import { classifyRisk, check, generateAllowlistOptions, generateScopeOptions } from '../../permissions/checker.js';
6
+ import { isSideEffectTool } from '../../tools/executor.js';
7
+ import { resolveExecutionTarget } from '../../tools/execution-target.js';
8
+ import { getAllTools, getTool } from '../../tools/registry.js';
5
9
  import { listSchedules, updateSchedule, deleteSchedule, describeCronExpression } from '../../schedule/schedule-store.js';
6
10
  import { listReminders, cancelReminder } from '../../tools/reminder/reminder-store.js';
7
11
  import { getSecureKey, setSecureKey, deleteSecureKey } from '../../security/secure-keys.js';
8
12
  import { upsertCredentialMetadata, deleteCredentialMetadata, getCredentialMetadata } from '../../tools/credentials/metadata-store.js';
9
13
  import { postToSlackWebhook } from '../../slack/slack-webhook.js';
10
14
  import { getApp } from '../../memory/app-store.js';
15
+ import { readHttpToken } from '../../util/platform.js';
11
16
  import type {
12
17
  ModelSetRequest,
13
18
  ImageGenModelSetRequest,
@@ -22,13 +27,52 @@ import type {
22
27
  IngressConfigRequest,
23
28
  VercelApiConfigRequest,
24
29
  TwitterIntegrationConfigRequest,
30
+ TelegramConfigRequest,
31
+ ToolPermissionSimulateRequest,
25
32
  } from '../ipc-protocol.js';
26
33
  import { log, CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, type HandlerContext } from './shared.js';
27
34
  import { MODEL_TO_PROVIDER } from '../session-slash.js';
28
35
 
29
- // Snapshot the env-provided value at module load time so we can restore it
30
- // when the user clears a Settings-set override.
31
- const ORIGINAL_INGRESS_ENV = process.env.INGRESS_PUBLIC_BASE_URL;
36
+ // Lazily capture the env-provided INGRESS_PUBLIC_BASE_URL on first access
37
+ // rather than at module load time. The daemon loads ~/.vellum/.env inside
38
+ // runDaemon() (see lifecycle.ts), which runs AFTER static ES module imports
39
+ // resolve. A module-level snapshot would miss dotenv-provided values.
40
+ let _originalIngressEnvCaptured = false;
41
+ let _originalIngressEnv: string | undefined;
42
+ function getOriginalIngressEnv(): string | undefined {
43
+ if (!_originalIngressEnvCaptured) {
44
+ _originalIngressEnv = process.env.INGRESS_PUBLIC_BASE_URL;
45
+ _originalIngressEnvCaptured = true;
46
+ }
47
+ return _originalIngressEnv;
48
+ }
49
+
50
+ const TELEGRAM_BOT_TOKEN_IN_URL_PATTERN = /\/bot\d{8,10}:[A-Za-z0-9_-]{30,120}\//g;
51
+ const TELEGRAM_BOT_TOKEN_PATTERN = /(?<![A-Za-z0-9_-])\d{8,10}:[A-Za-z0-9_-]{30,120}(?![A-Za-z0-9_-])/g;
52
+
53
+ function redactTelegramBotTokens(value: string): string {
54
+ return value
55
+ .replace(TELEGRAM_BOT_TOKEN_IN_URL_PATTERN, '/bot[REDACTED]/')
56
+ .replace(TELEGRAM_BOT_TOKEN_PATTERN, '[REDACTED]');
57
+ }
58
+
59
+ function summarizeTelegramError(err: unknown): string {
60
+ const parts: string[] = [];
61
+ if (err instanceof Error) {
62
+ parts.push(err.message);
63
+ } else {
64
+ parts.push(String(err));
65
+ }
66
+ const path = (err as { path?: unknown })?.path;
67
+ if (typeof path === 'string' && path.length > 0) {
68
+ parts.push(`path=${path}`);
69
+ }
70
+ const code = (err as { code?: unknown })?.code;
71
+ if (typeof code === 'string' && code.length > 0) {
72
+ parts.push(`code=${code}`);
73
+ }
74
+ return redactTelegramBotTokens(parts.join(' '));
75
+ }
32
76
 
33
77
  export function handleModelGet(socket: net.Socket, ctx: HandlerContext): void {
34
78
  const config = getConfig();
@@ -101,10 +145,7 @@ export function handleModelSet(
101
145
  ctx.setSuppressConfigReload(wasSuppressed);
102
146
  throw err;
103
147
  }
104
- const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
105
- if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
106
- const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
107
- ctx.debounceTimers.set('__suppress_reset__', resetTimer);
148
+ ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
108
149
 
109
150
  // Re-initialize provider with the new model so LLM calls use it
110
151
  const config = getConfig();
@@ -151,10 +192,7 @@ export function handleImageGenModelSet(
151
192
  ctx.setSuppressConfigReload(wasSuppressed);
152
193
  throw err;
153
194
  }
154
- const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
155
- if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
156
- const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
157
- ctx.debounceTimers.set('__suppress_reset__', resetTimer);
195
+ ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
158
196
 
159
197
  ctx.updateConfigFingerprint();
160
198
  log.info({ model: msg.model }, 'Image generation model updated');
@@ -170,7 +208,22 @@ export function handleAddTrustRule(
170
208
  _ctx: HandlerContext,
171
209
  ): void {
172
210
  try {
173
- addRule(msg.toolName, msg.pattern, msg.scope, msg.decision);
211
+ const hasMetadata = msg.allowHighRisk != null
212
+ || msg.executionTarget != null;
213
+
214
+ addRule(
215
+ msg.toolName,
216
+ msg.pattern,
217
+ msg.scope,
218
+ msg.decision,
219
+ undefined, // priority — use default
220
+ hasMetadata
221
+ ? {
222
+ allowHighRisk: msg.allowHighRisk,
223
+ executionTarget: msg.executionTarget,
224
+ }
225
+ : undefined,
226
+ );
174
227
  log.info({ toolName: msg.toolName, pattern: msg.pattern, scope: msg.scope, decision: msg.decision }, 'Trust rule added via client');
175
228
  } catch (err) {
176
229
  log.error({ err, toolName: msg.toolName, pattern: msg.pattern, scope: msg.scope }, 'Failed to add trust rule via client');
@@ -403,27 +456,70 @@ export function handleSlackWebhookConfig(
403
456
  }
404
457
  }
405
458
 
406
- function computeLocalGatewayTarget(): string {
459
+ function computeGatewayTarget(): string {
460
+ if (process.env.GATEWAY_INTERNAL_BASE_URL) {
461
+ return process.env.GATEWAY_INTERNAL_BASE_URL.replace(/\/+$/, '');
462
+ }
407
463
  const portRaw = process.env.GATEWAY_PORT || '7830';
408
464
  const port = Number(portRaw) || 7830;
409
465
  return `http://127.0.0.1:${port}`;
410
466
  }
411
467
 
468
+ /**
469
+ * Best-effort call to the gateway's internal reconcile endpoint so that
470
+ * Telegram webhook registration is updated immediately when the ingress
471
+ * URL changes, without requiring a gateway restart.
472
+ */
473
+ function triggerGatewayReconcile(ingressPublicBaseUrl: string | undefined): void {
474
+ const gatewayBase = computeGatewayTarget();
475
+ const token = readHttpToken();
476
+ if (!token) {
477
+ log.debug('Skipping gateway reconcile trigger: no HTTP bearer token available');
478
+ return;
479
+ }
480
+
481
+ const url = `${gatewayBase}/internal/telegram/reconcile`;
482
+ const body = JSON.stringify({ ingressPublicBaseUrl: ingressPublicBaseUrl ?? '' });
483
+
484
+ fetch(url, {
485
+ method: 'POST',
486
+ headers: {
487
+ 'Content-Type': 'application/json',
488
+ 'Authorization': `Bearer ${token}`,
489
+ },
490
+ body,
491
+ signal: AbortSignal.timeout(5_000),
492
+ }).then((res) => {
493
+ if (res.ok) {
494
+ log.info('Gateway Telegram webhook reconcile triggered successfully');
495
+ } else {
496
+ log.warn({ status: res.status }, 'Gateway Telegram webhook reconcile returned non-OK status');
497
+ }
498
+ }).catch((err) => {
499
+ log.debug({ err }, 'Gateway Telegram webhook reconcile failed (gateway may not be running)');
500
+ });
501
+ }
502
+
412
503
  export function handleIngressConfig(
413
504
  msg: IngressConfigRequest,
414
505
  socket: net.Socket,
415
506
  ctx: HandlerContext,
416
507
  ): void {
417
- const localGatewayTarget = computeLocalGatewayTarget();
508
+ const localGatewayTarget = computeGatewayTarget();
418
509
  try {
419
510
  if (msg.action === 'get') {
420
511
  const raw = loadRawConfig();
421
512
  const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
422
513
  const publicBaseUrl = (ingress.publicBaseUrl as string) ?? '';
423
- const enabled = (ingress.enabled as boolean) ?? false;
514
+ // Backward compatibility: if `enabled` was never explicitly set,
515
+ // infer from whether a publicBaseUrl is configured so existing users
516
+ // who predate the toggle aren't silently disabled.
517
+ const enabled = (ingress.enabled as boolean | undefined) ?? (publicBaseUrl ? true : false);
424
518
  ctx.send(socket, { type: 'ingress_config_response', enabled, publicBaseUrl, localGatewayTarget, success: true });
425
519
  } else if (msg.action === 'set') {
426
520
  const value = (msg.publicBaseUrl ?? '').trim().replace(/\/+$/, '');
521
+ // Ensure we capture the original env value before any mutation below
522
+ getOriginalIngressEnv();
427
523
  const raw = loadRawConfig();
428
524
 
429
525
  // Update ingress.publicBaseUrl — this is the single source of truth for
@@ -445,10 +541,7 @@ export function handleIngressConfig(
445
541
  ctx.setSuppressConfigReload(wasSuppressed);
446
542
  throw err;
447
543
  }
448
- const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
449
- if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
450
- const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
451
- ctx.debounceTimers.set('__suppress_reset__', resetTimer);
544
+ ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
452
545
 
453
546
  // Propagate to the gateway's process environment so it picks up the
454
547
  // new URL when it is restarted. For the local-deployment path the
@@ -456,16 +549,32 @@ export function handleIngressConfig(
456
549
  // so updating process.env here ensures the value is visible when the
457
550
  // gateway is restarted (e.g. by the self-upgrade skill or a manual
458
551
  // `pkill -f gateway`).
459
- if (value) {
552
+ // Only export the URL when ingress is enabled; clearing it when
553
+ // disabled ensures the gateway stops accepting inbound webhooks.
554
+ const isEnabled = (ingress.enabled as boolean | undefined) ?? (value ? true : false);
555
+ if (value && isEnabled) {
460
556
  process.env.INGRESS_PUBLIC_BASE_URL = value;
461
- } else if (ORIGINAL_INGRESS_ENV !== undefined) {
462
- process.env.INGRESS_PUBLIC_BASE_URL = ORIGINAL_INGRESS_ENV;
557
+ } else if (isEnabled && getOriginalIngressEnv() !== undefined) {
558
+ // Ingress is enabled but the user cleared the URL — fall back to the
559
+ // env var that was present when the process started.
560
+ process.env.INGRESS_PUBLIC_BASE_URL = getOriginalIngressEnv()!;
463
561
  } else {
562
+ // Ingress is disabled or no URL is configured and no startup env var
563
+ // exists — remove the env var so the gateway stops accepting webhooks.
464
564
  delete process.env.INGRESS_PUBLIC_BASE_URL;
465
565
  }
466
566
 
467
- const enabled = (ingress.enabled as boolean) ?? false;
468
- ctx.send(socket, { type: 'ingress_config_response', enabled, publicBaseUrl: value, localGatewayTarget, success: true });
567
+ ctx.send(socket, { type: 'ingress_config_response', enabled: isEnabled, publicBaseUrl: value, localGatewayTarget, success: true });
568
+
569
+ // Trigger immediate Telegram webhook reconcile on the gateway so
570
+ // that changing the ingress URL takes effect without a restart.
571
+ // Called unconditionally so the gateway clears its in-memory URL
572
+ // when ingress is disabled, preventing stale re-registration on
573
+ // credential rotation.
574
+ // Use the effective URL from process.env (which accounts for the
575
+ // fallback branch above) rather than the raw `value` from the UI.
576
+ const effectiveUrl = isEnabled ? process.env.INGRESS_PUBLIC_BASE_URL : undefined;
577
+ triggerGatewayReconcile(effectiveUrl);
469
578
  } else {
470
579
  ctx.send(socket, { type: 'ingress_config_response', enabled: false, publicBaseUrl: '', localGatewayTarget, success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
471
580
  }
@@ -546,6 +655,8 @@ export function handleTwitterIntegrationConfig(
546
655
  if (msg.action === 'get') {
547
656
  const raw = loadRawConfig();
548
657
  const mode = (raw.twitterIntegrationMode as 'local_byo' | 'managed' | undefined) ?? 'local_byo';
658
+ const strategy = (raw.twitterOperationStrategy as 'oauth' | 'browser' | 'auto' | undefined) ?? 'auto';
659
+ const strategyConfigured = Object.prototype.hasOwnProperty.call(raw, 'twitterOperationStrategy');
549
660
  const localClientConfigured = !!getSecureKey('credential:integration:twitter:oauth_client_id');
550
661
  const connected = !!getSecureKey('credential:integration:twitter:access_token');
551
662
  const meta = getCredentialMetadata('integration:twitter', 'access_token');
@@ -557,6 +668,47 @@ export function handleTwitterIntegrationConfig(
557
668
  localClientConfigured,
558
669
  connected,
559
670
  accountInfo: meta?.accountInfo ?? undefined,
671
+ strategy,
672
+ strategyConfigured,
673
+ });
674
+ } else if (msg.action === 'get_strategy') {
675
+ const raw = loadRawConfig();
676
+ const strategy = (raw.twitterOperationStrategy as 'oauth' | 'browser' | 'auto' | undefined) ?? 'auto';
677
+ const strategyConfigured = Object.prototype.hasOwnProperty.call(raw, 'twitterOperationStrategy');
678
+ ctx.send(socket, {
679
+ type: 'twitter_integration_config_response',
680
+ success: true,
681
+ managedAvailable: false,
682
+ localClientConfigured: !!getSecureKey('credential:integration:twitter:oauth_client_id'),
683
+ connected: !!getSecureKey('credential:integration:twitter:access_token'),
684
+ strategy,
685
+ strategyConfigured,
686
+ });
687
+ } else if (msg.action === 'set_strategy') {
688
+ const valid = ['oauth', 'browser', 'auto'];
689
+ const value = msg.strategy;
690
+ if (!value || !valid.includes(value)) {
691
+ ctx.send(socket, {
692
+ type: 'twitter_integration_config_response',
693
+ success: false,
694
+ managedAvailable: false,
695
+ localClientConfigured: false,
696
+ connected: false,
697
+ error: `Invalid strategy value: ${String(value)}. Must be one of: ${valid.join(', ')}`,
698
+ });
699
+ return;
700
+ }
701
+ const raw = loadRawConfig();
702
+ raw.twitterOperationStrategy = value;
703
+ saveRawConfig(raw);
704
+ ctx.send(socket, {
705
+ type: 'twitter_integration_config_response',
706
+ success: true,
707
+ managedAvailable: false,
708
+ localClientConfigured: !!getSecureKey('credential:integration:twitter:oauth_client_id'),
709
+ connected: !!getSecureKey('credential:integration:twitter:access_token'),
710
+ strategy: value as 'oauth' | 'browser' | 'auto',
711
+ strategyConfigured: true,
560
712
  });
561
713
  } else if (msg.action === 'set_mode') {
562
714
  const raw = loadRawConfig();
@@ -676,6 +828,267 @@ export function handleTwitterIntegrationConfig(
676
828
  }
677
829
  }
678
830
 
831
+ export async function handleTelegramConfig(
832
+ msg: TelegramConfigRequest,
833
+ socket: net.Socket,
834
+ ctx: HandlerContext,
835
+ ): Promise<void> {
836
+ try {
837
+ if (msg.action === 'get') {
838
+ const hasBotToken = !!getSecureKey('credential:telegram:bot_token');
839
+ const hasWebhookSecret = !!getSecureKey('credential:telegram:webhook_secret');
840
+ const meta = getCredentialMetadata('telegram', 'bot_token');
841
+ const botUsername = meta?.accountInfo ?? undefined;
842
+ ctx.send(socket, {
843
+ type: 'telegram_config_response',
844
+ success: true,
845
+ hasBotToken,
846
+ botUsername,
847
+ connected: hasBotToken && hasWebhookSecret,
848
+ hasWebhookSecret,
849
+ });
850
+ } else if (msg.action === 'set') {
851
+ // Resolve token: prefer explicit msg.botToken, fall back to secure storage.
852
+ // Track provenance so we only rollback tokens that were freshly provided.
853
+ const isNewToken = !!msg.botToken;
854
+ const botToken = msg.botToken || getSecureKey('credential:telegram:bot_token');
855
+ if (!botToken) {
856
+ ctx.send(socket, {
857
+ type: 'telegram_config_response',
858
+ success: false,
859
+ hasBotToken: false,
860
+ connected: false,
861
+ hasWebhookSecret: false,
862
+ error: 'botToken is required for set action',
863
+ });
864
+ return;
865
+ }
866
+
867
+ // Validate token via Telegram getMe API
868
+ let botUsername: string;
869
+ try {
870
+ const res = await fetch(`https://api.telegram.org/bot${botToken}/getMe`);
871
+ if (!res.ok) {
872
+ const body = await res.text();
873
+ ctx.send(socket, {
874
+ type: 'telegram_config_response',
875
+ success: false,
876
+ hasBotToken: false,
877
+ connected: false,
878
+ hasWebhookSecret: false,
879
+ error: `Telegram API validation failed: ${body}`,
880
+ });
881
+ return;
882
+ }
883
+ const data = await res.json() as { ok: boolean; result?: { username?: string } };
884
+ if (!data.ok || !data.result?.username) {
885
+ ctx.send(socket, {
886
+ type: 'telegram_config_response',
887
+ success: false,
888
+ hasBotToken: false,
889
+ connected: false,
890
+ hasWebhookSecret: false,
891
+ error: 'Telegram API returned unexpected response',
892
+ });
893
+ return;
894
+ }
895
+ botUsername = data.result.username;
896
+ } catch (err) {
897
+ const message = summarizeTelegramError(err);
898
+ ctx.send(socket, {
899
+ type: 'telegram_config_response',
900
+ success: false,
901
+ hasBotToken: false,
902
+ connected: false,
903
+ hasWebhookSecret: false,
904
+ error: `Failed to validate bot token: ${message}`,
905
+ });
906
+ return;
907
+ }
908
+
909
+ // Store bot token securely
910
+ const stored = setSecureKey('credential:telegram:bot_token', botToken);
911
+ if (!stored) {
912
+ ctx.send(socket, {
913
+ type: 'telegram_config_response',
914
+ success: false,
915
+ hasBotToken: false,
916
+ connected: false,
917
+ hasWebhookSecret: false,
918
+ error: 'Failed to store bot token in secure storage',
919
+ });
920
+ return;
921
+ }
922
+
923
+ // Store metadata with bot username
924
+ upsertCredentialMetadata('telegram', 'bot_token', {
925
+ accountInfo: botUsername,
926
+ });
927
+
928
+ // Ensure webhook secret exists (generate if missing)
929
+ let hasWebhookSecret = !!getSecureKey('credential:telegram:webhook_secret');
930
+ if (!hasWebhookSecret) {
931
+ const { randomUUID } = await import('node:crypto');
932
+ const webhookSecret = randomUUID();
933
+ const secretStored = setSecureKey('credential:telegram:webhook_secret', webhookSecret);
934
+ if (secretStored) {
935
+ upsertCredentialMetadata('telegram', 'webhook_secret', {});
936
+ hasWebhookSecret = true;
937
+ } else {
938
+ // Only roll back the bot token if it was freshly provided.
939
+ // When the token came from secure storage it was already valid
940
+ // configuration; deleting it would destroy working state.
941
+ if (isNewToken) {
942
+ deleteSecureKey('credential:telegram:bot_token');
943
+ deleteCredentialMetadata('telegram', 'bot_token');
944
+ }
945
+ ctx.send(socket, {
946
+ type: 'telegram_config_response',
947
+ success: false,
948
+ hasBotToken: !isNewToken,
949
+ connected: false,
950
+ hasWebhookSecret: false,
951
+ error: 'Failed to store webhook secret',
952
+ });
953
+ return;
954
+ }
955
+ } else {
956
+ // Self-heal: ensure metadata exists even when the secret was
957
+ // already present (covers previously lost/corrupted metadata).
958
+ upsertCredentialMetadata('telegram', 'webhook_secret', {});
959
+ }
960
+
961
+ ctx.send(socket, {
962
+ type: 'telegram_config_response',
963
+ success: true,
964
+ hasBotToken: true,
965
+ botUsername,
966
+ connected: true,
967
+ hasWebhookSecret,
968
+ });
969
+
970
+ // Trigger gateway reconcile so the webhook registration updates immediately
971
+ const effectiveUrl = process.env.INGRESS_PUBLIC_BASE_URL;
972
+ if (effectiveUrl) {
973
+ triggerGatewayReconcile(effectiveUrl);
974
+ }
975
+ } else if (msg.action === 'clear') {
976
+ // Deregister the Telegram webhook before deleting credentials.
977
+ // The gateway reconcile short-circuits when credentials are absent,
978
+ // so we must call the Telegram API directly while the token is still
979
+ // available.
980
+ const botToken = getSecureKey('credential:telegram:bot_token');
981
+ if (botToken) {
982
+ try {
983
+ await fetch(`https://api.telegram.org/bot${botToken}/deleteWebhook`);
984
+ } catch (err) {
985
+ log.warn(
986
+ { error: summarizeTelegramError(err) },
987
+ 'Failed to deregister Telegram webhook (proceeding with credential cleanup)',
988
+ );
989
+ }
990
+ }
991
+
992
+ deleteSecureKey('credential:telegram:bot_token');
993
+ deleteCredentialMetadata('telegram', 'bot_token');
994
+ deleteSecureKey('credential:telegram:webhook_secret');
995
+ deleteCredentialMetadata('telegram', 'webhook_secret');
996
+
997
+ ctx.send(socket, {
998
+ type: 'telegram_config_response',
999
+ success: true,
1000
+ hasBotToken: false,
1001
+ connected: false,
1002
+ hasWebhookSecret: false,
1003
+ });
1004
+
1005
+ // Trigger reconcile to deregister webhook
1006
+ const effectiveUrl = process.env.INGRESS_PUBLIC_BASE_URL;
1007
+ if (effectiveUrl) {
1008
+ triggerGatewayReconcile(effectiveUrl);
1009
+ }
1010
+ } else if (msg.action === 'set_commands') {
1011
+ const storedToken = getSecureKey('credential:telegram:bot_token');
1012
+ if (!storedToken) {
1013
+ ctx.send(socket, {
1014
+ type: 'telegram_config_response',
1015
+ success: false,
1016
+ hasBotToken: false,
1017
+ connected: false,
1018
+ hasWebhookSecret: false,
1019
+ error: 'Bot token not configured. Run set action first.',
1020
+ });
1021
+ return;
1022
+ }
1023
+
1024
+ const commands = msg.commands ?? [
1025
+ { command: 'new', description: 'Start a new conversation' },
1026
+ ];
1027
+
1028
+ try {
1029
+ const res = await fetch(`https://api.telegram.org/bot${storedToken}/setMyCommands`, {
1030
+ method: 'POST',
1031
+ headers: { 'Content-Type': 'application/json' },
1032
+ body: JSON.stringify({ commands }),
1033
+ });
1034
+ if (!res.ok) {
1035
+ const body = await res.text();
1036
+ ctx.send(socket, {
1037
+ type: 'telegram_config_response',
1038
+ success: false,
1039
+ hasBotToken: true,
1040
+ connected: !!getSecureKey('credential:telegram:webhook_secret'),
1041
+ hasWebhookSecret: !!getSecureKey('credential:telegram:webhook_secret'),
1042
+ error: `Failed to set bot commands: ${body}`,
1043
+ });
1044
+ return;
1045
+ }
1046
+ } catch (err) {
1047
+ const message = summarizeTelegramError(err);
1048
+ ctx.send(socket, {
1049
+ type: 'telegram_config_response',
1050
+ success: false,
1051
+ hasBotToken: true,
1052
+ connected: !!getSecureKey('credential:telegram:webhook_secret'),
1053
+ hasWebhookSecret: !!getSecureKey('credential:telegram:webhook_secret'),
1054
+ error: `Failed to set bot commands: ${message}`,
1055
+ });
1056
+ return;
1057
+ }
1058
+
1059
+ const hasBotToken = !!getSecureKey('credential:telegram:bot_token');
1060
+ const hasWebhookSecret = !!getSecureKey('credential:telegram:webhook_secret');
1061
+ ctx.send(socket, {
1062
+ type: 'telegram_config_response',
1063
+ success: true,
1064
+ hasBotToken,
1065
+ connected: hasBotToken && hasWebhookSecret,
1066
+ hasWebhookSecret,
1067
+ });
1068
+ } else {
1069
+ ctx.send(socket, {
1070
+ type: 'telegram_config_response',
1071
+ success: false,
1072
+ hasBotToken: false,
1073
+ connected: false,
1074
+ hasWebhookSecret: false,
1075
+ error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}`,
1076
+ });
1077
+ }
1078
+ } catch (err) {
1079
+ const message = err instanceof Error ? err.message : String(err);
1080
+ log.error({ err }, 'Failed to handle Telegram config');
1081
+ ctx.send(socket, {
1082
+ type: 'telegram_config_response',
1083
+ success: false,
1084
+ hasBotToken: false,
1085
+ connected: false,
1086
+ hasWebhookSecret: false,
1087
+ error: message,
1088
+ });
1089
+ }
1090
+ }
1091
+
679
1092
  export function handleEnvVarsRequest(socket: net.Socket, ctx: HandlerContext): void {
680
1093
  const vars: Record<string, string> = {};
681
1094
  for (const [key, value] of Object.entries(process.env)) {
@@ -684,6 +1097,109 @@ export function handleEnvVarsRequest(socket: net.Socket, ctx: HandlerContext): v
684
1097
  ctx.send(socket, { type: 'env_vars_response', vars });
685
1098
  }
686
1099
 
1100
+ export async function handleToolPermissionSimulate(
1101
+ msg: ToolPermissionSimulateRequest,
1102
+ socket: net.Socket,
1103
+ ctx: HandlerContext,
1104
+ ): Promise<void> {
1105
+ try {
1106
+ if (!msg.toolName || typeof msg.toolName !== 'string') {
1107
+ ctx.send(socket, {
1108
+ type: 'tool_permission_simulate_response',
1109
+ success: false,
1110
+ error: 'toolName is required',
1111
+ });
1112
+ return;
1113
+ }
1114
+ if (!msg.input || typeof msg.input !== 'object') {
1115
+ ctx.send(socket, {
1116
+ type: 'tool_permission_simulate_response',
1117
+ success: false,
1118
+ error: 'input is required and must be an object',
1119
+ });
1120
+ return;
1121
+ }
1122
+
1123
+ const workingDir = msg.workingDir ?? process.cwd();
1124
+
1125
+ // Only infer execution target when the tool is actually registered;
1126
+ // for unresolved tools, leave it undefined so trust rules are unscoped.
1127
+ const isRegistered = getTool(msg.toolName) !== undefined;
1128
+ const executionTarget = isRegistered ? resolveExecutionTarget(msg.toolName) : undefined;
1129
+ const policyContext = executionTarget ? { executionTarget } : undefined;
1130
+
1131
+ const riskLevel = await classifyRisk(msg.toolName, msg.input, workingDir);
1132
+ const result = await check(msg.toolName, msg.input, workingDir, policyContext);
1133
+
1134
+ // Private-thread override: promote allow → prompt for side-effect tools
1135
+ if (
1136
+ msg.forcePromptSideEffects
1137
+ && result.decision === 'allow'
1138
+ && isSideEffectTool(msg.toolName, msg.input)
1139
+ ) {
1140
+ result.decision = 'prompt';
1141
+ result.reason = 'Private thread: side-effect tools require explicit approval';
1142
+ }
1143
+
1144
+ // Non-interactive override: convert prompt → deny
1145
+ if (msg.isInteractive === false && result.decision === 'prompt') {
1146
+ result.decision = 'deny';
1147
+ result.reason = 'Non-interactive session: no client to approve prompt';
1148
+ }
1149
+
1150
+ // When decision is prompt, generate the full payload the UI needs
1151
+ let promptPayload: {
1152
+ allowlistOptions: Array<{ label: string; description: string; pattern: string }>;
1153
+ scopeOptions: Array<{ label: string; scope: string }>;
1154
+ persistentDecisionsAllowed: boolean;
1155
+ } | undefined;
1156
+
1157
+ if (result.decision === 'prompt') {
1158
+ const allowlistOptions = await generateAllowlistOptions(msg.toolName, msg.input);
1159
+ const scopeOptions = generateScopeOptions(workingDir, msg.toolName);
1160
+ const persistentDecisionsAllowed = !(
1161
+ msg.toolName === 'bash'
1162
+ && msg.input.network_mode === 'proxied'
1163
+ );
1164
+ promptPayload = { allowlistOptions, scopeOptions, persistentDecisionsAllowed };
1165
+ }
1166
+
1167
+ ctx.send(socket, {
1168
+ type: 'tool_permission_simulate_response',
1169
+ success: true,
1170
+ decision: result.decision,
1171
+ riskLevel,
1172
+ reason: result.reason,
1173
+ executionTarget,
1174
+ matchedRuleId: result.matchedRule?.id,
1175
+ promptPayload,
1176
+ });
1177
+ } catch (err) {
1178
+ const message = err instanceof Error ? err.message : String(err);
1179
+ log.error({ err }, 'Failed to simulate tool permission');
1180
+ ctx.send(socket, {
1181
+ type: 'tool_permission_simulate_response',
1182
+ success: false,
1183
+ error: message,
1184
+ });
1185
+ }
1186
+ }
1187
+
1188
+ export function handleToolNamesList(socket: net.Socket, ctx: HandlerContext): void {
1189
+ const tools = getAllTools();
1190
+ const names = tools.map((t) => t.name).sort((a, b) => a.localeCompare(b));
1191
+ const schemas: Record<string, import('../ipc-contract.js').ToolInputSchema> = {};
1192
+ for (const tool of tools) {
1193
+ try {
1194
+ const def = tool.getDefinition();
1195
+ schemas[tool.name] = def.input_schema as import('../ipc-contract.js').ToolInputSchema;
1196
+ } catch {
1197
+ // Skip tools whose definitions can't be resolved
1198
+ }
1199
+ }
1200
+ ctx.send(socket, { type: 'tool_names_list_response', names, schemas });
1201
+ }
1202
+
687
1203
  export const configHandlers = defineHandlers({
688
1204
  model_get: (_msg, socket, ctx) => handleModelGet(socket, ctx),
689
1205
  model_set: handleModelSet,
@@ -703,5 +1219,8 @@ export const configHandlers = defineHandlers({
703
1219
  ingress_config: handleIngressConfig,
704
1220
  vercel_api_config: handleVercelApiConfig,
705
1221
  twitter_integration_config: handleTwitterIntegrationConfig,
1222
+ telegram_config: handleTelegramConfig,
706
1223
  env_vars_request: (_msg, socket, ctx) => handleEnvVarsRequest(socket, ctx),
1224
+ tool_permission_simulate: handleToolPermissionSimulate,
1225
+ tool_names_list: (_msg, socket, ctx) => handleToolNamesList(socket, ctx),
707
1226
  });