vellum 0.2.7 → 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 (42) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +2 -2
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
  6. package/src/__tests__/ipc-snapshot.test.ts +11 -0
  7. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  8. package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
  9. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  10. package/src/__tests__/public-ingress-urls.test.ts +206 -0
  11. package/src/__tests__/tool-executor.test.ts +88 -0
  12. package/src/__tests__/turn-commit.test.ts +64 -0
  13. package/src/calls/twilio-config.ts +17 -1
  14. package/src/calls/twilio-routes.ts +10 -2
  15. package/src/calls/twilio-webhook-urls.ts +18 -21
  16. package/src/config/defaults.ts +4 -0
  17. package/src/config/schema.ts +30 -2
  18. package/src/config/system-prompt.ts +1 -1
  19. package/src/config/types.ts +1 -0
  20. package/src/daemon/computer-use-session.ts +2 -1
  21. package/src/daemon/handlers/config.ts +51 -2
  22. package/src/daemon/handlers/sessions.ts +2 -2
  23. package/src/daemon/handlers/work-items.ts +1 -1
  24. package/src/daemon/ipc-contract-inventory.json +4 -0
  25. package/src/daemon/ipc-contract.ts +16 -1
  26. package/src/daemon/session-tool-setup.ts +7 -0
  27. package/src/inbound/public-ingress-urls.ts +106 -0
  28. package/src/memory/attachments-store.ts +0 -1
  29. package/src/memory/channel-delivery-store.ts +0 -1
  30. package/src/memory/conversation-key-store.ts +0 -1
  31. package/src/memory/db.ts +346 -149
  32. package/src/memory/runs-store.ts +0 -3
  33. package/src/memory/schema.ts +0 -4
  34. package/src/runtime/http-server.ts +84 -2
  35. package/src/security/oauth-callback-registry.ts +56 -0
  36. package/src/security/oauth2.ts +174 -58
  37. package/src/swarm/backend-claude-code.ts +1 -1
  38. package/src/tools/assets/search.ts +1 -36
  39. package/src/tools/claude-code/claude-code.ts +3 -3
  40. package/src/tools/tasks/work-item-list.ts +16 -2
  41. package/src/workspace/provider-commit-message-generator.ts +39 -23
  42. package/src/workspace/turn-commit.ts +6 -2
@@ -1965,3 +1965,91 @@ describe('buildSanitizedEnv — baseline: credential exclusion', () => {
1965
1965
  }
1966
1966
  });
1967
1967
  });
