@vellumai/vellum-gateway 0.7.3 → 0.8.0
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/Dockerfile +2 -1
- package/bun.lock +8 -1
- package/knip.json +1 -0
- package/package.json +2 -1
- package/src/__tests__/ipc-route-policy-coverage.test.ts +297 -0
- package/src/__tests__/ipc-route-policy.test.ts +19 -0
- package/src/__tests__/ipc-server-watchdog.test.ts +189 -0
- package/src/auth/ipc-route-policy.ts +19 -0
- package/src/feature-flag-registry.json +1 -17
- package/src/handlers/handle-inbound.ts +1 -12
- package/src/index.ts +7 -0
- package/src/ipc/server.ts +113 -46
- package/src/risk/bash-risk-classifier.test.ts +82 -0
- package/src/risk/bash-risk-classifier.ts +19 -15
- package/src/risk/shell-parser.test.ts +159 -0
- package/src/risk/shell-parser.ts +150 -19
- package/src/runtime/client.ts +0 -11
- package/src/verification/voice-approval-sync.ts +107 -0
package/Dockerfile
CHANGED
|
@@ -11,6 +11,7 @@ COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
|
|
11
11
|
# Copy shared packages needed by gateway's repo-local dependencies
|
|
12
12
|
COPY packages/assistant-client ./packages/assistant-client
|
|
13
13
|
COPY packages/ces-client ./packages/ces-client
|
|
14
|
+
COPY packages/ipc-server-utils ./packages/ipc-server-utils
|
|
14
15
|
COPY packages/service-contracts ./packages/service-contracts
|
|
15
16
|
COPY packages/slack-text ./packages/slack-text
|
|
16
17
|
COPY packages/twilio-client ./packages/twilio-client
|
|
@@ -56,4 +57,4 @@ EXPOSE 7830
|
|
|
56
57
|
|
|
57
58
|
ENV GATEWAY_PORT=7830
|
|
58
59
|
|
|
59
|
-
CMD ["bun", "run", "src/index.ts"]
|
|
60
|
+
CMD ["bun", "--smol", "run", "src/index.ts"]
|
package/bun.lock
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@vellumai/assistant-client": "file:../packages/assistant-client",
|
|
9
9
|
"@vellumai/ces-client": "file:../packages/ces-client",
|
|
10
|
+
"@vellumai/ipc-server-utils": "file:../packages/ipc-server-utils",
|
|
10
11
|
"@vellumai/service-contracts": "file:../packages/service-contracts",
|
|
11
12
|
"@vellumai/slack-text": "file:../packages/slack-text",
|
|
12
13
|
"@vellumai/twilio-client": "file:../packages/twilio-client",
|
|
@@ -209,6 +210,8 @@
|
|
|
209
210
|
|
|
210
211
|
"@vellumai/ces-client": ["@vellumai/ces-client@file:../packages/ces-client", { "dependencies": { "@vellumai/service-contracts": "file:../service-contracts" }, "devDependencies": { "@types/bun": "1.2.4", "typescript": "5.7.3" } }],
|
|
211
212
|
|
|
213
|
+
"@vellumai/ipc-server-utils": ["@vellumai/ipc-server-utils@file:../packages/ipc-server-utils", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
|
|
214
|
+
|
|
212
215
|
"@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", { "dependencies": { "zod": "4.3.6" }, "devDependencies": { "@types/bun": "1.2.4", "typescript": "5.7.3" } }],
|
|
213
216
|
|
|
214
217
|
"@vellumai/slack-text": ["@vellumai/slack-text@file:../packages/slack-text", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
|
|
@@ -497,10 +500,12 @@
|
|
|
497
500
|
|
|
498
501
|
"@vellumai/ces-client/@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
|
|
499
502
|
|
|
500
|
-
"@vellumai/ces-client/@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", {}],
|
|
503
|
+
"@vellumai/ces-client/@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", { "dependencies": { "zod": "4.3.6" }, "devDependencies": { "@types/bun": "1.2.4", "typescript": "5.7.3" } }],
|
|
501
504
|
|
|
502
505
|
"@vellumai/ces-client/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
|
|
503
506
|
|
|
507
|
+
"@vellumai/ipc-server-utils/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
|
508
|
+
|
|
504
509
|
"@vellumai/service-contracts/@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
|
|
505
510
|
|
|
506
511
|
"@vellumai/service-contracts/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
|
|
@@ -567,6 +572,8 @@
|
|
|
567
572
|
|
|
568
573
|
"@vellumai/ces-client/@types/bun/bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="],
|
|
569
574
|
|
|
575
|
+
"@vellumai/ipc-server-utils/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
|
576
|
+
|
|
570
577
|
"@vellumai/service-contracts/@types/bun/bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="],
|
|
571
578
|
|
|
572
579
|
"@vellumai/slack-text/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
package/knip.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/vellum-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@vellumai/assistant-client": "file:../packages/assistant-client",
|
|
28
28
|
"@vellumai/ces-client": "file:../packages/ces-client",
|
|
29
|
+
"@vellumai/ipc-server-utils": "file:../packages/ipc-server-utils",
|
|
29
30
|
"@vellumai/service-contracts": "file:../packages/service-contracts",
|
|
30
31
|
"@vellumai/slack-text": "file:../packages/slack-text",
|
|
31
32
|
"@vellumai/twilio-client": "file:../packages/twilio-client",
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lint test: every daemon route whose HTTP-side policy is gateway-only
|
|
3
|
+
* MUST have a matching IPC policy entry, with matching required scopes.
|
|
4
|
+
*
|
|
5
|
+
* Background: the gateway's IPC proxy default-allows operationIds that
|
|
6
|
+
* have no policy entry. Routes restricted to the `svc_gateway` principal
|
|
7
|
+
* on the daemon HTTP path must also be locked down on IPC — otherwise an
|
|
8
|
+
* authenticated edge JWT can reach them by setting
|
|
9
|
+
* `X-Vellum-Proxy-Server: ipc`, bypassing the daemon HTTP router entirely.
|
|
10
|
+
*
|
|
11
|
+
* Symmetrically, the IPC entry's `requiredScopes` must match the daemon's
|
|
12
|
+
* `requiredScopes`. If IPC permits a broader scope than the daemon HTTP
|
|
13
|
+
* path requires, the IPC path is more permissive than the HTTP path —
|
|
14
|
+
* the same scope-bypass class this guard is designed to prevent.
|
|
15
|
+
*
|
|
16
|
+
* This bug class has bitten us multiple times:
|
|
17
|
+
* - PR #29571 (MCP OAuth routes — Codex finding)
|
|
18
|
+
* - PR #29612 (OAuth connect routes — Codex finding)
|
|
19
|
+
*
|
|
20
|
+
* Rather than rely on Codex catching it a third time, this test walks
|
|
21
|
+
* the daemon route source files and the daemon route-policy source file
|
|
22
|
+
* at test time and asserts every gateway-only operationId is registered
|
|
23
|
+
* in the IPC policy table with matching scopes and principals.
|
|
24
|
+
*
|
|
25
|
+
* Implementation notes:
|
|
26
|
+
* - Uses text parsing rather than direct imports because the gateway
|
|
27
|
+
* and assistant packages don't share source-level imports (they
|
|
28
|
+
* communicate through the `@vellumai/service-contracts` package).
|
|
29
|
+
* - Regexes are intentionally loose. False positives (matching too
|
|
30
|
+
* much) only result in extra coverage; false negatives (missing
|
|
31
|
+
* real gateway-only routes) defeat the lint.
|
|
32
|
+
* - Daemon route endpoints may include parameter segments
|
|
33
|
+
* (e.g. `internal/oauth/connect/status/:state`) while the
|
|
34
|
+
* daemon's route-policy keys drop those segments
|
|
35
|
+
* (e.g. `internal/oauth/connect/status`). We normalize by
|
|
36
|
+
* stripping `/:param` segments before matching so parameterized
|
|
37
|
+
* gateway-only routes are not silently excluded.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { describe, expect, test } from "bun:test";
|
|
41
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
42
|
+
import { dirname, join } from "node:path";
|
|
43
|
+
import { fileURLToPath } from "node:url";
|
|
44
|
+
|
|
45
|
+
import { getIpcRoutePolicy } from "../auth/ipc-route-policy.js";
|
|
46
|
+
|
|
47
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
|
|
49
|
+
// gateway/src/__tests__ → repo root → assistant/...
|
|
50
|
+
const ASSISTANT_SRC = join(
|
|
51
|
+
__dirname,
|
|
52
|
+
"..",
|
|
53
|
+
"..",
|
|
54
|
+
"..",
|
|
55
|
+
"assistant",
|
|
56
|
+
"src",
|
|
57
|
+
);
|
|
58
|
+
const ROUTES_DIR = join(ASSISTANT_SRC, "runtime", "routes");
|
|
59
|
+
const ROUTE_POLICY_FILE = join(
|
|
60
|
+
ASSISTANT_SRC,
|
|
61
|
+
"runtime",
|
|
62
|
+
"auth",
|
|
63
|
+
"route-policy.ts",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Step 1 — Collect every (operationId, endpoint) pair from daemon routes.
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
interface RoutePair {
|
|
71
|
+
operationId: string;
|
|
72
|
+
endpoint: string;
|
|
73
|
+
sourceFile: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectRouteSourceFiles(dir: string): string[] {
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (const entry of readdirSync(dir)) {
|
|
79
|
+
const full = join(dir, entry);
|
|
80
|
+
const st = statSync(full);
|
|
81
|
+
if (st.isDirectory()) {
|
|
82
|
+
if (entry === "__tests__") continue;
|
|
83
|
+
out.push(...collectRouteSourceFiles(full));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!entry.endsWith(".ts")) continue;
|
|
87
|
+
if (entry.endsWith(".test.ts")) continue;
|
|
88
|
+
out.push(full);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* For each `operationId: "..."` literal, find the closest `endpoint: "..."`
|
|
95
|
+
* literal within a 600-character window. The codebase's style writes both
|
|
96
|
+
* fields near the top of each route definition, so 600 chars comfortably
|
|
97
|
+
* covers the longest route block.
|
|
98
|
+
*/
|
|
99
|
+
function extractRoutePairs(source: string, sourceFile: string): RoutePair[] {
|
|
100
|
+
const pairs: RoutePair[] = [];
|
|
101
|
+
const opRegex = /operationId:\s*["']([^"']+)["']/g;
|
|
102
|
+
for (const m of source.matchAll(opRegex)) {
|
|
103
|
+
const operationId = m[1]!;
|
|
104
|
+
const start = m.index!;
|
|
105
|
+
const end = Math.min(start + 600, source.length);
|
|
106
|
+
const window = source.slice(start, end);
|
|
107
|
+
const epMatch = window.match(/endpoint:\s*["']([^"']+)["']/);
|
|
108
|
+
if (epMatch) {
|
|
109
|
+
pairs.push({ operationId, endpoint: epMatch[1]!, sourceFile });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return pairs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function collectAllRoutePairs(): RoutePair[] {
|
|
116
|
+
const out: RoutePair[] = [];
|
|
117
|
+
for (const file of collectRouteSourceFiles(ROUTES_DIR)) {
|
|
118
|
+
out.push(...extractRoutePairs(readFileSync(file, "utf-8"), file));
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Strip `/:param` segments so a route's `endpoint` matches the policy
|
|
125
|
+
* key registered in route-policy.ts. The daemon's HTTP router uses the
|
|
126
|
+
* non-parameterized form as the canonical policy key.
|
|
127
|
+
*
|
|
128
|
+
* Examples:
|
|
129
|
+
* "internal/oauth/connect/status/:state" → "internal/oauth/connect/status"
|
|
130
|
+
* "internal/mcp/auth/status/:serverId" → "internal/mcp/auth/status"
|
|
131
|
+
* "profiler/runs/:runId" → "profiler/runs"
|
|
132
|
+
*/
|
|
133
|
+
function normalizeEndpoint(endpoint: string): string {
|
|
134
|
+
return endpoint.replace(/\/:[^/]+/g, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Step 2 — Extract gateway-only endpoints (with required scopes) from
|
|
139
|
+
// daemon's route-policy.ts.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse the daemon's route-policy.ts source to find every endpoint
|
|
144
|
+
* registered with `allowedPrincipalTypes: ["svc_gateway"]`. For each,
|
|
145
|
+
* record the `requiredScopes` array so the IPC policy can be cross-checked
|
|
146
|
+
* for scope parity (not just principal parity).
|
|
147
|
+
*
|
|
148
|
+
* Two patterns are supported:
|
|
149
|
+
* 1. Direct: `registerPolicy("endpoint", { requiredScopes: [...], ["svc_gateway"] ... })`
|
|
150
|
+
* 2. Loop: `const X_ENDPOINTS = ["a", "b", ...]; for (const e of X_ENDPOINTS) { registerPolicy(e, { requiredScopes: [...], ["svc_gateway"] ... }) }`
|
|
151
|
+
*
|
|
152
|
+
* Pattern 2 is detected heuristically: when a `const ARRAY = [...]` is
|
|
153
|
+
* followed by a `for...of ARRAY` containing `registerPolicy(...)` and
|
|
154
|
+
* `["svc_gateway"]`, every string in the array is treated as gateway-only
|
|
155
|
+
* and shares the loop body's `requiredScopes`.
|
|
156
|
+
*/
|
|
157
|
+
function extractScopes(block: string): string[] | null {
|
|
158
|
+
const m = block.match(/requiredScopes:\s*\[([^\]]*)\]/);
|
|
159
|
+
if (!m) return null;
|
|
160
|
+
const scopes: string[] = [];
|
|
161
|
+
for (const lit of m[1]!.matchAll(/["']([^"']+)["']/g)) {
|
|
162
|
+
scopes.push(lit[1]!);
|
|
163
|
+
}
|
|
164
|
+
return scopes;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface GatewayOnlyEntry {
|
|
168
|
+
requiredScopes: string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractGatewayOnlyEndpoints(): Map<string, GatewayOnlyEntry> {
|
|
172
|
+
const text = readFileSync(ROUTE_POLICY_FILE, "utf-8");
|
|
173
|
+
const out = new Map<string, GatewayOnlyEntry>();
|
|
174
|
+
|
|
175
|
+
// Pattern 1: explicit registerPolicy calls.
|
|
176
|
+
//
|
|
177
|
+
// Split the file into individual `registerPolicy(...)` blocks first
|
|
178
|
+
// (using a non-greedy match up to the next `});`) so the multi-line
|
|
179
|
+
// [\s\S]*? alternation can't accidentally span multiple registrations
|
|
180
|
+
// and pick up a "svc_gateway"-only array from a different policy.
|
|
181
|
+
const blockRegex =
|
|
182
|
+
/registerPolicy\(\s*["']([^"']+)["']\s*,\s*\{[\s\S]*?\}\s*\)\s*;/g;
|
|
183
|
+
for (const m of text.matchAll(blockRegex)) {
|
|
184
|
+
const endpoint = m[1]!;
|
|
185
|
+
const block = m[0]!;
|
|
186
|
+
// Within this single registerPolicy block, require allowedPrincipalTypes
|
|
187
|
+
// to be EXACTLY ["svc_gateway"] — no other principals.
|
|
188
|
+
if (
|
|
189
|
+
!/allowedPrincipalTypes:\s*\[\s*["']svc_gateway["']\s*\]/.test(block)
|
|
190
|
+
)
|
|
191
|
+
continue;
|
|
192
|
+
const scopes = extractScopes(block);
|
|
193
|
+
if (!scopes) continue;
|
|
194
|
+
out.set(endpoint, { requiredScopes: scopes });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Pattern 2: const ARRAY = [...] followed by a for-of loop that
|
|
198
|
+
// registers svc_gateway-only policies for each element. Detected
|
|
199
|
+
// heuristically: when a `const ARRAY = [...]` is followed somewhere
|
|
200
|
+
// in the file by a for-of loop over that array containing both a
|
|
201
|
+
// `registerPolicy(` and a literal `["svc_gateway"]`, every string in
|
|
202
|
+
// the array is treated as gateway-only and shares the loop body's
|
|
203
|
+
// `requiredScopes`.
|
|
204
|
+
const arrayDeclRegex =
|
|
205
|
+
/const\s+([A-Z_][A-Z0-9_]*)\s*=\s*\[([\s\S]*?)\]\s*;/g;
|
|
206
|
+
for (const m of text.matchAll(arrayDeclRegex)) {
|
|
207
|
+
const arrayName = m[1]!;
|
|
208
|
+
const arrayBody = m[2]!;
|
|
209
|
+
// Find a for-of loop over this array. Use a non-greedy body match
|
|
210
|
+
// that stops at the closing `}` of the for-block.
|
|
211
|
+
const loopBlockRegex = new RegExp(
|
|
212
|
+
String.raw`for\s*\(\s*const\s+\w+\s+of\s+` +
|
|
213
|
+
arrayName +
|
|
214
|
+
String.raw`\s*\)\s*\{[\s\S]*?\}`,
|
|
215
|
+
);
|
|
216
|
+
const loopMatch = text.match(loopBlockRegex);
|
|
217
|
+
if (!loopMatch) continue;
|
|
218
|
+
const loopBody = loopMatch[0];
|
|
219
|
+
if (!loopBody.includes("registerPolicy")) continue;
|
|
220
|
+
if (!/\[\s*["']svc_gateway["']\s*\]/.test(loopBody)) continue;
|
|
221
|
+
const scopes = extractScopes(loopBody);
|
|
222
|
+
if (!scopes) continue;
|
|
223
|
+
// Extract every string literal from the array body.
|
|
224
|
+
for (const lit of arrayBody.matchAll(/["']([^"']+)["']/g)) {
|
|
225
|
+
out.set(lit[1]!, { requiredScopes: scopes });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Step 3 — Cross-reference and assert.
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
describe("ipc-route-policy: gateway-only coverage lint", () => {
|
|
237
|
+
const gatewayOnlyEndpoints = extractGatewayOnlyEndpoints();
|
|
238
|
+
const routePairs = collectAllRoutePairs();
|
|
239
|
+
|
|
240
|
+
// Build the gateway-only operationId set by intersecting
|
|
241
|
+
// (normalized routes) ∩ (policy keys). Preserve the daemon's
|
|
242
|
+
// requiredScopes so the IPC policy can be checked for scope parity.
|
|
243
|
+
const gatewayOnlyRoutes = routePairs
|
|
244
|
+
.map((r) => {
|
|
245
|
+
const normalized = normalizeEndpoint(r.endpoint);
|
|
246
|
+
const entry = gatewayOnlyEndpoints.get(normalized);
|
|
247
|
+
if (!entry) return null;
|
|
248
|
+
return { ...r, normalizedEndpoint: normalized, daemonScopes: entry.requiredScopes };
|
|
249
|
+
})
|
|
250
|
+
.filter(
|
|
251
|
+
(r): r is RoutePair & { normalizedEndpoint: string; daemonScopes: string[] } =>
|
|
252
|
+
r !== null,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
test("discovery sanity: found gateway-only daemon routes", () => {
|
|
256
|
+
// If the discovery returns zero, we'd silently pass every check
|
|
257
|
+
// below. Fail loud instead.
|
|
258
|
+
expect(gatewayOnlyEndpoints.size).toBeGreaterThan(0);
|
|
259
|
+
expect(gatewayOnlyRoutes.length).toBeGreaterThan(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// One test case per gateway-only route so the failure message points
|
|
263
|
+
// directly at the specific operationId that's missing coverage.
|
|
264
|
+
for (const route of gatewayOnlyRoutes) {
|
|
265
|
+
const relPath = route.sourceFile.split("/assistant/src/")[1] ?? route.sourceFile;
|
|
266
|
+
test(`${route.operationId} (endpoint=${route.endpoint}) has an IPC policy entry`, () => {
|
|
267
|
+
const policy = getIpcRoutePolicy(route.operationId);
|
|
268
|
+
expect(
|
|
269
|
+
policy,
|
|
270
|
+
`${route.operationId} is registered as a gateway-only daemon ` +
|
|
271
|
+
`route (endpoint=${route.endpoint}, defined in assistant/src/${relPath}) ` +
|
|
272
|
+
`but is missing from gateway/src/auth/ipc-route-policy.ts. ` +
|
|
273
|
+
`Add an entry: ` +
|
|
274
|
+
`["${route.operationId}", ${JSON.stringify(route.daemonScopes)}, ["svc_gateway"]] ` +
|
|
275
|
+
`to match the daemon HTTP policy.`,
|
|
276
|
+
).toBeDefined();
|
|
277
|
+
expect(policy!.allowedPrincipalTypes).toEqual(["svc_gateway"]);
|
|
278
|
+
// Scope parity: IPC requiredScopes must match daemon requiredScopes
|
|
279
|
+
// exactly (as a set). Otherwise the IPC path could be reached with
|
|
280
|
+
// a broader/different scope than the daemon HTTP path requires,
|
|
281
|
+
// recreating the scope-bypass class this lint exists to prevent.
|
|
282
|
+
// Compare as plain string[] — Scope is a string union, but the daemon
|
|
283
|
+
// scopes come from text-parsed source so they're already string[].
|
|
284
|
+
const ipcScopes: string[] = [...policy!.requiredScopes].sort();
|
|
285
|
+
const daemonScopes: string[] = [...route.daemonScopes].sort();
|
|
286
|
+
expect(
|
|
287
|
+
ipcScopes,
|
|
288
|
+
`${route.operationId} has IPC requiredScopes=${JSON.stringify(ipcScopes)} ` +
|
|
289
|
+
`but daemon HTTP requires ${JSON.stringify(daemonScopes)}. ` +
|
|
290
|
+
`Scope mismatch makes the IPC path more permissive than the HTTP ` +
|
|
291
|
+
`path, recreating the scope-bypass class this lint prevents. ` +
|
|
292
|
+
`Update the entry in gateway/src/auth/ipc-route-policy.ts to use ` +
|
|
293
|
+
`${JSON.stringify(daemonScopes)}.`,
|
|
294
|
+
).toEqual(daemonScopes);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
});
|
|
@@ -10,15 +10,34 @@ describe("ipc-route-policy: gateway-only daemon routes", () => {
|
|
|
10
10
|
// X-Vellum-Proxy-Server: ipc, bypassing the daemon HTTP router entirely.
|
|
11
11
|
test.each([
|
|
12
12
|
"admin_rollbackmigrations_post",
|
|
13
|
+
"emit_event",
|
|
13
14
|
"internal_mcp_auth_start",
|
|
14
15
|
"internal_mcp_auth_status",
|
|
15
16
|
"internal_mcp_reload",
|
|
17
|
+
"internal_oauth_callback",
|
|
16
18
|
"internal_oauth_connect_start",
|
|
17
19
|
"internal_oauth_connect_status",
|
|
20
|
+
"internal_twilio_connect_action",
|
|
21
|
+
"internal_twilio_status",
|
|
22
|
+
"internal_twilio_voice_webhook",
|
|
23
|
+
"profiler_runs_get",
|
|
24
|
+
"profiler_runs_by_runId_delete",
|
|
25
|
+
"profiler_runs_by_runId_export_post",
|
|
26
|
+
"profiler_runs_by_runId_get",
|
|
27
|
+
"upgrade_broadcast",
|
|
28
|
+
"workspace_commit",
|
|
18
29
|
])("%s requires internal.write and svc_gateway", (operationId) => {
|
|
19
30
|
const policy = getIpcRoutePolicy(operationId);
|
|
20
31
|
expect(policy).toBeDefined();
|
|
21
32
|
expect(policy!.requiredScopes).toEqual(["internal.write"]);
|
|
22
33
|
expect(policy!.allowedPrincipalTypes).toEqual(["svc_gateway"]);
|
|
23
34
|
});
|
|
35
|
+
|
|
36
|
+
// channels/inbound uses ingress.write rather than internal.write.
|
|
37
|
+
test("channel_inbound requires ingress.write and svc_gateway", () => {
|
|
38
|
+
const policy = getIpcRoutePolicy("channel_inbound");
|
|
39
|
+
expect(policy).toBeDefined();
|
|
40
|
+
expect(policy!.requiredScopes).toEqual(["ingress.write"]);
|
|
41
|
+
expect(policy!.allowedPrincipalTypes).toEqual(["svc_gateway"]);
|
|
42
|
+
});
|
|
24
43
|
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { createConnection, type Socket } from "node:net";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import "./test-preload.js";
|
|
9
|
+
|
|
10
|
+
import { GatewayIpcServer, type IpcRoute } from "../ipc/server.js";
|
|
11
|
+
|
|
12
|
+
// Integration tests for GatewayIpcServer's watchdog wiring. The watchdog's
|
|
13
|
+
// own unit tests (race guards, timer error handling, etc.) live in
|
|
14
|
+
// `@vellumai/ipc-server-utils`. These tests verify that the gateway server
|
|
15
|
+
// correctly wires the watchdog into its own lifecycle and legacy-server
|
|
16
|
+
// bookkeeping.
|
|
17
|
+
|
|
18
|
+
// macOS caps Unix socket paths at sizeof(sun_path)-1 == 103 chars, so the
|
|
19
|
+
// shared test-preload temp dir is too long. Mint our own short path under
|
|
20
|
+
// the system tmpdir for this test.
|
|
21
|
+
const shortRoot = mkdtempSync(join(tmpdir(), "vmw-"));
|
|
22
|
+
const socketPath = join(shortRoot, "g.sock");
|
|
23
|
+
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
try {
|
|
26
|
+
rmSync(shortRoot, { recursive: true, force: true });
|
|
27
|
+
} catch {
|
|
28
|
+
// best-effort
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function connectClient(path: string): Promise<Socket> {
|
|
33
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
34
|
+
const client: Socket = createConnection(path, () => resolve(client));
|
|
35
|
+
client.on("error", reject);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sendRequest(
|
|
40
|
+
client: Socket,
|
|
41
|
+
method: string,
|
|
42
|
+
params?: Record<string, unknown>,
|
|
43
|
+
): Promise<{ id: string; result?: unknown; error?: string }> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const id = randomBytes(4).toString("hex");
|
|
46
|
+
let buffer = "";
|
|
47
|
+
|
|
48
|
+
const onData = (chunk: Buffer) => {
|
|
49
|
+
buffer += chunk.toString();
|
|
50
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
51
|
+
if (newlineIdx !== -1) {
|
|
52
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
53
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
54
|
+
client.off("data", onData);
|
|
55
|
+
try {
|
|
56
|
+
resolve(JSON.parse(line));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
reject(err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
client.on("data", onData);
|
|
64
|
+
client.write(JSON.stringify({ id, method, params }) + "\n");
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const echoRoute: IpcRoute = {
|
|
69
|
+
method: "echo",
|
|
70
|
+
handler: (params) => ({ echoed: params?.value ?? null }),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a server with the test-owned short socket path. The constructor
|
|
75
|
+
* resolves the path via env-var defaults that may not point at our temp
|
|
76
|
+
* dir, so we override the private `socketPath` field directly — same
|
|
77
|
+
* pattern used by `ipc-server-multi-client.test.ts`.
|
|
78
|
+
*
|
|
79
|
+
* Note: the watchdog is constructed in the GatewayIpcServer constructor
|
|
80
|
+
* and captures the original (unmocked) socketPath via closure. Tests that
|
|
81
|
+
* exercise the watchdog must therefore disable the timer-driven path and
|
|
82
|
+
* use the public `rebindIfMissing()` entry point, which reads
|
|
83
|
+
* `this.socketPath` lazily through the watchdog's `socketPath` capture —
|
|
84
|
+
* which we also need to monkeypatch. See {@link buildServer}.
|
|
85
|
+
*/
|
|
86
|
+
function buildServer(opts: { watchdogIntervalMs: number }): GatewayIpcServer {
|
|
87
|
+
const server = new GatewayIpcServer([echoRoute], {
|
|
88
|
+
watchdogIntervalMs: opts.watchdogIntervalMs,
|
|
89
|
+
});
|
|
90
|
+
// The watchdog captures socketPath in its constructor, so override both
|
|
91
|
+
// the public field (for start()/stop()) and the watchdog's private copy.
|
|
92
|
+
(server as unknown as { socketPath: string }).socketPath = socketPath;
|
|
93
|
+
const watchdog = (server as unknown as { watchdog: { socketPath: string } })
|
|
94
|
+
.watchdog;
|
|
95
|
+
watchdog.socketPath = socketPath;
|
|
96
|
+
return server;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function waitForListening(path: string, timeoutMs = 1000): Promise<void> {
|
|
100
|
+
const deadline = Date.now() + timeoutMs;
|
|
101
|
+
while (!existsSync(path) && Date.now() < deadline) {
|
|
102
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
103
|
+
}
|
|
104
|
+
if (!existsSync(path)) {
|
|
105
|
+
throw new Error(`server did not bind ${path} within ${timeoutMs}ms`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("GatewayIpcServer watchdog wiring", () => {
|
|
110
|
+
let server: GatewayIpcServer | undefined;
|
|
111
|
+
const sockets: Socket[] = [];
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
server = undefined;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
for (const s of sockets) {
|
|
119
|
+
if (!s.destroyed) s.destroy();
|
|
120
|
+
}
|
|
121
|
+
sockets.length = 0;
|
|
122
|
+
|
|
123
|
+
if (server) {
|
|
124
|
+
server.stop();
|
|
125
|
+
server = undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (existsSync(socketPath)) {
|
|
129
|
+
try {
|
|
130
|
+
unlinkSync(socketPath);
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("rebindIfMissing restores the listener and accepts new clients end-to-end", async () => {
|
|
138
|
+
server = buildServer({ watchdogIntervalMs: 0 });
|
|
139
|
+
server.start();
|
|
140
|
+
await waitForListening(socketPath);
|
|
141
|
+
|
|
142
|
+
// A baseline client confirms the initial listener is healthy.
|
|
143
|
+
const baseline = await connectClient(socketPath);
|
|
144
|
+
sockets.push(baseline);
|
|
145
|
+
const baselineEcho = await sendRequest(baseline, "echo", { value: "pre" });
|
|
146
|
+
expect(baselineEcho.result).toEqual({ echoed: "pre" });
|
|
147
|
+
|
|
148
|
+
// Simulate the cleanup that wipes /run/* — unlink the socket file
|
|
149
|
+
// while the listening fd is still alive in the kernel.
|
|
150
|
+
unlinkSync(socketPath);
|
|
151
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
152
|
+
|
|
153
|
+
const rebound = await server.rebindIfMissing();
|
|
154
|
+
expect(rebound).toBe(true);
|
|
155
|
+
expect(existsSync(socketPath)).toBe(true);
|
|
156
|
+
|
|
157
|
+
// A new client can connect to the re-bound listener and exercise the
|
|
158
|
+
// route table — proving onRebind correctly installed the new server
|
|
159
|
+
// as the primary.
|
|
160
|
+
const fresh = await connectClient(socketPath);
|
|
161
|
+
sockets.push(fresh);
|
|
162
|
+
const freshEcho = await sendRequest(fresh, "echo", { value: "post" });
|
|
163
|
+
expect(freshEcho.result).toEqual({ echoed: "post" });
|
|
164
|
+
|
|
165
|
+
// The pre-existing client survives the rebind because its connected
|
|
166
|
+
// socket inode lives independently of the listener path.
|
|
167
|
+
expect(baseline.destroyed).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("stop() halts the watchdog so a later unlink does not resurrect the listener", async () => {
|
|
171
|
+
server = buildServer({ watchdogIntervalMs: 10 });
|
|
172
|
+
server.start();
|
|
173
|
+
await waitForListening(socketPath);
|
|
174
|
+
|
|
175
|
+
server.stop();
|
|
176
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
177
|
+
|
|
178
|
+
// Even if something recreated and removed the path again, the watchdog
|
|
179
|
+
// has been stopped and rebindIfMissing returns false because the
|
|
180
|
+
// server reference was nulled.
|
|
181
|
+
const rebound = await server.rebindIfMissing();
|
|
182
|
+
expect(rebound).toBe(false);
|
|
183
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
184
|
+
|
|
185
|
+
// Wait past several timer ticks to confirm no background rebind fires.
|
|
186
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
187
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -48,12 +48,31 @@ type PolicyEntry =
|
|
|
48
48
|
*/
|
|
49
49
|
const POLICY_TABLE: PolicyEntry[] = [
|
|
50
50
|
// Admin / internal
|
|
51
|
+
//
|
|
52
|
+
// Every operationId whose daemon-side route policy is gateway-only
|
|
53
|
+
// (`allowedPrincipalTypes: ["svc_gateway"]` in
|
|
54
|
+
// `assistant/src/runtime/auth/route-policy.ts`) MUST have a matching
|
|
55
|
+
// entry here. The gateway IPC proxy default-allows operationIds with
|
|
56
|
+
// no policy entry, so an authenticated edge JWT could otherwise reach
|
|
57
|
+
// them by setting `X-Vellum-Proxy-Server: ipc`, bypassing the daemon
|
|
58
|
+
// HTTP router entirely.
|
|
59
|
+
//
|
|
60
|
+
// The `ipc-route-policy-coverage.test.ts` lint enforces this invariant
|
|
61
|
+
// by walking the daemon route source files at test time.
|
|
51
62
|
["admin_rollbackmigrations_post", ["internal.write"], ["svc_gateway"]],
|
|
63
|
+
["channel_inbound", ["ingress.write"], ["svc_gateway"]],
|
|
64
|
+
["emit_event", ["internal.write"], ["svc_gateway"]],
|
|
52
65
|
["internal_mcp_auth_start", ["internal.write"], ["svc_gateway"]],
|
|
53
66
|
["internal_mcp_auth_status", ["internal.write"], ["svc_gateway"]],
|
|
54
67
|
["internal_mcp_reload", ["internal.write"], ["svc_gateway"]],
|
|
68
|
+
["internal_oauth_callback", ["internal.write"], ["svc_gateway"]],
|
|
55
69
|
["internal_oauth_connect_start", ["internal.write"], ["svc_gateway"]],
|
|
56
70
|
["internal_oauth_connect_status", ["internal.write"], ["svc_gateway"]],
|
|
71
|
+
["internal_twilio_connect_action", ["internal.write"], ["svc_gateway"]],
|
|
72
|
+
["internal_twilio_status", ["internal.write"], ["svc_gateway"]],
|
|
73
|
+
["internal_twilio_voice_webhook", ["internal.write"], ["svc_gateway"]],
|
|
74
|
+
["upgrade_broadcast", ["internal.write"], ["svc_gateway"]],
|
|
75
|
+
["workspace_commit", ["internal.write"], ["svc_gateway"]],
|
|
57
76
|
|
|
58
77
|
// Calls
|
|
59
78
|
["calls_answer", ["calls.write"]],
|
|
@@ -249,14 +249,6 @@
|
|
|
249
249
|
"description": "Enable disk pressure protection flows that block background work and remote actors while storage is critically low.",
|
|
250
250
|
"defaultEnabled": false
|
|
251
251
|
},
|
|
252
|
-
{
|
|
253
|
-
"id": "memory-v2-enabled",
|
|
254
|
-
"scope": "assistant",
|
|
255
|
-
"key": "memory-v2-enabled",
|
|
256
|
-
"label": "Memory v2 (concept-page activation model)",
|
|
257
|
-
"description": "Enables the v2 memory subsystem: prose concept pages with bidirectional edges, activation-based retrieval, and hourly LLM-driven consolidation. When on, v1 graph extraction/maintenance and PKB filing are suppressed; flipping the flag back off re-engages the full v1 pipeline.",
|
|
258
|
-
"defaultEnabled": true
|
|
259
|
-
},
|
|
260
252
|
{
|
|
261
253
|
"id": "account-deletion",
|
|
262
254
|
"scope": "client",
|
|
@@ -286,15 +278,7 @@
|
|
|
286
278
|
"scope": "assistant",
|
|
287
279
|
"key": "pro-plan-adjust",
|
|
288
280
|
"label": "Pro Plan Adjust",
|
|
289
|
-
"description": "Show the rich Plan card (current plan, features, Manage/Upgrade CTA) at the top of the macOS Settings → Billing tab.
|
|
290
|
-
"defaultEnabled": false
|
|
291
|
-
},
|
|
292
|
-
{
|
|
293
|
-
"id": "auto-credit-topup",
|
|
294
|
-
"scope": "assistant",
|
|
295
|
-
"key": "auto-credit-topup",
|
|
296
|
-
"label": "Auto Credit Top-Up",
|
|
297
|
-
"description": "Show the 'Configure Auto Top Ups' CTA in the macOS Settings → Billing tab. Mirrors the platform web flag of the same name that gates the auto-reload card and /v1/organizations/billing/auto-top-up/ API.",
|
|
281
|
+
"description": "Show the rich Plan card (current plan, features, Manage/Upgrade CTA) at the top of the macOS Settings → Billing tab.",
|
|
298
282
|
"defaultEnabled": false
|
|
299
283
|
}
|
|
300
284
|
]
|