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
package/bun.lock
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"@qdrant/js-client-rest": "^1.16.2",
|
|
13
13
|
"@sentry/node": "^10.38.0",
|
|
14
14
|
"@vellumai/cli": "0.1.8",
|
|
15
|
-
"@vellumai/vellum-gateway": "0.1.
|
|
15
|
+
"@vellumai/vellum-gateway": "0.1.10",
|
|
16
16
|
"agentmail": "^0.1.0",
|
|
17
17
|
"archiver": "^7.0.1",
|
|
18
18
|
"commander": "^13.1.0",
|
|
@@ -544,7 +544,7 @@
|
|
|
544
544
|
|
|
545
545
|
"@vellumai/cli": ["@vellumai/cli@0.1.8", "", { "dependencies": { "ink": "^6.7.0", "react": "^19.2.4" }, "bin": { "vellum-cli": "src/index.ts" } }, "sha512-36P6W1rV1dK9Qg0N3oFjffz1CSarkGBgqc2ZGEI1de9RPSkVWBI0QmKCZrPeg1/qDzEh3InUIsV1qjNIe7z1pg=="],
|
|
546
546
|
|
|
547
|
-
"@vellumai/vellum-gateway": ["@vellumai/vellum-gateway@0.1.
|
|
547
|
+
"@vellumai/vellum-gateway": ["@vellumai/vellum-gateway@0.1.10", "", { "dependencies": { "file-type": "^21.3.0", "pino": "^9.6.0", "pino-pretty": "^13.1.3", "zod": "^4.3.6" } }, "sha512-a41fGexW8RpWL4RTfZ3EM+XJMvz7t26D1axu2xAtZioXW3ZWMLGuogHnIJsgglzESl49E6VmmUsUGeD+dseV2w=="],
|
|
548
548
|
|
|
549
549
|
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
|
550
550
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vellum",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vellum": "./src/index.ts"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@qdrant/js-client-rest": "^1.16.2",
|
|
30
30
|
"@sentry/node": "^10.38.0",
|
|
31
31
|
"@vellumai/cli": "0.1.8",
|
|
32
|
-
"@vellumai/vellum-gateway": "0.1.
|
|
32
|
+
"@vellumai/vellum-gateway": "0.1.10",
|
|
33
33
|
"agentmail": "^0.1.0",
|
|
34
34
|
"archiver": "^7.0.1",
|
|
35
35
|
"commander": "^13.1.0",
|
|
@@ -249,8 +249,8 @@ describe('AssetMaterializeTool size limit', () => {
|
|
|
249
249
|
const db = getDb();
|
|
250
250
|
const fakeId = 'oversized-attachment';
|
|
251
251
|
db.run(
|
|
252
|
-
`INSERT INTO attachments (id,
|
|
253
|
-
VALUES ('${fakeId}', '
|
|
252
|
+
`INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
|
|
253
|
+
VALUES ('${fakeId}', 'huge.bin', 'application/octet-stream', ${51 * 1024 * 1024}, 'document', 'AAAA', ${Date.now()})`,
|
|
254
254
|
);
|
|
255
255
|
|
|
256
256
|
const result = await assetMaterializeTool.execute(
|
|
@@ -3854,3 +3854,107 @@ describe('computer-use tool permission defaults', () => {
|
|
|
3854
3854
|
expect(risk).toBe(RiskLevel.Low);
|
|
3855
3855
|
});
|
|
3856
3856
|
});
|
|
3857
|
+
|
|
3858
|
+
// ---------------------------------------------------------------------------
|
|
3859
|
+
// Scope-matching behavior: project-scoped vs everywhere rules
|
|
3860
|
+
// ---------------------------------------------------------------------------
|
|
3861
|
+
|
|
3862
|
+
describe('scope matching behavior', () => {
|
|
3863
|
+
beforeEach(() => {
|
|
3864
|
+
clearCache();
|
|
3865
|
+
testConfig.permissions = { mode: 'legacy' };
|
|
3866
|
+
try { rmSync(join(checkerTestDir, 'protected', 'trust.json')); } catch { /* may not exist */ }
|
|
3867
|
+
});
|
|
3868
|
+
|
|
3869
|
+
test('project-scoped rule matches tool invocations from within that directory', async () => {
|
|
3870
|
+
const projectDir = '/home/user/my-project';
|
|
3871
|
+
// Use the pattern format that file tools produce: "toolName:path/**"
|
|
3872
|
+
addRule('file_write', 'file_write:/home/user/my-project/**', projectDir);
|
|
3873
|
+
|
|
3874
|
+
// Invocation from within the project directory should match
|
|
3875
|
+
const result = await check('file_write', { path: '/home/user/my-project/src/index.ts' }, projectDir);
|
|
3876
|
+
expect(result.decision).toBe('allow');
|
|
3877
|
+
expect(result.matchedRule).toBeDefined();
|
|
3878
|
+
expect(result.matchedRule!.scope).toBe(projectDir);
|
|
3879
|
+
});
|
|
3880
|
+
|
|
3881
|
+
test('project-scoped rule matches tool invocations from subdirectory of project', async () => {
|
|
3882
|
+
const projectDir = '/home/user/my-project';
|
|
3883
|
+
addRule('file_write', 'file_write:/home/user/my-project/**', projectDir);
|
|
3884
|
+
|
|
3885
|
+
// Invocation from a subdirectory should also match (scope is a prefix match)
|
|
3886
|
+
const result = await check('file_write', { path: '/home/user/my-project/src/index.ts' }, '/home/user/my-project/src');
|
|
3887
|
+
expect(result.decision).toBe('allow');
|
|
3888
|
+
expect(result.matchedRule).toBeDefined();
|
|
3889
|
+
expect(result.matchedRule!.scope).toBe(projectDir);
|
|
3890
|
+
});
|
|
3891
|
+
|
|
3892
|
+
test('project-scoped rule does NOT match invocations from sibling directory', async () => {
|
|
3893
|
+
const projectDir = '/home/user/my-project';
|
|
3894
|
+
// Use a broad pattern that matches any file, scoped to the project
|
|
3895
|
+
addRule('file_write', 'file_write:*', projectDir);
|
|
3896
|
+
|
|
3897
|
+
// Invocation from a sibling directory should NOT match the project-scoped rule
|
|
3898
|
+
const result = await check('file_write', { path: '/home/user/other-project/file.ts' }, '/home/user/other-project');
|
|
3899
|
+
expect(result.decision).toBe('prompt');
|
|
3900
|
+
});
|
|
3901
|
+
|
|
3902
|
+
test('project-scoped rule does NOT match invocations from parent directory', async () => {
|
|
3903
|
+
const projectDir = '/home/user/my-project';
|
|
3904
|
+
addRule('file_write', 'file_write:*', projectDir);
|
|
3905
|
+
|
|
3906
|
+
// Invocation from a parent directory should NOT match
|
|
3907
|
+
const result = await check('file_write', { path: '/home/user/file.txt' }, '/home/user');
|
|
3908
|
+
expect(result.decision).toBe('prompt');
|
|
3909
|
+
});
|
|
3910
|
+
|
|
3911
|
+
test('project-scoped rule does NOT match directory with shared prefix', async () => {
|
|
3912
|
+
// A rule for /home/user/project should NOT match /home/user/project-evil
|
|
3913
|
+
// (directory-boundary enforcement in matchesScope)
|
|
3914
|
+
const projectDir = '/home/user/project';
|
|
3915
|
+
addRule('file_write', 'file_write:*', projectDir);
|
|
3916
|
+
|
|
3917
|
+
const result = await check('file_write', { path: '/home/user/project-evil/malicious.ts' }, '/home/user/project-evil');
|
|
3918
|
+
expect(result.decision).toBe('prompt');
|
|
3919
|
+
});
|
|
3920
|
+
|
|
3921
|
+
test('everywhere-scoped rule matches invocations from any directory', async () => {
|
|
3922
|
+
addRule('file_write', 'file_write:*', 'everywhere');
|
|
3923
|
+
|
|
3924
|
+
// Should match from various directories
|
|
3925
|
+
const r1 = await check('file_write', { path: 'file.ts' }, '/home/user/project-a');
|
|
3926
|
+
expect(r1.decision).toBe('allow');
|
|
3927
|
+
expect(r1.matchedRule).toBeDefined();
|
|
3928
|
+
expect(r1.matchedRule!.scope).toBe('everywhere');
|
|
3929
|
+
|
|
3930
|
+
const r2 = await check('file_write', { path: 'output.txt' }, '/var/tmp');
|
|
3931
|
+
expect(r2.decision).toBe('allow');
|
|
3932
|
+
expect(r2.matchedRule!.scope).toBe('everywhere');
|
|
3933
|
+
|
|
3934
|
+
const r3 = await check('file_write', { path: 'file.json' }, '/opt/data');
|
|
3935
|
+
expect(r3.decision).toBe('allow');
|
|
3936
|
+
expect(r3.matchedRule!.scope).toBe('everywhere');
|
|
3937
|
+
});
|
|
3938
|
+
|
|
3939
|
+
test('bash rule scoped to project matches commands within that project', async () => {
|
|
3940
|
+
const projectDir = '/home/user/my-project';
|
|
3941
|
+
addRule('bash', 'npm *', projectDir);
|
|
3942
|
+
|
|
3943
|
+
const result = await check('bash', { command: 'npm install' }, projectDir);
|
|
3944
|
+
expect(result.decision).toBe('allow');
|
|
3945
|
+
expect(result.matchedRule).toBeDefined();
|
|
3946
|
+
});
|
|
3947
|
+
|
|
3948
|
+
test('bash rule scoped to project does NOT match commands from different project', async () => {
|
|
3949
|
+
const projectDir = '/home/user/my-project';
|
|
3950
|
+
addRule('bash', 'npm *', projectDir);
|
|
3951
|
+
|
|
3952
|
+
const result = await check('bash', { command: 'npm install' }, '/home/user/other-project');
|
|
3953
|
+
// npm install is Low risk, so it falls through to auto-allow via the
|
|
3954
|
+
// default sandbox bash rule, not via the project-scoped rule.
|
|
3955
|
+
// The key assertion is that the project-scoped rule is NOT the matched rule.
|
|
3956
|
+
if (result.matchedRule) {
|
|
3957
|
+
expect(result.matchedRule.scope).not.toBe(projectDir);
|
|
3958
|
+
}
|
|
3959
|
+
});
|
|
3960
|
+
});
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for gateway-only ingress mode enforcement in the runtime HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Direct Twilio webhook routes return 410 in gateway_only mode
|
|
6
|
+
* - Internal forwarding routes (gateway→runtime) still work in gateway_only mode
|
|
7
|
+
* - Relay WebSocket upgrade blocked for non-localhost origins in gateway_only mode
|
|
8
|
+
* - Relay WebSocket upgrade allowed from localhost in gateway_only mode
|
|
9
|
+
* - All routes work normally in compat mode
|
|
10
|
+
* - Startup warning when RUNTIME_HTTP_HOST is not loopback in gateway_only mode
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
|
13
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'gw-only-enforcement-test-')));
|
|
18
|
+
|
|
19
|
+
mock.module('../util/platform.js', () => ({
|
|
20
|
+
getRootDir: () => testDir,
|
|
21
|
+
getDataDir: () => testDir,
|
|
22
|
+
getWorkspaceConfigPath: () => join(testDir, 'config.json'),
|
|
23
|
+
isMacOS: () => process.platform === 'darwin',
|
|
24
|
+
isLinux: () => process.platform === 'linux',
|
|
25
|
+
isWindows: () => process.platform === 'win32',
|
|
26
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
27
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
28
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
29
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
30
|
+
ensureDataDir: () => {},
|
|
31
|
+
migrateToDataLayout: () => {},
|
|
32
|
+
migrateToWorkspaceLayout: () => {},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Configurable ingress mode — tests toggle this between 'gateway_only' and 'compat'
|
|
36
|
+
let mockIngressMode: 'gateway_only' | 'compat' = 'compat';
|
|
37
|
+
|
|
38
|
+
const logMessages: { level: string; msg: string; args?: unknown }[] = [];
|
|
39
|
+
|
|
40
|
+
mock.module('../util/logger.js', () => ({
|
|
41
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
42
|
+
get: (_target, prop: string) => {
|
|
43
|
+
if (prop === 'child') return () => new Proxy({} as Record<string, unknown>, {
|
|
44
|
+
get: () => () => {},
|
|
45
|
+
});
|
|
46
|
+
return (...args: unknown[]) => {
|
|
47
|
+
if (typeof args[0] === 'string') {
|
|
48
|
+
logMessages.push({ level: prop, msg: args[0] });
|
|
49
|
+
} else if (typeof args[1] === 'string') {
|
|
50
|
+
logMessages.push({ level: prop, msg: args[1], args: args[0] });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
mock.module('../config/loader.js', () => ({
|
|
58
|
+
loadConfig: () => ({
|
|
59
|
+
model: 'test',
|
|
60
|
+
provider: 'test',
|
|
61
|
+
apiKeys: {},
|
|
62
|
+
memory: { enabled: false },
|
|
63
|
+
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
64
|
+
secretDetection: { enabled: false },
|
|
65
|
+
calls: {
|
|
66
|
+
enabled: true,
|
|
67
|
+
provider: 'twilio',
|
|
68
|
+
webhookBaseUrl: 'https://test.example.com',
|
|
69
|
+
maxDurationSeconds: 3600,
|
|
70
|
+
userConsultTimeoutSeconds: 120,
|
|
71
|
+
disclosure: { enabled: false, text: '' },
|
|
72
|
+
safety: { denyCategories: [] },
|
|
73
|
+
},
|
|
74
|
+
ingress: {
|
|
75
|
+
publicBaseUrl: 'https://test.example.com',
|
|
76
|
+
mode: mockIngressMode,
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
getConfig: () => ({
|
|
80
|
+
model: 'test',
|
|
81
|
+
provider: 'test',
|
|
82
|
+
apiKeys: {},
|
|
83
|
+
memory: { enabled: false },
|
|
84
|
+
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
85
|
+
secretDetection: { enabled: false },
|
|
86
|
+
ingress: {
|
|
87
|
+
publicBaseUrl: 'https://test.example.com',
|
|
88
|
+
mode: mockIngressMode,
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
invalidateConfigCache: () => {},
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
// Mock Twilio provider
|
|
95
|
+
mock.module('../calls/twilio-provider.js', () => ({
|
|
96
|
+
TwilioConversationRelayProvider: class {
|
|
97
|
+
static getAuthToken() { return 'mock-auth-token'; }
|
|
98
|
+
static verifyWebhookSignature() { return true; }
|
|
99
|
+
async initiateCall() { return { callSid: 'CA_mock_sid' }; }
|
|
100
|
+
async endCall() { return; }
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
// Mock Twilio config
|
|
105
|
+
mock.module('../calls/twilio-config.js', () => ({
|
|
106
|
+
getTwilioConfig: () => ({
|
|
107
|
+
accountSid: 'AC_test',
|
|
108
|
+
authToken: 'test_token',
|
|
109
|
+
phoneNumber: '+15550001111',
|
|
110
|
+
webhookBaseUrl: 'https://test.example.com',
|
|
111
|
+
wssBaseUrl: 'wss://test.example.com',
|
|
112
|
+
}),
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
mock.module('../security/secure-keys.js', () => ({
|
|
116
|
+
getSecureKey: () => null,
|
|
117
|
+
setSecureKey: () => true,
|
|
118
|
+
deleteSecureKey: () => {},
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
mock.module('../inbound/public-ingress-urls.js', () => ({
|
|
122
|
+
getPublicBaseUrl: () => 'https://test.example.com',
|
|
123
|
+
getTwilioRelayUrl: () => 'wss://test.example.com/webhooks/twilio/relay',
|
|
124
|
+
getTwilioVoiceWebhookUrl: (_cfg: unknown, id: string) => `https://test.example.com/webhooks/twilio/voice?callSessionId=${id}`,
|
|
125
|
+
getTwilioStatusCallbackUrl: () => 'https://test.example.com/webhooks/twilio/status',
|
|
126
|
+
getTwilioConnectActionUrl: () => 'https://test.example.com/webhooks/twilio/connect-action',
|
|
127
|
+
getOAuthCallbackUrl: () => 'https://test.example.com/webhooks/oauth/callback',
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
// Mock the oauth callback registry
|
|
131
|
+
mock.module('../security/oauth-callback-registry.js', () => ({
|
|
132
|
+
consumeCallback: () => true,
|
|
133
|
+
consumeCallbackError: () => true,
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
import { RuntimeHttpServer } from '../runtime/http-server.js';
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Helpers
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const TEST_TOKEN = 'test-bearer-token-gw';
|
|
143
|
+
const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
|
|
144
|
+
|
|
145
|
+
function makeFormBody(params: Record<string, string>): string {
|
|
146
|
+
return new URLSearchParams(params).toString();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Tests
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe('gateway-only ingress enforcement', () => {
|
|
154
|
+
let server: RuntimeHttpServer;
|
|
155
|
+
let port: number;
|
|
156
|
+
|
|
157
|
+
beforeEach(async () => {
|
|
158
|
+
logMessages.length = 0;
|
|
159
|
+
port = 17800 + Math.floor(Math.random() * 1000);
|
|
160
|
+
server = new RuntimeHttpServer({
|
|
161
|
+
port,
|
|
162
|
+
hostname: '127.0.0.1',
|
|
163
|
+
bearerToken: TEST_TOKEN,
|
|
164
|
+
});
|
|
165
|
+
await server.start();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
afterEach(async () => {
|
|
169
|
+
await server.stop();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── Direct Twilio webhook routes blocked in gateway_only mode ──────
|
|
173
|
+
|
|
174
|
+
describe('gateway_only mode — direct webhook routes', () => {
|
|
175
|
+
beforeEach(() => { mockIngressMode = 'gateway_only'; });
|
|
176
|
+
afterEach(() => { mockIngressMode = 'compat'; });
|
|
177
|
+
|
|
178
|
+
test('POST /webhooks/twilio/voice returns 410', async () => {
|
|
179
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/voice`, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
182
|
+
body: makeFormBody({ CallSid: 'CA123', AccountSid: 'AC_test' }),
|
|
183
|
+
});
|
|
184
|
+
expect(res.status).toBe(410);
|
|
185
|
+
const body = await res.json() as { error: string; code: string };
|
|
186
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
187
|
+
expect(body.error).toContain('gateway-only mode');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('POST /webhooks/twilio/status returns 410', async () => {
|
|
191
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/status`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
194
|
+
body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
|
|
195
|
+
});
|
|
196
|
+
expect(res.status).toBe(410);
|
|
197
|
+
const body = await res.json() as { error: string; code: string };
|
|
198
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('POST /webhooks/twilio/connect-action returns 410', async () => {
|
|
202
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/connect-action`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
205
|
+
body: makeFormBody({ CallSid: 'CA123' }),
|
|
206
|
+
});
|
|
207
|
+
expect(res.status).toBe(410);
|
|
208
|
+
const body = await res.json() as { error: string; code: string };
|
|
209
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('POST /v1/calls/twilio/voice-webhook returns 410', async () => {
|
|
213
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/voice-webhook`, {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
216
|
+
body: makeFormBody({ CallSid: 'CA123' }),
|
|
217
|
+
});
|
|
218
|
+
expect(res.status).toBe(410);
|
|
219
|
+
const body = await res.json() as { error: string; code: string };
|
|
220
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('POST /v1/calls/twilio/status returns 410', async () => {
|
|
224
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/status`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
227
|
+
body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
|
|
228
|
+
});
|
|
229
|
+
expect(res.status).toBe(410);
|
|
230
|
+
const body = await res.json() as { error: string; code: string };
|
|
231
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── Internal forwarding routes still work in gateway_only mode ─────
|
|
236
|
+
|
|
237
|
+
describe('gateway_only mode — internal forwarding routes', () => {
|
|
238
|
+
beforeEach(() => { mockIngressMode = 'gateway_only'; });
|
|
239
|
+
afterEach(() => { mockIngressMode = 'compat'; });
|
|
240
|
+
|
|
241
|
+
test('POST /v1/internal/twilio/voice-webhook is NOT blocked', async () => {
|
|
242
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
params: { CallSid: 'CA123', AccountSid: 'AC_test' },
|
|
247
|
+
originalUrl: `http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook?callSessionId=sess-123`,
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
// Should NOT be 410 — it may 404 or 400 because the call session
|
|
251
|
+
// doesn't exist, but the gateway-only guard should NOT block it.
|
|
252
|
+
expect(res.status).not.toBe(410);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('POST /v1/internal/twilio/status is NOT blocked', async () => {
|
|
256
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/status`, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
259
|
+
body: JSON.stringify({
|
|
260
|
+
params: { CallSid: 'CA123', CallStatus: 'completed' },
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
expect(res.status).not.toBe(410);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('POST /v1/internal/twilio/connect-action is NOT blocked', async () => {
|
|
267
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/connect-action`, {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
params: { CallSid: 'CA123' },
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
expect(res.status).not.toBe(410);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('POST /v1/internal/oauth/callback is NOT blocked', async () => {
|
|
278
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/internal/oauth/callback`, {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
281
|
+
body: JSON.stringify({
|
|
282
|
+
state: 'test-state',
|
|
283
|
+
code: 'test-code',
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
// Should succeed or return a non-410 status
|
|
287
|
+
expect(res.status).not.toBe(410);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── Relay WebSocket upgrade in gateway_only mode ───────────────────
|
|
292
|
+
|
|
293
|
+
describe('gateway_only mode — relay WebSocket upgrade', () => {
|
|
294
|
+
beforeEach(() => { mockIngressMode = 'gateway_only'; });
|
|
295
|
+
afterEach(() => { mockIngressMode = 'compat'; });
|
|
296
|
+
|
|
297
|
+
test('blocks non-localhost origin', async () => {
|
|
298
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
|
|
299
|
+
headers: {
|
|
300
|
+
'Upgrade': 'websocket',
|
|
301
|
+
'Connection': 'Upgrade',
|
|
302
|
+
'Origin': 'https://external.example.com',
|
|
303
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
304
|
+
'Sec-WebSocket-Version': '13',
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
expect(res.status).toBe(403);
|
|
308
|
+
const body = await res.json() as { error: string; code: string };
|
|
309
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
310
|
+
expect(body.error).toContain('gateway-only mode');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('allows request with no origin header (localhost)', async () => {
|
|
314
|
+
// Without an origin header, isLoopbackOrigin returns true
|
|
315
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
|
|
316
|
+
headers: {
|
|
317
|
+
'Upgrade': 'websocket',
|
|
318
|
+
'Connection': 'Upgrade',
|
|
319
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
320
|
+
'Sec-WebSocket-Version': '13',
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
// Should NOT be 403 — WebSocket upgrade may or may not succeed
|
|
324
|
+
// depending on test environment, but the gateway guard should pass.
|
|
325
|
+
expect(res.status).not.toBe(403);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('allows localhost origin', async () => {
|
|
329
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
|
|
330
|
+
headers: {
|
|
331
|
+
'Upgrade': 'websocket',
|
|
332
|
+
'Connection': 'Upgrade',
|
|
333
|
+
'Origin': 'http://127.0.0.1:3000',
|
|
334
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
335
|
+
'Sec-WebSocket-Version': '13',
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
// Should NOT be 403
|
|
339
|
+
expect(res.status).not.toBe(403);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ── Compat mode — everything works as before ───────────────────────
|
|
344
|
+
|
|
345
|
+
describe('compat mode — no enforcement', () => {
|
|
346
|
+
beforeEach(() => { mockIngressMode = 'compat'; });
|
|
347
|
+
|
|
348
|
+
test('POST /webhooks/twilio/voice is NOT blocked', async () => {
|
|
349
|
+
// In compat mode, disable webhook validation to focus on the ingress check
|
|
350
|
+
const savedDisable = process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
351
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
352
|
+
try {
|
|
353
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/voice?callSessionId=test-compat`, {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
356
|
+
body: makeFormBody({ CallSid: 'CA_compat', AccountSid: 'AC_test' }),
|
|
357
|
+
});
|
|
358
|
+
// Should NOT be 410 (gateway-only)
|
|
359
|
+
expect(res.status).not.toBe(410);
|
|
360
|
+
} finally {
|
|
361
|
+
if (savedDisable !== undefined) {
|
|
362
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = savedDisable;
|
|
363
|
+
} else {
|
|
364
|
+
delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('POST /webhooks/twilio/status is NOT blocked', async () => {
|
|
370
|
+
const savedDisable = process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
371
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
372
|
+
try {
|
|
373
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/status`, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
376
|
+
body: makeFormBody({ CallSid: 'CA_compat', CallStatus: 'completed' }),
|
|
377
|
+
});
|
|
378
|
+
expect(res.status).not.toBe(410);
|
|
379
|
+
} finally {
|
|
380
|
+
if (savedDisable !== undefined) {
|
|
381
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = savedDisable;
|
|
382
|
+
} else {
|
|
383
|
+
delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('relay WebSocket upgrade is NOT blocked for external origin', async () => {
|
|
389
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-compat`, {
|
|
390
|
+
headers: {
|
|
391
|
+
'Upgrade': 'websocket',
|
|
392
|
+
'Connection': 'Upgrade',
|
|
393
|
+
'Origin': 'https://external.example.com',
|
|
394
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
395
|
+
'Sec-WebSocket-Version': '13',
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
// In compat mode, the gateway-only guard should not activate
|
|
399
|
+
expect(res.status).not.toBe(403);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ── Startup warning for non-loopback host ──────────────────────────
|
|
404
|
+
|
|
405
|
+
describe('startup guard — non-loopback host warning', () => {
|
|
406
|
+
test('logs warning when hostname is not loopback in gateway_only mode', async () => {
|
|
407
|
+
mockIngressMode = 'gateway_only';
|
|
408
|
+
logMessages.length = 0;
|
|
409
|
+
|
|
410
|
+
const warnServer = new RuntimeHttpServer({
|
|
411
|
+
port: port + 100,
|
|
412
|
+
hostname: '0.0.0.0',
|
|
413
|
+
bearerToken: TEST_TOKEN,
|
|
414
|
+
});
|
|
415
|
+
await warnServer.start();
|
|
416
|
+
|
|
417
|
+
const infoMsg = logMessages.find(
|
|
418
|
+
m => m.level === 'info' && m.msg.includes('gateway-only ingress mode'),
|
|
419
|
+
);
|
|
420
|
+
expect(infoMsg).toBeDefined();
|
|
421
|
+
|
|
422
|
+
const warnMsg = logMessages.find(
|
|
423
|
+
m => m.level === 'warn' && m.msg.includes('not bound to loopback'),
|
|
424
|
+
);
|
|
425
|
+
expect(warnMsg).toBeDefined();
|
|
426
|
+
|
|
427
|
+
await warnServer.stop();
|
|
428
|
+
mockIngressMode = 'compat';
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('does NOT log warning when hostname is loopback in gateway_only mode', async () => {
|
|
432
|
+
mockIngressMode = 'gateway_only';
|
|
433
|
+
logMessages.length = 0;
|
|
434
|
+
|
|
435
|
+
// The main test server already uses 127.0.0.1, so restart with
|
|
436
|
+
// a fresh server and capture logs
|
|
437
|
+
const loopbackServer = new RuntimeHttpServer({
|
|
438
|
+
port: port + 200,
|
|
439
|
+
hostname: '127.0.0.1',
|
|
440
|
+
bearerToken: TEST_TOKEN,
|
|
441
|
+
});
|
|
442
|
+
await loopbackServer.start();
|
|
443
|
+
|
|
444
|
+
const infoMsg = logMessages.find(
|
|
445
|
+
m => m.level === 'info' && m.msg.includes('gateway-only ingress mode'),
|
|
446
|
+
);
|
|
447
|
+
expect(infoMsg).toBeDefined();
|
|
448
|
+
|
|
449
|
+
const warnMsg = logMessages.find(
|
|
450
|
+
m => m.level === 'warn' && m.msg.includes('not bound to loopback'),
|
|
451
|
+
);
|
|
452
|
+
expect(warnMsg).toBeUndefined();
|
|
453
|
+
|
|
454
|
+
await loopbackServer.stop();
|
|
455
|
+
mockIngressMode = 'compat';
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
});
|
|
@@ -1429,6 +1429,17 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
1429
1429
|
sessionId: 'sub-sess-001',
|
|
1430
1430
|
},
|
|
1431
1431
|
},
|
|
1432
|
+
agent_heartbeat_alert: {
|
|
1433
|
+
type: 'agent_heartbeat_alert',
|
|
1434
|
+
title: 'Agent unresponsive',
|
|
1435
|
+
body: 'The agent has not responded for 5 minutes.',
|
|
1436
|
+
},
|
|
1437
|
+
task_run_thread_created: {
|
|
1438
|
+
type: 'task_run_thread_created',
|
|
1439
|
+
conversationId: 'conv-task-001',
|
|
1440
|
+
workItemId: 'work-item-001',
|
|
1441
|
+
title: 'Task run thread',
|
|
1442
|
+
},
|
|
1432
1443
|
};
|
|
1433
1444
|
|
|
1434
1445
|
// ---------------------------------------------------------------------------
|