1968
+
1969
+ // ---------------------------------------------------------------------------
1970
+ // Persistent-allow lifecycle: roundtrip and auto-allow on subsequent invocation
1971
+ // ---------------------------------------------------------------------------
1972
+
1973
+ describe('ToolExecutor persistent-allow lifecycle', () => {
1974
+ beforeEach(() => {
1975
+ fakeToolResult = { content: 'ok', isError: false };
1976
+ lastCheckArgs = undefined;
1977
+ getToolOverride = undefined;
1978
+ checkResultOverride = undefined;
1979
+ checkFnOverride = undefined;
1980
+ if (addRuleSpy) { addRuleSpy.mockRestore(); addRuleSpy = undefined; }
1981
+ });
1982
+
1983
+ function setupAddRuleSpy() {
1984
+ addRuleSpy = spyOn(trustStore, 'addRule').mockImplementation(
1985
+ (tool: string, pattern: string, scope: string, decision = 'allow', priority = 100, options?: any) => {
1986
+ return { id: 'spy-rule-id', tool, pattern, scope, decision, priority, createdAt: Date.now(), ...options } as any;
1987
+ },
1988
+ );
1989
+ return addRuleSpy;
1990
+ }
1991
+
1992
+ test('persistent-allow roundtrip: always_allow saves rule and allows tool', async () => {
1993
+ // Simulate check() returning 'prompt' so the executor asks the user
1994
+ checkResultOverride = { decision: 'prompt', reason: 'Medium risk: requires approval' };
1995
+ const spy = setupAddRuleSpy();
1996
+
1997
+ // User responds with always_allow, selecting a pattern and scope
1998
+ const prompter = makePrompterWithDecision('always_allow', 'git *', '/tmp/project');
1999
+ const executor = new ToolExecutor(prompter);
2000
+ const result = await executor.execute('bash', { command: 'git status' }, makeContext());
2001
+
2002
+ // The tool should have been allowed to proceed
2003
+ expect(result.isError).toBe(false);
2004
+ expect(result.content).toBe('ok');
2005
+
2006
+ // addRule should have been called with the correct arguments
2007
+ expect(spy).toHaveBeenCalledTimes(1);
2008
+ const [tool, pattern, scope, decision] = spy.mock.calls[0];
2009
+ expect(tool).toBe('bash');
2010
+ expect(pattern).toBe('git *');
2011
+ expect(scope).toBe('/tmp/project');
2012
+ expect(decision).toBe('allow');
2013
+ });
2014
+
2015
+ test('auto-allow on subsequent invocation: matching rule skips prompt', async () => {
2016
+ // Simulate a previously saved rule by making check() return 'allow'
2017
+ // with a matched rule (as findHighestPriorityRule would).
2018
+ checkResultOverride = { decision: 'allow', reason: 'Matched trust rule: git *' };
2019
+
2020
+ let promptCalled = false;
2021
+ const trackingPrompter = {
2022
+ prompt: async () => { promptCalled = true; return { decision: 'allow' as const }; },
2023
+ resolveConfirmation: () => {},
2024
+ updateSender: () => {},
2025
+ dispose: () => {},
2026
+ } as unknown as PermissionPrompter;
2027
+
2028
+ const executor = new ToolExecutor(trackingPrompter);
2029
+ const result = await executor.execute('bash', { command: 'git status' }, makeContext());
2030
+
2031
+ // The tool should be auto-allowed
2032
+ expect(result.isError).toBe(false);
2033
+ expect(result.content).toBe('ok');
2034
+
2035
+ // The prompter should NOT have been called — the rule auto-allowed
2036
+ expect(promptCalled).toBe(false);
2037
+ });
2038
+
2039
+ test('always_allow with everywhere scope saves rule and allows tool', async () => {
2040
+ checkResultOverride = { decision: 'prompt', reason: 'Medium risk: requires approval' };
2041
+ const spy = setupAddRuleSpy();
2042
+
2043
+ const prompter = makePrompterWithDecision('always_allow', 'file_write:*', 'everywhere');
2044
+ const executor = new ToolExecutor(prompter);
2045
+ const result = await executor.execute('file_write', { path: '/tmp/test.txt', content: 'hello' }, makeContext());
2046
+
2047
+ expect(result.isError).toBe(false);
2048
+ expect(spy).toHaveBeenCalledTimes(1);
2049
+ const [tool, pattern, scope, decision] = spy.mock.calls[0];
2050
+ expect(tool).toBe('file_write');
2051
+ expect(pattern).toBe('file_write:*');
2052
+ expect(scope).toBe('everywhere');
2053
+ expect(decision).toBe('allow');
2054
+ });
2055
+ });
@@ -487,4 +487,68 @@ describe('LLM commit message integration', () => {
487
487
 
488
488
  expect(fullMessage).toContain('Turn:');
489
489
  });
490
+
491
+ test('changed files from preStatus are passed to generator', async () => {
492
+ let capturedContext: { changedFiles: string[] } | undefined;
493
+ let capturedOptions: { changedFiles: string[] } | undefined;
494
+
495
+ const llmResult: GenerateCommitMessageResult = {
496
+ message: 'feat: captured context test',
497
+ source: 'llm',
498
+ };
499
+
500
+ mock.module('../workspace/provider-commit-message-generator.js', () => ({
501
+ getCommitMessageGenerator: () => ({
502
+ generateCommitMessage: async (ctx: { changedFiles: string[] }, opts: { changedFiles: string[] }) => {
503
+ capturedContext = ctx;
504
+ capturedOptions = opts;
505
+ return llmResult;
506
+ },
507
+ }),
508
+ }));
509
+
510
+ const { commitTurnChanges: commit } = await import('../workspace/turn-commit.js');
511
+
512
+ const service = new WorkspaceGitService(testDir);
513
+ await service.ensureInitialized();
514
+
515
+ writeFileSync(join(testDir, 'alpha.ts'), 'export const a = 1;');
516
+ writeFileSync(join(testDir, 'beta.ts'), 'export const b = 2;');
517
+
518
+ await commit(testDir, 'sess_files', 1);
519
+
520
+ // The generator should have received the actual file list, not empty arrays
521
+ expect(capturedContext).toBeDefined();
522
+ expect(capturedContext!.changedFiles.length).toBeGreaterThan(0);
523
+ expect(capturedContext!.changedFiles).toContain('alpha.ts');
524
+ expect(capturedContext!.changedFiles).toContain('beta.ts');
525
+
526
+ expect(capturedOptions).toBeDefined();
527
+ expect(capturedOptions!.changedFiles.length).toBeGreaterThan(0);
528
+ expect(capturedOptions!.changedFiles).toContain('alpha.ts');
529
+ expect(capturedOptions!.changedFiles).toContain('beta.ts');
530
+ });
531
+
532
+ test('clean workspace skips LLM generator call', async () => {
533
+ let generatorCalled = false;
534
+
535
+ mock.module('../workspace/provider-commit-message-generator.js', () => ({
536
+ getCommitMessageGenerator: () => ({
537
+ generateCommitMessage: async () => {
538
+ generatorCalled = true;
539
+ return { message: 'should not be called', source: 'llm' as const };
540
+ },
541
+ }),
542
+ }));
543
+
544
+ const { commitTurnChanges: commit } = await import('../workspace/turn-commit.js');
545
+
546
+ const service = new WorkspaceGitService(testDir);
547
+ await service.ensureInitialized();
548
+
549
+ // No file changes — workspace is clean
550
+ await commit(testDir, 'sess_clean', 1);
551
+
552
+ expect(generatorCalled).toBe(false);
553
+ });
490
554
  });
