@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
@@ -168,9 +168,10 @@ export async function handleContactPromptSubmit(req: Request): Promise<Response>
168
168
  { channelType, address: normalizedAddress, contactId, existingContactId: existingChannel[0].contactId },
169
169
  "contact-prompt-submit: channel already assigned to another contact",
170
170
  );
171
- await ipcCallAssistant("resolve_contact_prompt", {
172
- body: { requestId, error: "Channel already assigned to another contact" },
173
- });
171
+ await notifyDaemonResolveError(
172
+ requestId,
173
+ "Channel already assigned to another contact",
174
+ );
174
175
  return Response.json(
175
176
  { accepted: false, error: "Channel already assigned to another contact" },
176
177
  { status: 409 },
@@ -223,9 +224,10 @@ export async function handleContactPromptSubmit(req: Request): Promise<Response>
223
224
  }
224
225
 
225
226
  // Notify daemon of failure so the CLI doesn't hang.
226
- await ipcCallAssistant("resolve_contact_prompt", {
227
- body: { requestId, error: "Failed to create contact channel" },
228
- });
227
+ await notifyDaemonResolveError(
228
+ requestId,
229
+ "Failed to create contact channel",
230
+ );
229
231
  return Response.json(
230
232
  { accepted: false, error: "Failed to create contact channel" },
231
233
  { status: 500 },
@@ -239,22 +241,49 @@ export async function handleContactPromptSubmit(req: Request): Promise<Response>
239
241
  }
240
242
  } catch (err) {
241
243
  log.error({ err, requestId }, "contact-prompt-submit: DB error");
242
- await ipcCallAssistant("resolve_contact_prompt", {
243
- body: { requestId, error: "Database error" },
244
- });
244
+ await notifyDaemonResolveError(requestId, "Database error");
245
245
  return Response.json({ accepted: false, error: "Database error" }, { status: 500 });
246
246
  }
247
247
 
248
248
  // Notify daemon to unblock the waiting contacts/prompt IPC call.
249
- const ipcResult = await ipcCallAssistant("resolve_contact_prompt", {
250
- body: { requestId, contactId, channelId, channelType, address: normalizedAddress },
251
- });
252
- if (!ipcResult || (ipcResult as { resolved?: boolean }).resolved === false) {
249
+ try {
250
+ const ipcResult = await ipcCallAssistant("resolve_contact_prompt", {
251
+ body: { requestId, contactId, channelId, channelType, address: normalizedAddress },
252
+ });
253
+ if ((ipcResult as { resolved?: boolean }).resolved === false) {
254
+ log.warn(
255
+ { requestId, contactId },
256
+ "contact-prompt-submit: resolve_contact_prompt IPC did not find a pending prompt — CLI may time out",
257
+ );
258
+ }
259
+ } catch (err) {
253
260
  log.warn(
254
- { requestId, contactId },
255
- "contact-prompt-submit: resolve_contact_prompt IPC did not find a pending prompt — CLI may time out",
261
+ { err, requestId, contactId },
262
+ "contact-prompt-submit: resolve_contact_prompt IPC failed — CLI may time out",
256
263
  );
257
264
  }
258
265
 
259
266
  return Response.json({ accepted: true });
260
267
  }
268
+
269
+ /**
270
+ * Best-effort notification to the daemon that a pending contact prompt has
271
+ * resolved with an error. Failures here must not block the HTTP response —
272
+ * the caller has already decided the request failed; we just want to wake
273
+ * the CLI up.
274
+ */
275
+ async function notifyDaemonResolveError(
276
+ requestId: string,
277
+ error: string,
278
+ ): Promise<void> {
279
+ try {
280
+ await ipcCallAssistant("resolve_contact_prompt", {
281
+ body: { requestId, error },
282
+ });
283
+ } catch (err) {
284
+ log.warn(
285
+ { err, requestId },
286
+ "contact-prompt-submit: resolve_contact_prompt error notification failed",
287
+ );
288
+ }
289
+ }
@@ -15,6 +15,7 @@ import {
15
15
  assistantDbRun,
16
16
  } from "../../db/assistant-db-proxy.js";
17
17
  import { getGatewayDb } from "../../db/connection.js";
