@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.
Files changed (63) hide show
  1. package/AGENTS.md +10 -0
  2. package/Dockerfile +4 -2
  3. package/bun.lock +8 -1
  4. package/knip.json +1 -0
  5. package/package.json +2 -1
  6. package/src/__tests__/contact-prompt-submit.test.ts +5 -5
  7. package/src/__tests__/contact-store-mark-channel-verified.test.ts +177 -0
  8. package/src/__tests__/contacts-control-plane-proxy.test.ts +297 -6
  9. package/src/__tests__/contacts-control-plane-route-match.test.ts +16 -0
  10. package/src/__tests__/edge-auth.test.ts +253 -0
  11. package/src/__tests__/edge-guardian-auth.test.ts +198 -0
  12. package/src/__tests__/ipc-route-policy-coverage.test.ts +297 -0
  13. package/src/__tests__/ipc-route-policy.test.ts +43 -0
  14. package/src/__tests__/ipc-server-watchdog.test.ts +189 -0
  15. package/src/__tests__/live-voice-websocket.test.ts +1 -1
  16. package/src/__tests__/slack-normalize.test.ts +132 -0
  17. package/src/auth/guardian-bootstrap.ts +3 -1
  18. package/src/auth/ipc-route-policy.ts +34 -0
  19. package/src/db/assistant-db-proxy.ts +76 -7
  20. package/src/db/contact-store.ts +767 -1
  21. package/src/db/schema.ts +29 -0
  22. package/src/feature-flag-registry.json +17 -17
  23. package/src/handlers/handle-inbound.ts +9 -23
  24. package/src/http/middleware/auth.ts +193 -40
  25. package/src/http/router.ts +26 -4
  26. package/src/http/routes/channel-verification-session-proxy.ts +53 -6
  27. package/src/http/routes/contact-prompt.ts +44 -15
  28. package/src/http/routes/contacts-control-plane-proxy.ts +329 -2
  29. package/src/http/routes/contacts-control-plane-route-match.ts +12 -0
  30. package/src/http/routes/ipc-runtime-proxy.test.ts +38 -43
  31. package/src/http/routes/ipc-runtime-proxy.ts +2 -2
  32. package/src/http/routes/log-export.test.ts +1 -0
  33. package/src/http/routes/log-export.ts +9 -2
  34. package/src/http/routes/log-tail.test.ts +10 -9
  35. package/src/http/routes/log-tail.ts +5 -2
  36. package/src/http/routes/pair.ts +8 -0
  37. package/src/http/routes/twilio-voice-webhook.ts +11 -2
  38. package/src/index.ts +98 -13
  39. package/src/ipc/assistant-client.test.ts +67 -15
  40. package/src/ipc/assistant-client.ts +12 -118
  41. package/src/ipc/risk-classification-handlers.test.ts +76 -0
  42. package/src/ipc/risk-classification-handlers.ts +20 -9
  43. package/src/ipc/server.ts +113 -46
  44. package/src/logger.ts +71 -17
  45. package/src/post-assistant-ready.ts +9 -3
  46. package/src/risk/bash-risk-classifier.test.ts +106 -0
  47. package/src/risk/bash-risk-classifier.ts +19 -15
  48. package/src/risk/command-registry/commands/assistant.ts +75 -26
  49. package/src/risk/command-registry.test.ts +3 -1
  50. package/src/risk/shell-parser.test.ts +159 -0
  51. package/src/risk/shell-parser.ts +150 -19
  52. package/src/runtime/client.ts +0 -11
  53. package/src/schema.ts +32 -0
  54. package/src/slack/normalize.test.ts +5 -0
  55. package/src/slack/normalize.ts +7 -0
  56. package/src/velay/bridge-utils.ts +10 -0
  57. package/src/velay/client.test.ts +156 -0
  58. package/src/velay/client.ts +83 -0
  59. package/src/velay/http-bridge.test.ts +29 -0
  60. package/src/velay/http-bridge.ts +7 -0
  61. package/src/velay/protocol.ts +12 -1
  62. package/src/verification/outbound-voice-verification-sync.ts +171 -0
  63. 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.log"), lines.join("\n"));
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.log"), lines.join("\n"));
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.log"), lines.join("\n"));
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.log"), lines.join("\n"));
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.log"), lines.join("\n"));
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.log"), lines.join("\n"));
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.log"), yesterdayLines.join("\n"));
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.log"), todayLines.join("\n"));
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.log"), makeLogLine(30, "entry"));
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
- const LOG_FILE_PATTERN = /^gateway-\d{4}-\d{2}-\d{2}\.log$/;
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,
@@ -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
- `To verify, open your assistant's contacts page, click Verify next to the phone channel, ` +
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 = { assistantRuntimeBaseUrl: config.assistantRuntimeBaseUrl };
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: (_req, _params, getClientIp) =>
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
- const resolved = await response;
1523
- return withExtensionCorsHeaders(resolved, extensionOrigin);
1598
+ return withExtensionCorsHeaders(response, extensionOrigin);
1524
1599
  }
1525
1600
  if (webviewOrigin) {
1526
- const resolved = await response;
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) return withExtensionCorsHeaders(body, 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) return withExtensionCorsHeaders(errBody, 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) return withExtensionCorsHeaders(notFound, 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
- ? { externalChatId: normalized.event.message.conversationExternalId }
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 { ipcCallAssistant, ipcSuggestTrustRule } from "./assistant-client.js";
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 around each test.
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("returns undefined when the socket does not exist", async () => {
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
- const result = await ipcCallAssistant("test_method");
138
- expect(result).toBeUndefined();
167
+ await expect(ipcCallAssistant("test_method")).rejects.toBeInstanceOf(
168
+ IpcTransportError,
169
+ );
139
170
  });
140
171
 
141
- test("returns undefined when server returns an error field", async () => {
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
- const result = await ipcCallAssistant("failing_method");
150
- expect(result).toBeUndefined();
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("throws when the assistant returns an error field", async () => {
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.toThrow(
239
- "ipcSuggestTrustRule: unexpected response shape",
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("throws when the socket is unavailable (assistant not running)", async () => {
334
+ test("propagates IpcTransportError when the socket is unavailable", async () => {
283
335
  setupWorkspace();
284
- // No server — socket does not exist, ipcCallAssistant returns undefined
336
+ // No server — socket does not exist, ipcCallAssistant throws IpcTransportError.
285
337
 
286
- await expect(ipcSuggestTrustRule(validRequest)).rejects.toThrow(
287
- "ipcSuggestTrustRule: unexpected response shape",
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 ipcCallAssistantStrict} when the daemon returns
53
- * a handler-level error (e.g. a RouteError with statusCode).
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 ipcCallAssistantStrict} when the daemon is
69
- * unreachable (socket error, timeout, closed before response).
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} with
204
- * statusCode and code.
205
- * - On transport failure: throws {@link IpcTransportError}.
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 ipcCallAssistantStrict(
101
+ export async function ipcCallAssistant(
208
102
  method: string,
209
103
  params?: Record<string, unknown>,
210
104
  ): Promise<unknown> {