@@ -2,6 +2,7 @@ import { getSecureKey } from '../security/secure-keys.js';
2
2
  import { getLogger } from '../util/logger.js';
3
3
  import { loadConfig } from '../config/loader.js';
4
4
  import { getWebhookBaseUrl } from './twilio-webhook-urls.js';
5
+ import { getTwilioRelayUrl } from '../inbound/public-ingress-urls.js';
5
6
 
6
7
  const log = getLogger('twilio-config');
7
8
 
@@ -19,7 +20,22 @@ export function getTwilioConfig(): TwilioConfig {
19
20
  const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('credential:twilio:phone_number') || '';
20
21
  const config = loadConfig();
21
22
  const webhookBaseUrl = getWebhookBaseUrl(config);
22
- const wssBaseUrl = process.env.TWILIO_WSS_BASE_URL || '';
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
+ }
23
39
 
24
40
  if (!accountSid || !authToken) {
25
41
  throw new Error('Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.');
@@ -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);
@@ -1,31 +1,24 @@
1
- import { getLogger } from '../util/logger.js';
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
+ */
2
7
 
3
- const log = getLogger('twilio-webhook-urls');
8
+ import {
9
+ getPublicBaseUrl,
10
+ type IngressConfig,
11
+ } from '../inbound/public-ingress-urls.js';
4
12
 
5
13
  /**
6
14
  * Resolve the webhook base URL from config, falling back to the
7
15
  * TWILIO_WEBHOOK_BASE_URL environment variable with a deprecation warning.
8
16
  * Throws if neither source provides a value.
17
+ *
18
+ * @deprecated Use `getPublicBaseUrl` from `inbound/public-ingress-urls.ts` instead.
9
19
  */
