@vellumai/vellum-gateway 0.7.3 → 0.8.1
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/AGENTS.md +10 -0
- package/Dockerfile +4 -2
- package/bun.lock +8 -1
- package/knip.json +1 -0
- package/package.json +2 -1
- package/src/__tests__/contact-prompt-submit.test.ts +5 -5
- package/src/__tests__/contact-store-mark-channel-verified.test.ts +177 -0
- package/src/__tests__/contacts-control-plane-proxy.test.ts +297 -6
- package/src/__tests__/contacts-control-plane-route-match.test.ts +16 -0
- package/src/__tests__/edge-auth.test.ts +253 -0
- package/src/__tests__/edge-guardian-auth.test.ts +198 -0
- package/src/__tests__/ipc-route-policy-coverage.test.ts +297 -0
- package/src/__tests__/ipc-route-policy.test.ts +43 -0
- package/src/__tests__/ipc-server-watchdog.test.ts +189 -0
- package/src/__tests__/live-voice-websocket.test.ts +1 -1
- package/src/__tests__/slack-normalize.test.ts +132 -0
- package/src/auth/guardian-bootstrap.ts +3 -1
- package/src/auth/ipc-route-policy.ts +34 -0
- package/src/db/assistant-db-proxy.ts +76 -7
- package/src/db/contact-store.ts +767 -1
- package/src/db/schema.ts +29 -0
- package/src/feature-flag-registry.json +17 -17
- package/src/handlers/handle-inbound.ts +9 -23
- package/src/http/middleware/auth.ts +193 -40
- package/src/http/router.ts +26 -4
- package/src/http/routes/channel-verification-session-proxy.ts +53 -6
- package/src/http/routes/contact-prompt.ts +44 -15
- package/src/http/routes/contacts-control-plane-proxy.ts +329 -2
- package/src/http/routes/contacts-control-plane-route-match.ts +12 -0
- package/src/http/routes/ipc-runtime-proxy.test.ts +38 -43
- package/src/http/routes/ipc-runtime-proxy.ts +2 -2
- package/src/http/routes/log-export.test.ts +1 -0
- package/src/http/routes/log-export.ts +9 -2
- package/src/http/routes/log-tail.test.ts +10 -9
- package/src/http/routes/log-tail.ts +5 -2
- package/src/http/routes/pair.ts +8 -0
- package/src/http/routes/twilio-voice-webhook.ts +11 -2
- package/src/index.ts +98 -13
- package/src/ipc/assistant-client.test.ts +67 -15
- package/src/ipc/assistant-client.ts +12 -118
- package/src/ipc/risk-classification-handlers.test.ts +76 -0
- package/src/ipc/risk-classification-handlers.ts +20 -9
- package/src/ipc/server.ts +113 -46
- package/src/logger.ts +71 -17
- package/src/post-assistant-ready.ts +9 -3
- package/src/risk/bash-risk-classifier.test.ts +106 -0
- package/src/risk/bash-risk-classifier.ts +19 -15
- package/src/risk/command-registry/commands/assistant.ts +75 -26
- package/src/risk/command-registry.test.ts +3 -1
- 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/schema.ts +32 -0
- package/src/slack/normalize.test.ts +5 -0
- package/src/slack/normalize.ts +7 -0
- package/src/velay/bridge-utils.ts +10 -0
- package/src/velay/client.test.ts +156 -0
- package/src/velay/client.ts +83 -0
- package/src/velay/http-bridge.test.ts +29 -0
- package/src/velay/http-bridge.ts +7 -0
- package/src/velay/protocol.ts +12 -1
- package/src/verification/outbound-voice-verification-sync.ts +171 -0
- package/src/verification/voice-approval-sync.ts +107 -0
|
@@ -43,6 +43,7 @@ mock.module("../../logger.js", () => {
|
|
|
43
43
|
getLogger: () => noopLogger,
|
|
44
44
|
initLogger: noop,
|
|
45
45
|
LOG_FILE_PATTERN: /^gateway-(\d{4}-\d{2}-\d{2})\.log$/,
|
|
46
|
+
LOG_FILE_JSON_PATTERN: /^gateway-(\d{4}-\d{2}-\d{2})\.jsonl$/,
|
|
46
47
|
};
|
|
47
48
|
});
|
|
48
49
|
|
|
@@ -137,7 +138,7 @@ describe("log-tail handler", () => {
|
|
|
137
138
|
makeLogLine(30, "entry 4"),
|
|
138
139
|
makeLogLine(30, "entry 5"),
|
|
139
140
|
];
|
|
140
|
-
writeFileSync(join(tmpDir, "gateway-2026-05-04.
|
|
141
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.jsonl"), lines.join("\n"));
|
|
141
142
|
|
|
142
143
|
const config = makeConfig(tmpDir);
|
|
143
144
|
const handler = createLogTailHandler(config);
|
|
@@ -161,7 +162,7 @@ describe("log-tail handler", () => {
|
|
|
161
162
|
makeLogLine(50, "error msg"),
|
|
162
163
|
makeLogLine(60, "fatal msg"),
|
|
163
164
|
];
|
|
164
|
-
writeFileSync(join(tmpDir, "gateway-2026-05-04.
|
|
165
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.jsonl"), lines.join("\n"));
|
|
165
166
|
|
|
166
167
|
const config = makeConfig(tmpDir);
|
|
167
168
|
const handler = createLogTailHandler(config);
|
|
@@ -184,7 +185,7 @@ describe("log-tail handler", () => {
|
|
|
184
185
|
makeLogLine(30, "trust message", { module: "trust" }),
|
|
185
186
|
makeLogLine(30, "another mcp", { module: "mcp" }),
|
|
186
187
|
];
|
|
187
|
-
writeFileSync(join(tmpDir, "gateway-2026-05-04.
|
|
188
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.jsonl"), lines.join("\n"));
|
|
188
189
|
|
|
189
190
|
const config = makeConfig(tmpDir);
|
|
190
191
|
const handler = createLogTailHandler(config);
|
|
@@ -205,7 +206,7 @@ describe("log-tail handler", () => {
|
|
|
205
206
|
makeLogLine(30, "valid entry"),
|
|
206
207
|
"{broken json",
|
|
207
208
|
];
|
|
208
|
-
writeFileSync(join(tmpDir, "gateway-2026-05-04.
|
|
209
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.jsonl"), lines.join("\n"));
|
|
209
210
|
|
|
210
211
|
const config = makeConfig(tmpDir);
|
|
211
212
|
const handler = createLogTailHandler(config);
|
|
@@ -225,7 +226,7 @@ describe("log-tail handler", () => {
|
|
|
225
226
|
makeLogLine(30, "entry 3"),
|
|
226
227
|
makeLogLine(30, "entry 4"),
|
|
227
228
|
];
|
|
228
|
-
writeFileSync(join(tmpDir, "gateway-2026-05-04.
|
|
229
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.jsonl"), lines.join("\n"));
|
|
229
230
|
|
|
230
231
|
const config = makeConfig(tmpDir);
|
|
231
232
|
const handler = createLogTailHandler(config);
|
|
@@ -244,7 +245,7 @@ describe("log-tail handler", () => {
|
|
|
244
245
|
makeLogLine(30, "entry 2"),
|
|
245
246
|
makeLogLine(30, "entry 3"),
|
|
246
247
|
];
|
|
247
|
-
writeFileSync(join(tmpDir, "gateway-2026-05-04.
|
|
248
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.jsonl"), lines.join("\n"));
|
|
248
249
|
|
|
249
250
|
const config = makeConfig(tmpDir);
|
|
250
251
|
const handler = createLogTailHandler(config);
|
|
@@ -264,11 +265,11 @@ describe("log-tail handler", () => {
|
|
|
264
265
|
makeLogLine(30, "yesterday 2"),
|
|
265
266
|
makeLogLine(30, "yesterday 3"),
|
|
266
267
|
];
|
|
267
|
-
writeFileSync(join(tmpDir, "gateway-2026-05-03.
|
|
268
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-03.jsonl"), yesterdayLines.join("\n"));
|
|
268
269
|
|
|
269
270
|
// Today file: 1 entry
|
|
270
271
|
const todayLines = [makeLogLine(30, "today 1")];
|
|
271
|
-
writeFileSync(join(tmpDir, "gateway-2026-05-04.
|
|
272
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.jsonl"), todayLines.join("\n"));
|
|
272
273
|
|
|
273
274
|
const config = makeConfig(tmpDir);
|
|
274
275
|
const handler = createLogTailHandler(config);
|
|
@@ -296,7 +297,7 @@ describe("log-tail handler", () => {
|
|
|
296
297
|
test("n=1001 in querystring → returns successfully (clamped to 1000)", async () => {
|
|
297
298
|
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
298
299
|
|
|
299
|
-
writeFileSync(join(tmpDir, "gateway-2026-05-04.
|
|
300
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.jsonl"), makeLogLine(30, "entry"));
|
|
300
301
|
|
|
301
302
|
const config = makeConfig(tmpDir);
|
|
302
303
|
const handler = createLogTailHandler(config);
|
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import type { GatewayConfig } from "../../config.js";
|
|
5
|
-
import { getLogger } from "../../logger.js";
|
|
5
|
+
import { getLogger, LOG_FILE_JSON_PATTERN } from "../../logger.js";
|
|
6
6
|
|
|
7
7
|
const log = getLogger("log-tail");
|
|
8
8
|
|
|
@@ -18,7 +18,10 @@ const LEVEL_MAP: Record<LevelName, number> = {
|
|
|
18
18
|
fatal: 60,
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Server-side level/module filtering walks the JSONL sidecar produced by the
|
|
22
|
+
// logger. The default human-readable .log file is pino-pretty multi-line and
|
|
23
|
+
// not safely parseable line-by-line.
|
|
24
|
+
const LOG_FILE_PATTERN = LOG_FILE_JSON_PATTERN;
|
|
22
25
|
|
|
23
26
|
export function createLogTailHandler(
|
|
24
27
|
config: GatewayConfig,
|
package/src/http/routes/pair.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { KNOWN_EXTENSION_ORIGINS } from "../../chrome-extension-origins.js";
|
|
|
30
30
|
import { assistantDbQuery } from "../../db/assistant-db-proxy.js";
|
|
31
31
|
import { getLogger } from "../../logger.js";
|
|
32
32
|
import { isLoopbackAddress } from "../../util/is-loopback-address.js";
|
|
33
|
+
import { VELAY_FORWARDED_HEADER } from "../../velay/bridge-utils.js";
|
|
33
34
|
|
|
34
35
|
const log = getLogger("pair");
|
|
35
36
|
|
|
@@ -211,6 +212,13 @@ export async function handlePair(
|
|
|
211
212
|
});
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
// Defense-in-depth: reject Velay-bridged requests. The bridge injects this
|
|
216
|
+
// header on every forwarded request; it cannot be stripped by a Velay client.
|
|
217
|
+
if (req.headers.get(VELAY_FORWARDED_HEADER)) {
|
|
218
|
+
auditDeny(req, clientIp, "velay_bridged");
|
|
219
|
+
return errorResponse("FORBIDDEN", "endpoint is local-only", 403);
|
|
220
|
+
}
|
|
221
|
+
|
|
214
222
|
if (!clientIp || !isLoopbackAddress(clientIp)) {
|
|
215
223
|
auditDeny(req, clientIp, "non_loopback_peer");
|
|
216
224
|
return errorResponse("FORBIDDEN", "endpoint is local-only", 403);
|
|
@@ -152,20 +152,29 @@ export function createTwilioVoiceWebhookHandler(
|
|
|
152
152
|
// this number themselves, so disclosing their own name is expected.
|
|
153
153
|
const unverifiedStatuses = new Set(["unverified", "pending"]);
|
|
154
154
|
if (callerRecord && unverifiedStatuses.has(callerRecord.channel.status)) {
|
|
155
|
+
const isGuardian = callerRecord.contact.role === "guardian";
|
|
155
156
|
log.info(
|
|
156
157
|
{
|
|
157
158
|
callSid: params.CallSid,
|
|
158
159
|
contactId: callerRecord.contact.id,
|
|
159
160
|
channelStatus: callerRecord.channel.status,
|
|
161
|
+
isGuardian,
|
|
160
162
|
},
|
|
161
163
|
"Known-but-unverified caller — returning verification guidance TwiML",
|
|
162
164
|
);
|
|
163
165
|
const name = escapeXml(callerRecord.contact.displayName);
|
|
166
|
+
// Conditional guidance: only the guardian has direct access to the
|
|
167
|
+
// assistant's contacts page; other contacts must ask the guardian
|
|
168
|
+
// to (re)start a verification session for them.
|
|
169
|
+
const action = isGuardian
|
|
170
|
+
? `To verify, open your assistant's contacts page, click Verify next to the phone channel, ` +
|
|
171
|
+
`and follow the prompts. Then call back once the verification session is active.`
|
|
172
|
+
: `Please reach out to the account guardian to start a new verification session, ` +
|
|
173
|
+
`then call back once the verification session is active.`;
|
|
164
174
|
const twiml =
|
|
165
175
|
`<?xml version="1.0" encoding="UTF-8"?><Response>` +
|
|
166
176
|
`<Say>This number is registered as ${name}'s phone but has not been verified yet. ` +
|
|
167
|
-
|
|
168
|
-
`and follow the prompts. Then call back once the verification session is active.</Say>` +
|
|
177
|
+
`${action}</Say>` +
|
|
169
178
|
`</Response>`;
|
|
170
179
|
return new Response(twiml, { status: 200, headers: TWIML_HEADERS });
|
|
171
180
|
}
|
package/src/index.ts
CHANGED
|
@@ -106,6 +106,14 @@ import {
|
|
|
106
106
|
createBackupSnapshotHandler,
|
|
107
107
|
} from "./backup/backup-routes.js";
|
|
108
108
|
import { startBackupWorker } from "./backup/backup-worker.js";
|
|
109
|
+
import {
|
|
110
|
+
startVoiceApprovalSync,
|
|
111
|
+
stopVoiceApprovalSync,
|
|
112
|
+
} from "./verification/voice-approval-sync.js";
|
|
113
|
+
import {
|
|
114
|
+
startOutboundVoiceVerificationSync,
|
|
115
|
+
stopOutboundVoiceVerificationSync,
|
|
116
|
+
} from "./verification/outbound-voice-verification-sync.js";
|
|
109
117
|
import { createWorkspaceCommitProxyHandler } from "./http/routes/workspace-commit-proxy.js";
|
|
110
118
|
import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
|
|
111
119
|
import { createLogExportHandler } from "./http/routes/log-export.js";
|
|
@@ -417,7 +425,9 @@ async function main() {
|
|
|
417
425
|
|
|
418
426
|
const audioProxy = createAudioProxyHandler(config);
|
|
419
427
|
|
|
420
|
-
const backupDeps = {
|
|
428
|
+
const backupDeps = {
|
|
429
|
+
assistantRuntimeBaseUrl: config.assistantRuntimeBaseUrl,
|
|
430
|
+
};
|
|
421
431
|
const handleListBackups = createListBackupsHandler(backupDeps);
|
|
422
432
|
const handleCreateBackup = createBackupSnapshotHandler(backupDeps);
|
|
423
433
|
|
|
@@ -635,6 +645,13 @@ async function main() {
|
|
|
635
645
|
handler: (req, params) =>
|
|
636
646
|
contactsControlPlaneProxy.handleUpdateContactChannel(req, params[0]),
|
|
637
647
|
},
|
|
648
|
+
{
|
|
649
|
+
path: /^\/v1\/contact-channels\/([^/]+)\/verify$/,
|
|
650
|
+
method: "POST",
|
|
651
|
+
auth: "edge-guardian",
|
|
652
|
+
handler: (req, params) =>
|
|
653
|
+
contactsControlPlaneProxy.handleVerifyContactChannel(req, params[0]),
|
|
654
|
+
},
|
|
638
655
|
// ── Contacts/invites control plane ──
|
|
639
656
|
{
|
|
640
657
|
path: "/v1/contacts/invites",
|
|
@@ -671,7 +688,15 @@ async function main() {
|
|
|
671
688
|
{
|
|
672
689
|
// Keep DELETE on the invite collection unsupported; only /invites/:id
|
|
673
690
|
// should revoke an invite.
|
|
674
|
-
path: /^\/v1\/contacts\/(?!invites
|
|
691
|
+
path: /^\/v1\/contacts\/(?!invites\/?$)([^/]+)\/?$/,
|
|
692
|
+
method: "DELETE",
|
|
693
|
+
auth: "edge",
|
|
694
|
+
handler: (_req, params) =>
|
|
695
|
+
contactsControlPlaneProxy.handleDeleteContact(params[0]),
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
// Assistant-scoped variant for clients using the auto-prefix.
|
|
699
|
+
path: /^\/v1\/assistants\/[^/]+\/contacts\/(?!invites\/?$)([^/]+)\/?$/,
|
|
675
700
|
method: "DELETE",
|
|
676
701
|
auth: "edge",
|
|
677
702
|
handler: (_req, params) =>
|
|
@@ -707,8 +732,8 @@ async function main() {
|
|
|
707
732
|
path: "/v1/guardian/reset-bootstrap",
|
|
708
733
|
method: "POST",
|
|
709
734
|
auth: "none",
|
|
710
|
-
handler: (
|
|
711
|
-
channelVerificationSessionProxy.handleResetBootstrap(getClientIp()),
|
|
735
|
+
handler: (req, _params, getClientIp) =>
|
|
736
|
+
channelVerificationSessionProxy.handleResetBootstrap(getClientIp(), req),
|
|
712
737
|
},
|
|
713
738
|
{
|
|
714
739
|
path: "/v1/channel-verification-sessions",
|
|
@@ -1271,6 +1296,57 @@ async function main() {
|
|
|
1271
1296
|
auth: "edge",
|
|
1272
1297
|
handler: (req, params) => handleTrustRulesDelete(req, params[0]),
|
|
1273
1298
|
},
|
|
1299
|
+
|
|
1300
|
+
// ── Trust rules v3 — assistant-scoped variants ──
|
|
1301
|
+
// Mirror the flat /v1/trust-rules routes for clients that use
|
|
1302
|
+
// GatewayHTTPClient's auto-prefix (Swift TrustRuleClient and
|
|
1303
|
+
// vellum-assistant-platform's web/src/lib/trust-rules/api.ts), which build
|
|
1304
|
+
// URLs like /v1/assistants/<id>/trust-rules/. Without these, the request
|
|
1305
|
+
// falls through to the runtime-proxy catch-all and the daemon serves 404
|
|
1306
|
+
// on mutations (the daemon HTTP handlers were stripped by #28784).
|
|
1307
|
+
//
|
|
1308
|
+
// Trust rules are gateway-global, so the assistant id is matched and
|
|
1309
|
+
// discarded. Same precedent as the assistant-scoped /v1/assistants/.../
|
|
1310
|
+
// contacts DELETE route above.
|
|
1311
|
+
{
|
|
1312
|
+
path: /^\/v1\/assistants\/[^/]+\/trust-rules\/?$/,
|
|
1313
|
+
method: "GET",
|
|
1314
|
+
auth: "edge",
|
|
1315
|
+
handler: (req) => handleTrustRulesList(req),
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
// Must appear before the create entry and before the /:id catch-all
|
|
1319
|
+
// so the literal /suggest segment is matched first.
|
|
1320
|
+
path: /^\/v1\/assistants\/[^/]+\/trust-rules\/suggest\/?$/,
|
|
1321
|
+
method: "POST",
|
|
1322
|
+
auth: "edge",
|
|
1323
|
+
handler: (req) => handleTrustRulesSuggest(req),
|
|
1324
|
+
},
|
|
1325
|
+
{
|
|
1326
|
+
path: /^\/v1\/assistants\/[^/]+\/trust-rules\/?$/,
|
|
1327
|
+
method: "POST",
|
|
1328
|
+
auth: "edge",
|
|
1329
|
+
handler: (req) => handleTrustRulesCreate(req),
|
|
1330
|
+
},
|
|
1331
|
+
{
|
|
1332
|
+
// Reset must be registered before the /:id catch-all regex.
|
|
1333
|
+
path: /^\/v1\/assistants\/[^/]+\/trust-rules\/([^/]+)\/reset\/?$/,
|
|
1334
|
+
method: "POST",
|
|
1335
|
+
auth: "edge",
|
|
1336
|
+
handler: (req, params) => handleTrustRulesReset(req, params[0]),
|
|
1337
|
+
},
|
|
1338
|
+
{
|
|
1339
|
+
path: /^\/v1\/assistants\/[^/]+\/trust-rules\/([^/]+)\/?$/,
|
|
1340
|
+
method: "PATCH",
|
|
1341
|
+
auth: "edge",
|
|
1342
|
+
handler: (req, params) => handleTrustRulesUpdate(req, params[0]),
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
path: /^\/v1\/assistants\/[^/]+\/trust-rules\/([^/]+)\/?$/,
|
|
1346
|
+
method: "DELETE",
|
|
1347
|
+
auth: "edge",
|
|
1348
|
+
handler: (req, params) => handleTrustRulesDelete(req, params[0]),
|
|
1349
|
+
},
|
|
1274
1350
|
];
|
|
1275
1351
|
|
|
1276
1352
|
// Runtime proxy catch-all — must be last so specific routes are checked first.
|
|
@@ -1516,15 +1592,13 @@ async function main() {
|
|
|
1516
1592
|
|
|
1517
1593
|
// ── Route table dispatch ──
|
|
1518
1594
|
try {
|
|
1519
|
-
const response = router(req, url, resolveClientIp, svr);
|
|
1595
|
+
const response = await router(req, url, resolveClientIp, svr);
|
|
1520
1596
|
if (response !== null) {
|
|
1521
1597
|
if (extensionOrigin) {
|
|
1522
|
-
|
|
1523
|
-
return withExtensionCorsHeaders(resolved, extensionOrigin);
|
|
1598
|
+
return withExtensionCorsHeaders(response, extensionOrigin);
|
|
1524
1599
|
}
|
|
1525
1600
|
if (webviewOrigin) {
|
|
1526
|
-
|
|
1527
|
-
return withCorsHeaders(resolved, webviewOrigin);
|
|
1601
|
+
return withCorsHeaders(response, webviewOrigin);
|
|
1528
1602
|
}
|
|
1529
1603
|
return response;
|
|
1530
1604
|
}
|
|
@@ -1544,7 +1618,8 @@ async function main() {
|
|
|
1544
1618
|
headers: { "Retry-After": String(err.retryAfterSecs) },
|
|
1545
1619
|
},
|
|
1546
1620
|
);
|
|
1547
|
-
if (extensionOrigin)
|
|
1621
|
+
if (extensionOrigin)
|
|
1622
|
+
return withExtensionCorsHeaders(body, extensionOrigin);
|
|
1548
1623
|
return withCorsHeaders(body, webviewOrigin!);
|
|
1549
1624
|
}
|
|
1550
1625
|
log.error({ err }, "Unhandled gateway error");
|
|
@@ -1552,7 +1627,8 @@ async function main() {
|
|
|
1552
1627
|
{ error: "Internal server error" },
|
|
1553
1628
|
{ status: 500 },
|
|
1554
1629
|
);
|
|
1555
|
-
if (extensionOrigin)
|
|
1630
|
+
if (extensionOrigin)
|
|
1631
|
+
return withExtensionCorsHeaders(errBody, extensionOrigin);
|
|
1556
1632
|
return withCorsHeaders(errBody, webviewOrigin!);
|
|
1557
1633
|
}
|
|
1558
1634
|
|
|
@@ -1560,7 +1636,8 @@ async function main() {
|
|
|
1560
1636
|
{ error: "Not found", source: "gateway" },
|
|
1561
1637
|
{ status: 404 },
|
|
1562
1638
|
);
|
|
1563
|
-
if (extensionOrigin)
|
|
1639
|
+
if (extensionOrigin)
|
|
1640
|
+
return withExtensionCorsHeaders(notFound, extensionOrigin);
|
|
1564
1641
|
if (webviewOrigin) return withCorsHeaders(notFound, webviewOrigin);
|
|
1565
1642
|
return notFound;
|
|
1566
1643
|
}
|
|
@@ -1709,7 +1786,10 @@ async function main() {
|
|
|
1709
1786
|
sourceChannel: "slack",
|
|
1710
1787
|
externalUserId: normalized.event.actor.actorExternalId,
|
|
1711
1788
|
...(normalized.event.source.chatType === "im"
|
|
1712
|
-
? {
|
|
1789
|
+
? {
|
|
1790
|
+
externalChatId:
|
|
1791
|
+
normalized.event.message.conversationExternalId,
|
|
1792
|
+
}
|
|
1713
1793
|
: {}),
|
|
1714
1794
|
displayName: normalized.event.actor.displayName,
|
|
1715
1795
|
username: normalized.event.actor.username,
|
|
@@ -2049,6 +2129,9 @@ async function main() {
|
|
|
2049
2129
|
assistantRuntimeBaseUrl: config.assistantRuntimeBaseUrl,
|
|
2050
2130
|
});
|
|
2051
2131
|
|
|
2132
|
+
startVoiceApprovalSync();
|
|
2133
|
+
startOutboundVoiceVerificationSync();
|
|
2134
|
+
|
|
2052
2135
|
const featureFlagWatcher = new FeatureFlagWatcher();
|
|
2053
2136
|
featureFlagWatcher.start();
|
|
2054
2137
|
|
|
@@ -2095,6 +2178,8 @@ async function main() {
|
|
|
2095
2178
|
const shutdownTasks: Promise<void>[] = [];
|
|
2096
2179
|
sleepWakeDetector.stop();
|
|
2097
2180
|
backupWorkerHandle.stop();
|
|
2181
|
+
stopVoiceApprovalSync();
|
|
2182
|
+
stopOutboundVoiceVerificationSync();
|
|
2098
2183
|
credentialWatcher.stop();
|
|
2099
2184
|
configFileWatcher.stop();
|
|
2100
2185
|
avatarSyncWatcher.stop();
|
|
@@ -14,7 +14,12 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
IpcHandlerError,
|
|
19
|
+
IpcTransportError,
|
|
20
|
+
ipcCallAssistant,
|
|
21
|
+
ipcSuggestTrustRule,
|
|
22
|
+
} from "./assistant-client.js";
|
|
18
23
|
|
|
19
24
|
// ---------------------------------------------------------------------------
|
|
20
25
|
// Test infrastructure
|
|
@@ -22,10 +27,16 @@ import { ipcCallAssistant, ipcSuggestTrustRule } from "./assistant-client.js";
|
|
|
22
27
|
|
|
23
28
|
let server: Server | undefined;
|
|
24
29
|
let origWorkspaceDir: string | undefined;
|
|
30
|
+
let origAssistantIpcDir: string | undefined;
|
|
25
31
|
|
|
26
|
-
// Save and restore VELLUM_WORKSPACE_DIR
|
|
32
|
+
// Save and restore VELLUM_WORKSPACE_DIR + ASSISTANT_IPC_SOCKET_DIR around
|
|
33
|
+
// each test. The sandbox sets ASSISTANT_IPC_SOCKET_DIR, which would
|
|
34
|
+
// otherwise win over VELLUM_WORKSPACE_DIR in `resolveIpcSocketPath` and
|
|
35
|
+
// route requests to the real daemon socket instead of our test server.
|
|
27
36
|
beforeEach(() => {
|
|
28
37
|
origWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
|
|
38
|
+
origAssistantIpcDir = process.env.ASSISTANT_IPC_SOCKET_DIR;
|
|
39
|
+
delete process.env.ASSISTANT_IPC_SOCKET_DIR;
|
|
29
40
|
server = undefined;
|
|
30
41
|
});
|
|
31
42
|
|
|
@@ -36,6 +47,12 @@ afterEach(async () => {
|
|
|
36
47
|
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
if (origAssistantIpcDir !== undefined) {
|
|
51
|
+
process.env.ASSISTANT_IPC_SOCKET_DIR = origAssistantIpcDir;
|
|
52
|
+
} else {
|
|
53
|
+
delete process.env.ASSISTANT_IPC_SOCKET_DIR;
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
if (server) {
|
|
40
57
|
await new Promise<void>((resolve) => {
|
|
41
58
|
server!.close(() => resolve());
|
|
@@ -72,6 +89,19 @@ function sendError(socket: Socket, id: string, error: string): void {
|
|
|
72
89
|
socket.write(JSON.stringify({ id, error }) + "\n");
|
|
73
90
|
}
|
|
74
91
|
|
|
92
|
+
/** Send a handler-level error (with statusCode) over the socket. */
|
|
93
|
+
function sendHandlerError(
|
|
94
|
+
socket: Socket,
|
|
95
|
+
id: string,
|
|
96
|
+
error: string,
|
|
97
|
+
statusCode: number,
|
|
98
|
+
errorCode: string,
|
|
99
|
+
): void {
|
|
100
|
+
socket.write(
|
|
101
|
+
JSON.stringify({ id, error, statusCode, errorCode }) + "\n",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
/**
|
|
76
106
|
* Start an in-process NDJSON server that reads one request and calls
|
|
77
107
|
* `handler` with the parsed method, params, and socket.
|
|
@@ -131,14 +161,15 @@ describe("ipcCallAssistant", () => {
|
|
|
131
161
|
expect(result).toEqual(expectedResult);
|
|
132
162
|
});
|
|
133
163
|
|
|
134
|
-
test("
|
|
164
|
+
test("throws IpcTransportError when the socket does not exist", async () => {
|
|
135
165
|
setupWorkspace();
|
|
136
166
|
// No server started — socket file does not exist
|
|
137
|
-
|
|
138
|
-
|
|
167
|
+
await expect(ipcCallAssistant("test_method")).rejects.toBeInstanceOf(
|
|
168
|
+
IpcTransportError,
|
|
169
|
+
);
|
|
139
170
|
});
|
|
140
171
|
|
|
141
|
-
test("
|
|
172
|
+
test("throws IpcTransportError when server returns an error without statusCode", async () => {
|
|
142
173
|
const sockPath = setupWorkspace();
|
|
143
174
|
|
|
144
175
|
await startServer(sockPath, (id, _method, _params, socket) => {
|
|
@@ -146,8 +177,29 @@ describe("ipcCallAssistant", () => {
|
|
|
146
177
|
socket.end();
|
|
147
178
|
});
|
|
148
179
|
|
|
149
|
-
|
|
150
|
-
|
|
180
|
+
await expect(ipcCallAssistant("failing_method")).rejects.toBeInstanceOf(
|
|
181
|
+
IpcTransportError,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("throws IpcHandlerError when server returns error with statusCode", async () => {
|
|
186
|
+
const sockPath = setupWorkspace();
|
|
187
|
+
|
|
188
|
+
await startServer(sockPath, (id, _method, _params, socket) => {
|
|
189
|
+
sendHandlerError(socket, id, "Not found", 404, "NOT_FOUND");
|
|
190
|
+
socket.end();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const promise = ipcCallAssistant("failing_method");
|
|
194
|
+
await expect(promise).rejects.toBeInstanceOf(IpcHandlerError);
|
|
195
|
+
try {
|
|
196
|
+
await promise;
|
|
197
|
+
} catch (err) {
|
|
198
|
+
const handlerErr = err as IpcHandlerError;
|
|
199
|
+
expect(handlerErr.message).toBe("Not found");
|
|
200
|
+
expect(handlerErr.statusCode).toBe(404);
|
|
201
|
+
expect(handlerErr.code).toBe("NOT_FOUND");
|
|
202
|
+
}
|
|
151
203
|
});
|
|
152
204
|
|
|
153
205
|
test("passes method and params to the server", async () => {
|
|
@@ -227,7 +279,7 @@ describe("ipcSuggestTrustRule", () => {
|
|
|
227
279
|
expect(receivedMethod).toBe("suggest_trust_rule");
|
|
228
280
|
});
|
|
229
281
|
|
|
230
|
-
test("
|
|
282
|
+
test("propagates IpcTransportError when the assistant returns an error field", async () => {
|
|
231
283
|
const sockPath = setupWorkspace();
|
|
232
284
|
|
|
233
285
|
await startServer(sockPath, (id, _method, _params, socket) => {
|
|
@@ -235,8 +287,8 @@ describe("ipcSuggestTrustRule", () => {
|
|
|
235
287
|
socket.end();
|
|
236
288
|
});
|
|
237
289
|
|
|
238
|
-
await expect(ipcSuggestTrustRule(validRequest)).rejects.
|
|
239
|
-
|
|
290
|
+
await expect(ipcSuggestTrustRule(validRequest)).rejects.toBeInstanceOf(
|
|
291
|
+
IpcTransportError,
|
|
240
292
|
);
|
|
241
293
|
});
|
|
242
294
|
|
|
@@ -279,12 +331,12 @@ describe("ipcSuggestTrustRule", () => {
|
|
|
279
331
|
);
|
|
280
332
|
});
|
|
281
333
|
|
|
282
|
-
test("
|
|
334
|
+
test("propagates IpcTransportError when the socket is unavailable", async () => {
|
|
283
335
|
setupWorkspace();
|
|
284
|
-
// No server — socket does not exist, ipcCallAssistant
|
|
336
|
+
// No server — socket does not exist, ipcCallAssistant throws IpcTransportError.
|
|
285
337
|
|
|
286
|
-
await expect(ipcSuggestTrustRule(validRequest)).rejects.
|
|
287
|
-
|
|
338
|
+
await expect(ipcSuggestTrustRule(validRequest)).rejects.toBeInstanceOf(
|
|
339
|
+
IpcTransportError,
|
|
288
340
|
);
|
|
289
341
|
});
|
|
290
342
|
});
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
|
|
16
16
|
import { connect, type Socket } from "node:net";
|
|
17
17
|
|
|
18
|
-
import { getLogger } from "../logger.js";
|
|
19
18
|
import type { ScopeOption, DirectoryScopeOption } from "../risk/risk-types.js";
|
|
20
19
|
import { resolveIpcSocketPath } from "./socket-path.js";
|
|
21
20
|
|
|
@@ -49,8 +48,8 @@ interface IpcResponse {
|
|
|
49
48
|
// ---------------------------------------------------------------------------
|
|
50
49
|
|
|
51
50
|
/**
|
|
52
|
-
* Error thrown by {@link
|
|
53
|
-
*
|
|
51
|
+
* Error thrown by {@link ipcCallAssistant} when the daemon returns a
|
|
52
|
+
* handler-level error (e.g. a RouteError with statusCode).
|
|
54
53
|
*/
|
|
55
54
|
export class IpcHandlerError extends Error {
|
|
56
55
|
readonly statusCode: number;
|
|
@@ -65,8 +64,8 @@ export class IpcHandlerError extends Error {
|
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
/**
|
|
68
|
-
* Error thrown by {@link
|
|
69
|
-
*
|
|
67
|
+
* Error thrown by {@link ipcCallAssistant} when the daemon is unreachable
|
|
68
|
+
* (socket error, timeout, closed before response).
|
|
70
69
|
*/
|
|
71
70
|
export class IpcTransportError extends Error {
|
|
72
71
|
constructor(message: string) {
|
|
@@ -87,124 +86,19 @@ function getAssistantSocketPath(): string {
|
|
|
87
86
|
// One-shot IPC call to the assistant
|
|
88
87
|
// ---------------------------------------------------------------------------
|
|
89
88
|
|
|
90
|
-
const log = getLogger("assistant-client");
|
|
91
|
-
|
|
92
89
|
/**
|
|
93
90
|
* One-shot IPC helper: connect to assistant.sock, call a method, disconnect.
|
|
94
91
|
*
|
|
95
|
-
* Returns `undefined` on any failure (socket not found, timeout, parse error)
|
|
96
|
-
* so callers can fall back gracefully. Uses a 30-second call timeout to
|
|
97
|
-
* accommodate LLM latency on the assistant side.
|
|
98
|
-
*/
|
|
99
|
-
export async function ipcCallAssistant(
|
|
100
|
-
method: string,
|
|
101
|
-
params?: Record<string, unknown>,
|
|
102
|
-
): Promise<unknown> {
|
|
103
|
-
const socketPath = getAssistantSocketPath();
|
|
104
|
-
|
|
105
|
-
return new Promise<unknown>((resolve) => {
|
|
106
|
-
let settled = false;
|
|
107
|
-
let callTimer: ReturnType<typeof setTimeout> | undefined;
|
|
108
|
-
|
|
109
|
-
const finish = (value: unknown) => {
|
|
110
|
-
if (settled) return;
|
|
111
|
-
settled = true;
|
|
112
|
-
clearTimeout(connectTimer);
|
|
113
|
-
if (callTimer) clearTimeout(callTimer);
|
|
114
|
-
socket.destroy();
|
|
115
|
-
resolve(value);
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const connectTimer = setTimeout(() => {
|
|
119
|
-
log.warn(
|
|
120
|
-
{ method, socketPath, timeoutMs: CONNECT_TIMEOUT_MS },
|
|
121
|
-
"Assistant IPC connect timed out",
|
|
122
|
-
);
|
|
123
|
-
finish(undefined);
|
|
124
|
-
}, CONNECT_TIMEOUT_MS);
|
|
125
|
-
|
|
126
|
-
const socket: Socket = connect(socketPath);
|
|
127
|
-
socket.unref();
|
|
128
|
-
|
|
129
|
-
let buffer = "";
|
|
130
|
-
const reqId = crypto.randomUUID();
|
|
131
|
-
|
|
132
|
-
socket.on("connect", () => {
|
|
133
|
-
clearTimeout(connectTimer);
|
|
134
|
-
const req: IpcRequest = { id: reqId, method, params };
|
|
135
|
-
socket.write(JSON.stringify(req) + "\n");
|
|
136
|
-
|
|
137
|
-
callTimer = setTimeout(() => {
|
|
138
|
-
log.warn(
|
|
139
|
-
{ method, socketPath, timeoutMs: CALL_TIMEOUT_MS },
|
|
140
|
-
"Assistant IPC call timed out waiting for response",
|
|
141
|
-
);
|
|
142
|
-
finish(undefined);
|
|
143
|
-
}, CALL_TIMEOUT_MS);
|
|
144
|
-
|
|
145
|
-
socket.on("data", (chunk) => {
|
|
146
|
-
buffer += chunk.toString();
|
|
147
|
-
let newlineIdx: number;
|
|
148
|
-
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
|
149
|
-
const line = buffer.slice(0, newlineIdx).trim();
|
|
150
|
-
buffer = buffer.slice(newlineIdx + 1);
|
|
151
|
-
if (!line) continue;
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
const msg = JSON.parse(line) as IpcResponse;
|
|
155
|
-
if (msg.id === reqId) {
|
|
156
|
-
if (msg.error) {
|
|
157
|
-
log.warn(
|
|
158
|
-
{ error: msg.error, method },
|
|
159
|
-
"Assistant IPC call returned error",
|
|
160
|
-
);
|
|
161
|
-
finish(undefined);
|
|
162
|
-
} else {
|
|
163
|
-
finish(msg.result);
|
|
164
|
-
}
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
} catch {
|
|
168
|
-
// Ignore malformed lines
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
socket.on("error", (err) => {
|
|
175
|
-
log.warn(
|
|
176
|
-
{
|
|
177
|
-
err: err instanceof Error ? err.message : String(err),
|
|
178
|
-
code: (err as NodeJS.ErrnoException).code ?? "unknown",
|
|
179
|
-
method,
|
|
180
|
-
socketPath,
|
|
181
|
-
},
|
|
182
|
-
"Assistant IPC socket error",
|
|
183
|
-
);
|
|
184
|
-
finish(undefined);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
socket.on("close", () => {
|
|
188
|
-
if (!settled) {
|
|
189
|
-
log.warn(
|
|
190
|
-
{ method, socketPath },
|
|
191
|
-
"Assistant IPC socket closed before response",
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
finish(undefined);
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Strict IPC call that distinguishes handler errors from transport failures.
|
|
201
|
-
*
|
|
202
92
|
* - On success: resolves with the result value.
|
|
203
|
-
* - On handler error (RouteError): throws {@link IpcHandlerError}
|
|
204
|
-
* statusCode and code.
|
|
205
|
-
* - On transport failure
|
|
93
|
+
* - On handler error (assistant RouteError): throws {@link IpcHandlerError}
|
|
94
|
+
* with statusCode and code.
|
|
95
|
+
* - On transport failure (socket not found, timeout, parse error, closed
|
|
96
|
+
* before response): throws {@link IpcTransportError}.
|
|
97
|
+
*
|
|
98
|
+
* Uses a 30-second call timeout to accommodate LLM latency on the
|
|
99
|
+
* assistant side.
|
|
206
100
|
*/
|
|
207
|
-
export async function
|
|
101
|
+
export async function ipcCallAssistant(
|
|
208
102
|
method: string,
|
|
209
103
|
params?: Record<string, unknown>,
|
|
210
104
|
): Promise<unknown> {
|