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.
- package/bun.lock +2 -2
- package/package.json +2 -2
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
- package/src/__tests__/ipc-snapshot.test.ts +11 -0
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
- package/src/__tests__/public-ingress-urls.test.ts +206 -0
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/calls/twilio-config.ts +17 -1
- package/src/calls/twilio-routes.ts +10 -2
- package/src/calls/twilio-webhook-urls.ts +18 -21
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +30 -2
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +51 -2
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +16 -1
- package/src/daemon/session-tool-setup.ts +7 -0
- package/src/inbound/public-ingress-urls.ts +106 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +346 -149
- package/src/memory/runs-store.ts +0 -3
- package/src/memory/schema.ts +0 -4
- package/src/runtime/http-server.ts +84 -2
- package/src/security/oauth-callback-registry.ts +56 -0
- package/src/security/oauth2.ts +174 -58
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/claude-code/claude-code.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/workspace/provider-commit-message-generator.ts +39 -23
- 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
|
-
|
|
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
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`;
|
package/src/config/defaults.ts
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -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
|
|
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:',
|
package/src/config/types.ts
CHANGED
|
@@ -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:
|
|
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({
|
|
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
|
|
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
|
|
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
|
|