10
- export function getWebhookBaseUrl(config: { calls: { webhookBaseUrl?: string } }): string {
11
- const configValue = config.calls.webhookBaseUrl;
12
- if (configValue) {
13
- const normalized = normalizeBaseUrl(configValue);
14
- if (normalized) return normalized;
15
- }
16
-
17
- const envValue = process.env.TWILIO_WEBHOOK_BASE_URL;
18
- if (envValue) {
19
- log.warn(
20
- 'TWILIO_WEBHOOK_BASE_URL env var is deprecated — set calls.webhookBaseUrl in config instead.',
21
- );
22
- const normalized = normalizeBaseUrl(envValue);
23
- if (normalized) return normalized;
24
- }
25
-
26
- throw new Error(
27
- 'No webhook base URL configured. Set calls.webhookBaseUrl in config or TWILIO_WEBHOOK_BASE_URL env var.',
28
- );
20
+ export function getWebhookBaseUrl(config: { calls: { webhookBaseUrl?: string }; ingress?: { publicBaseUrl?: string } }): string {
21
+ return getPublicBaseUrl(config as IngressConfig);
29
22
  }
30
23
 
31
24
  /**
@@ -37,6 +30,8 @@ export function normalizeBaseUrl(url: string): string {
37
30
 
38
31
  /**
39
32
  * Build the Twilio voice webhook URL for a given call session.
33
+ *
34
+ * @deprecated Use `getTwilioVoiceWebhookUrl` from `inbound/public-ingress-urls.ts` instead.
40
35
  */
41
36
  export function buildTwilioVoiceWebhookUrl(baseUrl: string, callSessionId: string): string {
42
37
  return `${normalizeBaseUrl(baseUrl)}/webhooks/twilio/voice?callSessionId=${callSessionId}`;
@@ -44,6 +39,8 @@ export function buildTwilioVoiceWebhookUrl(baseUrl: string, callSessionId: strin
44
39
 
45
40
  /**
46
41
  * Build the Twilio status callback URL.
42
+ *
43
+ * @deprecated Use `getTwilioStatusCallbackUrl` from `inbound/public-ingress-urls.ts` instead.
47
44
  */
48
45
  export function buildTwilioStatusCallbackUrl(baseUrl: string): string {
49
46
  return `${normalizeBaseUrl(baseUrl)}/webhooks/twilio/status`;
@@ -228,4 +228,8 @@ export const DEFAULT_CONFIG: AssistantConfig = {
228
228
  denyCategories: [],
229
229
  },
230
230
  },
231
+ ingress: {
232
+ publicBaseUrl: '',
233
+ mode: 'compat' as const,
234
+ },
231
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({
@@ -914,6 +926,17 @@ export const SkillsConfigSchema = z.object({
914
926
  allowBundled: z.array(z.string()).nullable().default(null),
915
927
  });
916
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
+
917
940
  export const AssistantConfigSchema = z.object({
918
941
  provider: z
919
942
  .enum(VALID_PROVIDERS, {
@@ -1163,6 +1186,10 @@ export const AssistantConfigSchema = z.object({
1163
1186
  denyCategories: [],
1164
1187
  },
1165
1188
  }),
1189
+ ingress: IngressConfigSchema.default({
1190
+ publicBaseUrl: '',
1191
+ mode: 'compat',
1192
+ }),
1166
1193
  }).superRefine((config, ctx) => {
1167
1194
  if (config.contextWindow.targetInputTokens >= config.contextWindow.maxInputTokens) {
1168
1195
  ctx.addIssue({
@@ -1223,3 +1250,4 @@ export type WorkspaceGitConfig = z.infer<typeof WorkspaceGitConfigSchema>;
1223
1250
  export type CallsConfig = z.infer<typeof CallsConfigSchema>;
1224
1251
  export type CallsDisclosureConfig = z.infer<typeof CallsDisclosureConfigSchema>;
1225
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';
@@ -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 {
@@ -20,6 +20,7 @@ import type {
20
20
  ShareToSlackRequest,
21
21
  SlackWebhookConfigRequest,
22
22
  TwilioWebhookConfigRequest,
23
+ IngressConfigRequest,
23
24
  VercelApiConfigRequest,
24
25
  TwitterIntegrationConfigRequest,
25
26
  } from '../ipc-protocol.js';
@@ -165,9 +166,9 @@ export function handleAddTrustRule(
165
166
  ): void {
166
167
  try {
167
168
  addRule(msg.toolName, msg.pattern, msg.scope, msg.decision);
168
- 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');
169
170
  } catch (err) {
170
- 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');
171
172
  }
172
173
  }
173
174
 
@@ -425,6 +426,8 @@ export function handleTwilioWebhookConfig(
425
426
  const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
426
427
  ctx.debounceTimers.set('__suppress_reset__', resetTimer);
427
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)}` });
428
431
  }
429
432
  } catch (err) {
430
433
  const message = err instanceof Error ? err.message : String(err);
@@ -432,6 +435,51 @@ export function handleTwilioWebhookConfig(
432
435
  }
433
436
  }
434
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
+
435
483
  export function handleVercelApiConfig(
436
484
  msg: VercelApiConfigRequest,
437
485
  socket: net.Socket,
@@ -658,6 +706,7 @@ export const configHandlers = defineHandlers({
658
706
  share_to_slack: handleShareToSlack,
659
707
  slack_webhook_config: handleSlackWebhookConfig,
660
708
  twilio_webhook_config: handleTwilioWebhookConfig,
709
+ ingress_config: handleIngressConfig,
661
710
  vercel_api_config: handleVercelApiConfig,
662
711
  twitter_integration_config: handleTwitterIntegrationConfig,
663
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",
@@ -142,6 +143,7 @@
142
143
  "GetSigningIdentityRequest",
143
144
  "HistoryResponse",
144
145
  "HomeBaseGetResponse",
146
+ "IngressConfigResponse",
145
147
  "IntegrationConnectResult",
146
148
  "IntegrationListResponse",
147
149
  "IpcBlobProbeResult",
@@ -255,6 +257,7 @@
255
257
  "history_request",
256
258
  "home_base_get",
257
259
  "image_gen_model_set",
260
+ "ingress_config",
258
261
  "integration_connect",
259
262
  "integration_disconnect",
260
263
  "integration_list",
@@ -365,6 +368,7 @@
365
368
  "get_signing_identity",
366
369
  "history_response",
367
370
  "home_base_get_response",
371
+ "ingress_config_response",
368
372
  "integration_connect_result",
369
373
  "integration_list_response",
370
374
  "ipc_blob_probe_result",
@@ -478,6 +478,12 @@ export interface TwilioWebhookConfigRequest {
478
478
  webhookBaseUrl?: string;
479
479
  }
480
480
 
481
+ export interface IngressConfigRequest {
482
+ type: 'ingress_config';
483
+ action: 'get' | 'set';
484
+ publicBaseUrl?: string;
485
+ }
486
+
481
487
  export interface VercelApiConfigRequest {
482
488
  type: 'vercel_api_config';
483
489
  action: 'get' | 'set' | 'delete';
@@ -942,6 +948,7 @@ export type ClientMessage =
942
948
  | ShareToSlackRequest
943
949
  | SlackWebhookConfigRequest
944
950
  | TwilioWebhookConfigRequest
951
+ | IngressConfigRequest
945
952
  | VercelApiConfigRequest
946
953
  | TwitterIntegrationConfigRequest
947
954
  | TwitterAuthStartRequest
@@ -1697,6 +1704,13 @@ export interface TwilioWebhookConfigResponse {
1697
1704
  error?: string;
1698
1705
  }
1699
1706
 
1707
+ export interface IngressConfigResponse {
1708
+ type: 'ingress_config_response';
1709
+ publicBaseUrl: string;
1710
+ success: boolean;
1711
+ error?: string;
1712
+ }
1713
+
1700
1714
  export interface OpenUrl {
1701
1715
  type: 'open_url';
1702
1716
  url: string;
@@ -2015,7 +2029,7 @@ export interface WorkItemDeleteResponse {
2015
2029
  success: boolean;
2016
2030
  }
2017
2031
 
2018
- export type WorkItemRunTaskErrorCode = 'not_found' | 'already_running' | 'invalid_status' | 'no_task';
2032
+ export type WorkItemRunTaskErrorCode = 'not_found' | 'already_running' | 'invalid_status' | 'no_task' | 'permission_required';
2019
2033
 
2020
2034
  export interface WorkItemRunTaskResponse {
2021
2035
  type: 'work_item_run_task_response';
@@ -2181,6 +2195,7 @@ export type ServerMessage =
2181
2195
  | ShareToSlackResponse
2182
2196
  | SlackWebhookConfigResponse
2183
2197
  | TwilioWebhookConfigResponse
2198
+ | IngressConfigResponse
2184
2199
  | VercelApiConfigResponse
2185
2200
  | TwitterIntegrationConfigResponse
2186
2201
  | TwitterAuthResult
@@ -14,6 +14,9 @@ import type { PermissionPrompter } from '../permissions/prompter.js';
14
14
  import type { SecretPrompter } from '../permissions/secret-prompter.js';
15
15
  import { addRule, findHighestPriorityRule } from '../permissions/trust-store.js';
16
16
  import { generateAllowlistOptions, generateScopeOptions, normalizeWebFetchUrl } from '../permissions/checker.js';
17
+ import { getLogger } from '../util/logger.js';
18
+
19
+ const log = getLogger('session-tool-setup');
17
20
  import { getAllToolDefinitions } from '../tools/registry.js';
18
21
  import { allUiSurfaceTools } from '../tools/ui-surface/definitions.js';
19
22
  import { coreAppProxyTools } from '../tools/apps/definitions.js';
@@ -248,10 +251,12 @@ export function createToolExecutor(
248
251
  req.principal,
249
252
  );
250
253
  if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
254
+ log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule');
251
255
  addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
252
256
  response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
253
257
  }
254
258
  if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
259
+ log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule');
255
260
  addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'deny');
256
261
  }
257
262
  return {
@@ -440,10 +445,12 @@ export function createProxyApprovalCallback(
440
445
 
441
446
  // Persist trust rule if the user chose "always allow" or "always deny"
442
447
  if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
448
+ log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule (proxy)');
443
449
  addRule(toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
444
450
  response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
445
451
  }
446
452
  if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
453
+ log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule (proxy)');
447
454
  addRule(toolName, response.selectedPattern, response.selectedScope, 'deny');
448
455
  }
449
456