18
+ import { ContactStore } from "../../db/contact-store.js";
18
19
  import { contacts } from "../../db/schema.js";
19
20
  import { fetchImpl } from "../../fetch.js";
20
21
  import { ipcCallAssistant } from "../../ipc/assistant-client.js";
@@ -22,6 +23,61 @@ import { getLogger } from "../../logger.js";
22
23
 
23
24
  const log = getLogger("contacts-control-plane-proxy");
24
25
 
26
+ // ---------------------------------------------------------------------------
27
+ // Validation constants (mirrored from assistant/src/runtime/routes/contact-routes.ts)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const VALID_CONTACT_TYPES = ["human", "assistant"] as const;
31
+ const VALID_ASSISTANT_SPECIES = ["vellum"] as const;
32
+ const VALID_CHANNEL_STATUSES = [
33
+ "active",
34
+ "pending",
35
+ "revoked",
36
+ "blocked",
37
+ "unverified",
38
+ ] as const;
39
+ const VALID_CHANNEL_POLICIES = ["allow", "deny", "escalate"] as const;
40
+
41
+ type ContactType = (typeof VALID_CONTACT_TYPES)[number];
42
+ type AssistantSpecies = (typeof VALID_ASSISTANT_SPECIES)[number];
43
+ type ChannelStatus = (typeof VALID_CHANNEL_STATUSES)[number];
44
+ type ChannelPolicy = (typeof VALID_CHANNEL_POLICIES)[number];
45
+
46
+ function isContactType(v: unknown): v is ContactType {
47
+ return VALID_CONTACT_TYPES.includes(v as ContactType);
48
+ }
49
+ function isAssistantSpecies(v: unknown): v is AssistantSpecies {
50
+ return VALID_ASSISTANT_SPECIES.includes(v as AssistantSpecies);
51
+ }
52
+ function isChannelStatus(v: unknown): v is ChannelStatus {
53
+ return VALID_CHANNEL_STATUSES.includes(v as ChannelStatus);
54
+ }
55
+ function isChannelPolicy(v: unknown): v is ChannelPolicy {
56
+ return VALID_CHANNEL_POLICIES.includes(v as ChannelPolicy);
57
+ }
58
+
59
+ /**
60
+ * Validate that metadata matches the expected shape for the given species.
61
+ * Mirrors `validateSpeciesMetadata` in `assistant/src/contacts/contact-store.ts`.
62
+ */
63
+ function validateSpeciesMetadata(
64
+ species: AssistantSpecies,
65
+ metadata: Record<string, unknown> | null | undefined,
66
+ ): string | null {
67
+ if (metadata == null) return null;
68
+
69
+ if (species === "vellum") {
70
+ if (typeof metadata.assistantId !== "string" || !metadata.assistantId) {
71
+ return 'Vellum assistant metadata requires a non-empty "assistantId" string';
72
+ }
73
+ if (typeof metadata.gatewayUrl !== "string" || !metadata.gatewayUrl) {
74
+ return 'Vellum assistant metadata requires a non-empty "gatewayUrl" string';
75
+ }
76
+ }
77
+
78
+ return null;
79
+ }
80
+
25
81
  export function createContactsControlPlaneProxyHandler(config: GatewayConfig) {
26
82
  async function forward(
27
83
  req: Request,
@@ -72,8 +128,240 @@ export function createContactsControlPlaneProxyHandler(config: GatewayConfig) {
72
128
  return forward(req, "/v1/contacts", url.search);
73
129
  },
74
130
 
131
+ /**
132
+ * POST /v1/contacts — gateway-native contact upsert.
133
+ *
134
+ * Writes to BOTH the gateway DB (auth/authz fields: id, displayName, role,
135
+ * principalId + channels) and the assistant DB (all fields including notes,
136
+ * userFile, contactType) so the daemon stays in sync during the migration
137
+ * transition period.
138
+ *
139
+ * Resolution order mirrors the assistant's upsertContact:
140
+ * 1. Match by `body.id` if provided.
141
+ * 2. Match by (type, address) on any provided channel.
142
+ * 3. Create a new contact with a generated id.
143
+ */
75
144
  async handleUpsertContact(req: Request): Promise<Response> {
76
- return forward(req, "/v1/contacts");
145
+ // ── Parse body ──────────────────────────────────────────────────
146
+ let body: Record<string, unknown>;
147
+ try {
148
+ body = (await req.json()) as Record<string, unknown>;
149
+ } catch {
150
+ return Response.json(
151
+ { error: { code: "BAD_REQUEST", message: "Invalid JSON body" } },
152
+ { status: 400 },
153
+ );
154
+ }
155
+
156
+ // ── Validate ────────────────────────────────────────────────────
157
+ if (
158
+ !body.displayName ||
159
+ typeof body.displayName !== "string" ||
160
+ !body.displayName.trim()
161
+ ) {
162
+ return Response.json(
163
+ {
164
+ error: {
165
+ code: "BAD_REQUEST",
166
+ message:
167
+ "displayName is required and must be a non-empty string",
168
+ },
169
+ },
170
+ { status: 400 },
171
+ );
172
+ }
173
+ const displayName = (body.displayName as string).trim();
174
+
175
+ if (body.contactType !== undefined && !isContactType(body.contactType)) {
176
+ return Response.json(
177
+ {
178
+ error: {
179
+ code: "BAD_REQUEST",
180
+ message: `Invalid contactType "${body.contactType}". Must be one of: ${VALID_CONTACT_TYPES.join(", ")}`,
181
+ },
182
+ },
183
+ { status: 400 },
184
+ );
185
+ }
186
+
187
+ const assistantMeta = body.assistantMetadata as
188
+ | { species?: unknown; metadata?: unknown }
189
+ | undefined;
190
+
191
+ if (body.contactType === "assistant") {
192
+ if (!assistantMeta) {
193
+ return Response.json(
194
+ {
195
+ error: {
196
+ code: "BAD_REQUEST",
197
+ message:
198
+ 'assistantMetadata is required when contactType is "assistant"',
199
+ },
200
+ },
201
+ { status: 400 },
202
+ );
203
+ }
204
+ if (!isAssistantSpecies(assistantMeta.species)) {
205
+ return Response.json(
206
+ {
207
+ error: {
208
+ code: "BAD_REQUEST",
209
+ message: `Invalid species "${assistantMeta.species}". Must be one of: ${VALID_ASSISTANT_SPECIES.join(", ")}`,
210
+ },
211
+ },
212
+ { status: 400 },
213
+ );
214
+ }
215
+ const speciesError = validateSpeciesMetadata(
216
+ assistantMeta.species,
217
+ assistantMeta.metadata as
218
+ | Record<string, unknown>
219
+ | null
220
+ | undefined,
221
+ );
222
+ if (speciesError) {
223
+ return Response.json(
224
+ { error: { code: "BAD_REQUEST", message: speciesError } },
225
+ { status: 400 },
226
+ );
227
+ }
228
+ }
229
+ if (body.contactType === "human" && assistantMeta) {
230
+ return Response.json(
231
+ {
232
+ error: {
233
+ code: "BAD_REQUEST",
234
+ message:
235
+ 'assistantMetadata must not be provided when contactType is "human"',
236
+ },
237
+ },
238
+ { status: 400 },
239
+ );
240
+ }
241
+ if (assistantMeta && !body.contactType) {
242
+ return Response.json(
243
+ {
244
+ error: {
245
+ code: "BAD_REQUEST",
246
+ message:
247
+ 'contactType must be "assistant" when assistantMetadata is provided',
248
+ },
249
+ },
250
+ { status: 400 },
251
+ );
252
+ }
253
+
254
+ type ChannelInput = {
255
+ type: string;
256
+ address: string;
257
+ isPrimary?: boolean;
258
+ externalUserId?: string | null;
259
+ externalChatId?: string | null;
260
+ status?: string;
261
+ policy?: string;
262
+ };
263
+
264
+ const channelInputs = body.channels as ChannelInput[] | undefined;
265
+ if (channelInputs !== undefined) {
266
+ if (!Array.isArray(channelInputs)) {
267
+ return Response.json(
268
+ {
269
+ error: {
270
+ code: "BAD_REQUEST",
271
+ message: "channels must be an array",
272
+ },
273
+ },
274
+ { status: 400 },
275
+ );
276
+ }
277
+ for (const ch of channelInputs) {
278
+ if (typeof ch?.type !== "string" || !ch.type.trim()) {
279
+ return Response.json(
280
+ {
281
+ error: {
282
+ code: "BAD_REQUEST",
283
+ message:
284
+ "channel.type is required and must be a non-empty string",
285
+ },
286
+ },
287
+ { status: 400 },
288
+ );
289
+ }
290
+ if (typeof ch?.address !== "string" || !ch.address.trim()) {
291
+ return Response.json(
292
+ {
293
+ error: {
294
+ code: "BAD_REQUEST",
295
+ message:
296
+ "channel.address is required and must be a non-empty string",
297
+ },
298
+ },
299
+ { status: 400 },
300
+ );
301
+ }
302
+ if (ch.status !== undefined && !isChannelStatus(ch.status)) {
303
+ return Response.json(
304
+ {
305
+ error: {
306
+ code: "BAD_REQUEST",
307
+ message: `Invalid channel status "${ch.status}". Must be one of: ${VALID_CHANNEL_STATUSES.join(", ")}`,
308
+ },
309
+ },
310
+ { status: 400 },
311
+ );
312
+ }
313
+ if (ch.policy !== undefined && !isChannelPolicy(ch.policy)) {
314
+ return Response.json(
315
+ {
316
+ error: {
317
+ code: "BAD_REQUEST",
318
+ message: `Invalid channel policy "${ch.policy}". Must be one of: ${VALID_CHANNEL_POLICIES.join(", ")}`,
319
+ },
320
+ },
321
+ { status: 400 },
322
+ );
323
+ }
324
+ }
325
+ }
326
+
327
+ // ── Service-layer write (gateway DB + assistant DB dual-write) ───
328
+ const store = new ContactStore();
329
+ const { contact, created } = await store.upsertContact({
330
+ id: body.id as string | undefined,
331
+ displayName,
332
+ role: body.role as string | undefined,
333
+ principalId: body.principalId as string | null | undefined,
334
+ notes: body.notes as string | null | undefined,
335
+ contactType: body.contactType as string | undefined,
336
+ assistantMetadata:
337
+ body.contactType === "assistant" && assistantMeta
338
+ ? {
339
+ species: assistantMeta.species as string,
340
+ metadata:
341
+ (assistantMeta.metadata as
342
+ | Record<string, unknown>
343
+ | null
344
+ | undefined) ?? null,
345
+ }
346
+ : undefined,
347
+ channels: channelInputs?.map((ch) => ({
348
+ type: ch.type,
349
+ address: ch.address,
350
+ isPrimary: ch.isPrimary,
351
+ externalUserId: ch.externalUserId ?? null,
352
+ externalChatId: ch.externalChatId ?? null,
353
+ status: ch.status,
354
+ policy: ch.policy,
355
+ })),
356
+ });
357
+
358
+ // ── Emit contacts_changed ────────────────────────────────────────
359
+ void ipcCallAssistant("emit_event", {
360
+ body: { kind: "contacts_changed" },
361
+ } as unknown as Record<string, unknown>).catch(() => {});
362
+
363
+ log.info({ contactId: contact.id, created }, "upsert_contact: handled natively");
364
+ return Response.json({ ok: true, contact });
77
365
  },
78
366
 
79
367
  async handleGetContact(req: Request, contactId: string): Promise<Response> {
@@ -103,7 +391,7 @@ export function createContactsControlPlaneProxyHandler(config: GatewayConfig) {
103
391
  getGatewayDb().delete(contacts).where(eq(contacts.id, contactId)).run();
104
392
  void ipcCallAssistant("emit_event", {
105
393
  body: { kind: "contacts_changed" },
106
- } as unknown as Record<string, unknown>);
394
+ } as unknown as Record<string, unknown>).catch(() => {});
107
395
  log.info({ contactId }, "delete_contact: deleted");
108
396
  return new Response(null, { status: 204 });
109
397
  },
@@ -119,6 +407,45 @@ export function createContactsControlPlaneProxyHandler(config: GatewayConfig) {
119
407
  return forward(req, `/v1/contact-channels/${contactChannelId}`);
120
408
  },
121
409
 
410
+ /**
411
+ * POST /v1/contact-channels/:id/verify — guardian-only manual verify.
412
+ *
413
+ * Gateway-native + dual-write: the channel mutation happens in the
414
+ * gateway DB first (source of truth); a best-effort mirror is written
415
+ * to the assistant DB so the daemon stays in sync during the
416
+ * gateway-security-migration transition period.
417
+ *
418
+ * Idempotent: a row that's already active+verifiedVia=manual returns
419
+ * the same shape (200 with channel) but no second write occurs.
420
+ */
421
+ async handleVerifyContactChannel(
422
+ _req: Request,
423
+ contactChannelId: string,
424
+ ): Promise<Response> {
425
+ const result =
426
+ await new ContactStore().markChannelVerified(contactChannelId);
427
+ if (!result) {
428
+ return Response.json(
429
+ {
430
+ error: {
431
+ code: "NOT_FOUND",
432
+ message: `Channel "${contactChannelId}" not found`,
433
+ },
434
+ },
435
+ { status: 404 },
436
+ );
437
+ }
438
+ log.info(
439
+ {
440
+ contactChannelId,
441
+ didWrite: result.didWrite,
442
+ status: result.channel.status,
443
+ },
444
+ "manual_verify: channel attested verified by guardian",
445
+ );
446
+ return Response.json({ ok: true, channel: result.channel });
447
+ },
448
+
122
449
  // ── Invite routes ──
123
450
  async handleListInvites(req: Request): Promise<Response> {
124
451
  const url = new URL(req.url);
@@ -5,6 +5,7 @@ export type ContactsControlPlaneRoute =
5
5
  | { kind: "deleteContact"; contactId: string }
6
6
  | { kind: "mergeContacts" }
7
7
  | { kind: "updateContactChannel"; contactChannelId: string }
8
+ | { kind: "verifyContactChannel"; contactChannelId: string }
8
9
  | { kind: "listInvites" }
9
10
  | { kind: "createInvite" }
10
11
  | { kind: "redeemInvite" }
@@ -26,6 +27,17 @@ export function matchContactsControlPlaneRoute(
26
27
  return { kind: "mergeContacts" };
27
28
  }
28
29
 
30
+ // Channel verify (must come before the bare channel match)
31
+ const channelVerifyMatch = pathname.match(
32
+ /^\/v1\/contact-channels\/([^/]+)\/verify$/,
33
+ );
34
+ if (channelVerifyMatch && method === "POST") {
35
+ return {
36
+ kind: "verifyContactChannel",
37
+ contactChannelId: channelVerifyMatch[1],
38
+ };
39
+ }
40
+
29
41
  // Channel status/policy updates
30
42
  const channelMatch = pathname.match(/^\/v1\/contact-channels\/([^/]+)$/);
31
43
  if (channelMatch && method === "PATCH") {
@@ -31,39 +31,38 @@ class MockIpcTransportError extends Error {
31
31
  }
32
32
  }
33
33
 
34
- // ipcCallAssistant — used by refreshRouteSchema to prime the cache
35
- const ipcCallAssistantMock = mock(() =>
36
- Promise.resolve([
37
- { operationId: "health", endpoint: "health", method: "GET" },
38
- { operationId: "acp_steer", endpoint: "acp/:id/steer", method: "POST" },
39
- {
40
- operationId: "acp_list_sessions",
41
- endpoint: "acp/sessions",
42
- method: "GET",
43
- },
44
- {
45
- operationId: "apps_dist_file",
46
- endpoint: "apps/:appId/dist/:filename",
47
- method: "GET",
48
- },
49
- // Policy-enforced routes (policies resolved gateway-side)
50
- { operationId: "settings_get", endpoint: "settings", method: "GET" },
51
- { operationId: "calls_start", endpoint: "calls/start", method: "POST" },
52
- ]),
53
- );
34
+ // Single mock for `ipcCallAssistant` — used by both `refreshRouteSchema`
35
+ // (to prime the cache) and `tryIpcProxy` (per-request IPC calls).
36
+ const ROUTE_SCHEMA = [
37
+ { operationId: "health", endpoint: "health", method: "GET" },
38
+ { operationId: "acp_steer", endpoint: "acp/:id/steer", method: "POST" },
39
+ {
40
+ operationId: "acp_list_sessions",
41
+ endpoint: "acp/sessions",
42
+ method: "GET",
43
+ },
44
+ {
45
+ operationId: "apps_dist_file",
46
+ endpoint: "apps/:appId/dist/:filename",
47
+ method: "GET",
48
+ },
49
+ // Policy-enforced routes (policies resolved gateway-side)
50
+ { operationId: "settings_get", endpoint: "settings", method: "GET" },
51
+ { operationId: "calls_start", endpoint: "calls/start", method: "POST" },
52
+ ];
53
+
54
+ const defaultIpcImpl = (
55
+ method: string,
56
+ _params?: Record<string, unknown>,
57
+ ): Promise<unknown> => {
58
+ if (method === "get_route_schema") return Promise.resolve(ROUTE_SCHEMA);
59
+ return Promise.resolve({ ok: true });
60
+ };
54
61
 
55
- // ipcCallAssistantStrict used by tryIpcProxy for actual IPC calls
56
- const ipcCallAssistantStrictMock = mock(
57
- (_method: string, _params?: Record<string, unknown>) =>
58
- Promise.resolve({ ok: true }),
59
- );
62
+ const ipcCallAssistantMock = mock(defaultIpcImpl);
60
63
 
61
- // Single mock.module for assistant-client — must include ALL exports
62
- // that any transitive import needs (route-schema-cache uses ipcCallAssistant,
63
- // ipc-runtime-proxy uses ipcCallAssistantStrict + error classes).
64
64
  mock.module("../../ipc/assistant-client.js", () => ({
65
65
  ipcCallAssistant: ipcCallAssistantMock,
66
- ipcCallAssistantStrict: ipcCallAssistantStrictMock,
67
66
  IpcHandlerError: MockIpcHandlerError,
68
67
  IpcTransportError: MockIpcTransportError,
69
68
  }));
@@ -196,10 +195,8 @@ describe("matchRoute", () => {
196
195
 
197
196
  describe("tryIpcProxy", () => {
198
197
  beforeEach(() => {
199
- ipcCallAssistantStrictMock.mockReset();
200
- ipcCallAssistantStrictMock.mockImplementation(() =>
201
- Promise.resolve({ ok: true }),
202
- );
198
+ ipcCallAssistantMock.mockReset();
199
+ ipcCallAssistantMock.mockImplementation(defaultIpcImpl);
203
200
  validateEdgeTokenMock.mockReset();
204
201
  validateEdgeTokenMock.mockImplementation(() => ({
205
202
  ok: true,
@@ -241,8 +238,8 @@ describe("tryIpcProxy", () => {
241
238
  expect(result).not.toBeNull();
242
239
  expect(result!.status).toBe(200);
243
240
 
244
- expect(ipcCallAssistantStrictMock).toHaveBeenCalledTimes(1);
245
- const [opId, params] = ipcCallAssistantStrictMock.mock.calls[0] as [
241
+ expect(ipcCallAssistantMock).toHaveBeenCalledTimes(1);
242
+ const [opId, params] = ipcCallAssistantMock.mock.calls[0] as [
246
243
  string,
247
244
  Record<string, unknown>,
248
245
  ];
@@ -263,7 +260,7 @@ describe("tryIpcProxy", () => {
263
260
 
264
261
  await tryIpcProxy(req, makeConfig());
265
262
 
266
- const [, params] = ipcCallAssistantStrictMock.mock.calls[0] as [
263
+ const [, params] = ipcCallAssistantMock.mock.calls[0] as [
267
264
  string,
268
265
  Record<string, unknown>,
269
266
  ];
@@ -306,7 +303,7 @@ describe("tryIpcProxy", () => {
306
303
  });
307
304
 
308
305
  test("returns handler error status code from IpcHandlerError", async () => {
309
- ipcCallAssistantStrictMock.mockImplementation(() => {
306
+ ipcCallAssistantMock.mockImplementation(() => {
310
307
  throw new MockIpcHandlerError("Not found", 404, "NOT_FOUND");
311
308
  });
312
309
 
@@ -320,7 +317,7 @@ describe("tryIpcProxy", () => {
320
317
  });
321
318
 
322
319
  test("returns 502 on transport error", async () => {
323
- ipcCallAssistantStrictMock.mockImplementation(() => {
320
+ ipcCallAssistantMock.mockImplementation(() => {
324
321
  throw new MockIpcTransportError("Socket closed");
325
322
  });
326
323
 
@@ -333,7 +330,7 @@ describe("tryIpcProxy", () => {
333
330
  const req = makeRequest("/v1/acp/sessions?limit=10&offset=5");
334
331
  await tryIpcProxy(req, makeConfig());
335
332
 
336
- const [, params] = ipcCallAssistantStrictMock.mock.calls[0] as [
333
+ const [, params] = ipcCallAssistantMock.mock.calls[0] as [
337
334
  string,
338
335
  Record<string, unknown>,
339
336
  ];
@@ -347,10 +344,8 @@ describe("tryIpcProxy", () => {
347
344
 
348
345
  describe("policy enforcement", () => {
349
346
  beforeEach(() => {
350
- ipcCallAssistantStrictMock.mockReset();
351
- ipcCallAssistantStrictMock.mockImplementation(() =>
352
- Promise.resolve({ ok: true }),
353
- );
347
+ ipcCallAssistantMock.mockReset();
348
+ ipcCallAssistantMock.mockImplementation(defaultIpcImpl);
354
349
  validateEdgeTokenMock.mockReset();
355
350
  });
356
351
 
@@ -22,9 +22,9 @@ import { validateEdgeToken } from "../../auth/token-exchange.js";
22
22
  import type { TokenClaims } from "../../auth/types.js";
23
23
  import type { GatewayConfig } from "../../config.js";
24
24
  import {
25
- ipcCallAssistantStrict,
26
25
  IpcHandlerError,
27
26
  IpcTransportError,
27
+ ipcCallAssistant,
28
28
  } from "../../ipc/assistant-client.js";
29
29
  import { matchRoute } from "../../ipc/route-schema-cache.js";
30
30
  import { getLogger } from "../../logger.js";
@@ -160,7 +160,7 @@ export async function tryIpcProxy(
160
160
 
161
161
  // --- Call daemon via IPC ------------------------------------------------
162
162
  try {
163
- const result = await ipcCallAssistantStrict(match.operationId, params);
163
+ const result = await ipcCallAssistant(match.operationId, params);
164
164
 
165
165
  const duration = Math.round(performance.now() - start);
166
166
  log.info(
@@ -63,6 +63,7 @@ mock.module("../../logger.js", () => {
63
63
  getLogger: () => noopLogger,
64
64
  initLogger: noop,
65
65
  LOG_FILE_PATTERN: /^gateway-(\d{4}-\d{2}-\d{2})\.log$/,
66
+ LOG_FILE_JSON_PATTERN: /^gateway-(\d{4}-\d{2}-\d{2})\.jsonl$/,
66
67
  };
67
68
  });
68
69
 
@@ -30,7 +30,11 @@ import { fetchCesLogExport } from "@vellumai/ces-client/http-log-export";
30
30
  import { mintServiceToken } from "../../auth/token-exchange.js";
31
31
  import type { GatewayConfig } from "../../config.js";
32
32
  import { fetchImpl } from "../../fetch.js";
33
- import { getLogger, LOG_FILE_PATTERN } from "../../logger.js";
33
+ import {
34
+ getLogger,
35
+ LOG_FILE_JSON_PATTERN,
36
+ LOG_FILE_PATTERN,
37
+ } from "../../logger.js";
34
38
 
35
39
  const log = getLogger("log-export");
36
40
 
@@ -257,7 +261,10 @@ async function collectGatewayLogs(
257
261
  let totalBytes = 0;
258
262
 
259
263
  for (const name of entries) {
260
- const match = LOG_FILE_PATTERN.exec(name);
264
+ // Include both the pretty .log and the JSONL sidecar so support bundles
265
+ // give humans something readable and tooling something parseable.
266
+ const match =
267
+ LOG_FILE_PATTERN.exec(name) ?? LOG_FILE_JSON_PATTERN.exec(name);
261
268
  if (!match) continue;
262
269
 
263
270
  const fileDateStr = match[1];