@vellumai/vellum-gateway 0.5.7 → 0.5.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,6 +20,8 @@ mock.module("../fetch.js", () => ({
20
20
 
21
21
  let lockFileExists = false;
22
22
  let writtenLockFiles: string[] = [];
23
+ let consumedSecretsContent: string | null = null;
24
+ let writtenConsumedFiles: string[] = [];
23
25
 
24
26
  mock.module("node:fs", () => ({
25
27
  ...actualFs,
@@ -27,8 +29,20 @@ mock.module("node:fs", () => ({
27
29
  if (typeof p === "string" && p.endsWith("guardian-init.lock")) {
28
30
  return lockFileExists;
29
31
  }
32
+ if (typeof p === "string" && p.endsWith("guardian-init-consumed.json")) {
33
+ return consumedSecretsContent !== null;
34
+ }
30
35
  return actualFs.existsSync(p);
31
36
  },
37
+ readFileSync: (p: string, encoding?: BufferEncoding) => {
38
+ if (typeof p === "string" && p.endsWith("guardian-init-consumed.json")) {
39
+ if (consumedSecretsContent === null) {
40
+ throw new Error("ENOENT");
41
+ }
42
+ return consumedSecretsContent;
43
+ }
44
+ return actualFs.readFileSync(p, encoding as BufferEncoding);
45
+ },
32
46
  writeFileSync: (
33
47
  p: string,
34
48
  data: string | NodeJS.ArrayBufferView,
@@ -39,6 +53,11 @@ mock.module("node:fs", () => ({
39
53
  lockFileExists = true;
40
54
  return;
41
55
  }
56
+ if (typeof p === "string" && p.endsWith("guardian-init-consumed.json")) {
57
+ consumedSecretsContent = String(data);
58
+ writtenConsumedFiles.push(p);
59
+ return;
60
+ }
42
61
  return actualFs.writeFileSync(p, data, options);
43
62
  },
44
63
  }));
@@ -78,13 +97,16 @@ afterEach(() => {
78
97
  fetchMock = mock(async () => new Response());
79
98
  lockFileExists = false;
80
99
  writtenLockFiles = [];
100
+ consumedSecretsContent = null;
101
+ writtenConsumedFiles = [];
81
102
  });
82
103
 
83
104
  describe("guardian/init bootstrap secret", () => {
84
105
  test("rejects requests without secret when GUARDIAN_BOOTSTRAP_SECRET is set", async () => {
85
106
  process.env.GUARDIAN_BOOTSTRAP_SECRET = "test-secret-abc123";
86
107
  try {
87
- const handler = createChannelVerificationSessionProxyHandler(makeConfig());
108
+ const handler =
109
+ createChannelVerificationSessionProxyHandler(makeConfig());
88
110
  const res = await handler.handleGuardianInit(
89
111
  new Request("http://localhost:7830/v1/guardian/init", {
90
112
  method: "POST",
@@ -104,7 +126,8 @@ describe("guardian/init bootstrap secret", () => {
104
126
  test("rejects requests with wrong secret", async () => {
105
127
  process.env.GUARDIAN_BOOTSTRAP_SECRET = "test-secret-abc123";
106
128
  try {
107
- const handler = createChannelVerificationSessionProxyHandler(makeConfig());
129
+ const handler =
130
+ createChannelVerificationSessionProxyHandler(makeConfig());
108
131
  const res = await handler.handleGuardianInit(
109
132
  new Request("http://localhost:7830/v1/guardian/init", {
110
133
  method: "POST",
@@ -124,7 +147,7 @@ describe("guardian/init bootstrap secret", () => {
124
147
  }
125
148
  });
126
149
 
127
- test("accepts requests with correct secret", async () => {
150
+ test("accepts requests with correct secret and writes lock for single secret", async () => {
128
151
  process.env.GUARDIAN_BOOTSTRAP_SECRET = "test-secret-abc123";
129
152
  fetchMock = mock(async () => {
130
153
  return new Response(
@@ -134,7 +157,8 @@ describe("guardian/init bootstrap secret", () => {
134
157
  });
135
158
 
136
159
  try {
137
- const handler = createChannelVerificationSessionProxyHandler(makeConfig());
160
+ const handler =
161
+ createChannelVerificationSessionProxyHandler(makeConfig());
138
162
  const res = await handler.handleGuardianInit(
139
163
  new Request("http://localhost:7830/v1/guardian/init", {
140
164
  method: "POST",
@@ -147,6 +171,9 @@ describe("guardian/init bootstrap secret", () => {
147
171
  );
148
172
 
149
173
  expect(res.status).toBe(200);
174
+ // Single secret: consumed file written and lock file created immediately
175
+ expect(writtenConsumedFiles.length).toBe(1);
176
+ expect(writtenLockFiles.length).toBe(1);
150
177
  } finally {
151
178
  delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
152
179
  }
@@ -291,3 +318,189 @@ describe("guardian/init one-time-use lockfile", () => {
291
318
  expect(writtenLockFiles.length).toBe(0);
292
319
  });
293
320
  });
321
+
322
+ describe("guardian/init multi-secret consumption tracking", () => {
323
+ const SECRET_A = "secret-laptop-aaa";
324
+ const SECRET_B = "secret-remote-bbb";
325
+
326
+ function makeInitRequest(secret?: string): Request {
327
+ const headers: Record<string, string> = {
328
+ "Content-Type": "application/json",
329
+ };
330
+ if (secret) {
331
+ headers["x-bootstrap-secret"] = secret;
332
+ }
333
+ return new Request("http://localhost:7830/v1/guardian/init", {
334
+ method: "POST",
335
+ headers,
336
+ body: JSON.stringify({
337
+ platform: "cli",
338
+ deviceId: `device-${secret ?? "none"}`,
339
+ }),
340
+ });
341
+ }
342
+
343
+ test("first secret is consumed but lock is deferred until all secrets used", async () => {
344
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
345
+ fetchMock = mock(async () => {
346
+ return new Response(
347
+ JSON.stringify({ accessToken: "jwt-a", refreshToken: "rt-a" }),
348
+ { status: 200, headers: { "content-type": "application/json" } },
349
+ );
350
+ });
351
+
352
+ try {
353
+ const handler =
354
+ createChannelVerificationSessionProxyHandler(makeConfig());
355
+ const res = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
356
+
357
+ expect(res.status).toBe(200);
358
+ // Consumed file written, but lock file NOT yet created
359
+ expect(writtenConsumedFiles.length).toBe(1);
360
+ expect(writtenLockFiles.length).toBe(0);
361
+ } finally {
362
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
363
+ }
364
+ });
365
+
366
+ test("lock file written after all secrets consumed", async () => {
367
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
368
+ fetchMock = mock(async () => {
369
+ return new Response(
370
+ JSON.stringify({ accessToken: "jwt", refreshToken: "rt" }),
371
+ { status: 200, headers: { "content-type": "application/json" } },
372
+ );
373
+ });
374
+
375
+ try {
376
+ const handler =
377
+ createChannelVerificationSessionProxyHandler(makeConfig());
378
+
379
+ // First secret
380
+ const res1 = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
381
+ expect(res1.status).toBe(200);
382
+ expect(writtenLockFiles.length).toBe(0);
383
+
384
+ // Second secret
385
+ const res2 = await handler.handleGuardianInit(makeInitRequest(SECRET_B));
386
+ expect(res2.status).toBe(200);
387
+ expect(writtenLockFiles.length).toBe(1);
388
+ } finally {
389
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
390
+ }
391
+ });
392
+
393
+ test("reusing a consumed secret is rejected", async () => {
394
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
395
+ fetchMock = mock(async () => {
396
+ return new Response(
397
+ JSON.stringify({ accessToken: "jwt", refreshToken: "rt" }),
398
+ { status: 200, headers: { "content-type": "application/json" } },
399
+ );
400
+ });
401
+
402
+ try {
403
+ const handler =
404
+ createChannelVerificationSessionProxyHandler(makeConfig());
405
+
406
+ // Consume SECRET_A
407
+ const res1 = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
408
+ expect(res1.status).toBe(200);
409
+
410
+ // Try to reuse SECRET_A
411
+ const res2 = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
412
+ expect(res2.status).toBe(403);
413
+ const body = await res2.json();
414
+ expect(body.error).toBe("Bootstrap secret already used");
415
+ } finally {
416
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
417
+ }
418
+ });
419
+
420
+ test("all secrets rejected after full consumption and lock", async () => {
421
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
422
+ fetchMock = mock(async () => {
423
+ return new Response(
424
+ JSON.stringify({ accessToken: "jwt", refreshToken: "rt" }),
425
+ { status: 200, headers: { "content-type": "application/json" } },
426
+ );
427
+ });
428
+
429
+ try {
430
+ const handler =
431
+ createChannelVerificationSessionProxyHandler(makeConfig());
432
+
433
+ // Consume both
434
+ await handler.handleGuardianInit(makeInitRequest(SECRET_A));
435
+ await handler.handleGuardianInit(makeInitRequest(SECRET_B));
436
+ expect(writtenLockFiles.length).toBe(1);
437
+
438
+ // Any further attempt is rejected (lock file exists)
439
+ const res = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
440
+ expect(res.status).toBe(403);
441
+ const body = await res.json();
442
+ expect(body.error).toContain("already");
443
+ } finally {
444
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
445
+ }
446
+ });
447
+
448
+ test("concurrent requests with same secret rejected by in-flight guard", async () => {
449
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
450
+ let resolveProxy: (() => void) | undefined;
451
+ fetchMock = mock(async () => {
452
+ await new Promise<void>((resolve) => {
453
+ resolveProxy = resolve;
454
+ });
455
+ return new Response(
456
+ JSON.stringify({ accessToken: "jwt", refreshToken: "rt" }),
457
+ { status: 200, headers: { "content-type": "application/json" } },
458
+ );
459
+ });
460
+
461
+ try {
462
+ const handler =
463
+ createChannelVerificationSessionProxyHandler(makeConfig());
464
+
465
+ // GIVEN a first request with SECRET_A is in flight
466
+ const p1 = handler.handleGuardianInit(makeInitRequest(SECRET_A));
467
+
468
+ // WHEN a second request with the same SECRET_A arrives concurrently
469
+ const res2 = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
470
+
471
+ // THEN the second request is rejected
472
+ expect(res2.status).toBe(403);
473
+ const body = await res2.json();
474
+ expect(body.error).toBe("Bootstrap secret already used");
475
+
476
+ // AND the first request completes successfully once resolved
477
+ resolveProxy!();
478
+ const res1 = await p1;
479
+ expect(res1.status).toBe(200);
480
+ } finally {
481
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
482
+ }
483
+ });
484
+
485
+ test("consumed secret not recorded when upstream fails", async () => {
486
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
487
+ fetchMock = mock(async () => {
488
+ return new Response(JSON.stringify({ error: "Internal error" }), {
489
+ status: 500,
490
+ headers: { "content-type": "application/json" },
491
+ });
492
+ });
493
+
494
+ try {
495
+ const handler =
496
+ createChannelVerificationSessionProxyHandler(makeConfig());
497
+ const res = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
498
+
499
+ expect(res.status).toBe(500);
500
+ expect(writtenConsumedFiles.length).toBe(0);
501
+ expect(writtenLockFiles.length).toBe(0);
502
+ } finally {
503
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
504
+ }
505
+ });
506
+ });
@@ -1,5 +1,9 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { classifySlackError, isRetryable } from "../slack/errors.js";
2
+ import {
3
+ classifySlackError,
4
+ isRetryable,
5
+ getUserMessage,
6
+ } from "../slack/errors.js";
3
7
 
4
8
  describe("classifySlackError", () => {
5
9
  test("classifies auth errors", () => {
@@ -75,3 +79,83 @@ describe("isRetryable", () => {
75
79
  expect(isRetryable("channel_not_found")).toBe(false);
76
80
  });
77
81
  });
82
+
83
+ describe("getUserMessage", () => {
84
+ test("returns specific message for channel_not_found", () => {
85
+ expect(getUserMessage("channel_not_found")).toBe(
86
+ "I can't send messages to this channel. Please re-add me to the channel.",
87
+ );
88
+ });
89
+
90
+ test("returns specific message for not_in_channel", () => {
91
+ expect(getUserMessage("not_in_channel")).toBe(
92
+ "I need to be invited to this channel first. Please add me to the channel.",
93
+ );
94
+ });
95
+
96
+ test("returns specific message for missing_scope", () => {
97
+ expect(getUserMessage("missing_scope")).toBe(
98
+ "I don't have the required permissions. Please re-install the Slack app with the necessary scopes.",
99
+ );
100
+ });
101
+
102
+ test("returns specific message for token_revoked", () => {
103
+ expect(getUserMessage("token_revoked")).toBe(
104
+ "My Slack connection has expired. Please re-configure the Slack integration.",
105
+ );
106
+ });
107
+
108
+ test("returns specific message for token_expired", () => {
109
+ expect(getUserMessage("token_expired")).toBe(
110
+ "My Slack connection has expired. Please re-configure the Slack integration.",
111
+ );
112
+ });
113
+
114
+ test("returns specific message for invalid_auth", () => {
115
+ expect(getUserMessage("invalid_auth")).toBe(
116
+ "My Slack connection has expired. Please re-configure the Slack integration.",
117
+ );
118
+ });
119
+
120
+ test("returns specific message for is_archived", () => {
121
+ expect(getUserMessage("is_archived")).toBe(
122
+ "This channel has been archived. Please unarchive it or use a different channel.",
123
+ );
124
+ });
125
+
126
+ test("returns specific message for cannot_dm_bot", () => {
127
+ expect(getUserMessage("cannot_dm_bot")).toBe(
128
+ "I can't send direct messages to other bots.",
129
+ );
130
+ });
131
+
132
+ test("falls back to category message for auth errors without specific override", () => {
133
+ expect(getUserMessage("not_authed")).toBe(
134
+ "My Slack connection has expired. Please re-configure the Slack integration.",
135
+ );
136
+ expect(getUserMessage("account_inactive")).toBe(
137
+ "My Slack connection has expired. Please re-configure the Slack integration.",
138
+ );
139
+ });
140
+
141
+ test("falls back to category message for permission errors without specific override", () => {
142
+ expect(getUserMessage("ekm_access_denied")).toBe(
143
+ "I don't have the required permissions for this channel. Please check my access.",
144
+ );
145
+ expect(getUserMessage("restricted_action")).toBe(
146
+ "I don't have the required permissions for this channel. Please check my access.",
147
+ );
148
+ });
149
+
150
+ test("returns undefined for unknown errors", () => {
151
+ expect(getUserMessage("some_new_error")).toBeUndefined();
152
+ expect(getUserMessage(undefined)).toBeUndefined();
153
+ expect(getUserMessage("")).toBeUndefined();
154
+ });
155
+
156
+ test("returns message for rate_limit errors", () => {
157
+ expect(getUserMessage("rate_limited")).toBe(
158
+ "Slack rate limit reached. Please try again in a moment.",
159
+ );
160
+ });
161
+ });
@@ -142,12 +142,12 @@ export function getRootDir(): string {
142
142
  /**
143
143
  * Returns the workspace root for user-facing state.
144
144
  *
145
- * When the WORKSPACE_DIR env var is set, returns that value (used in
146
- * containerized deployments where the workspace is a separate volume).
147
- * Otherwise falls back to ~/.vellum/workspace.
145
+ * When VELLUM_WORKSPACE_DIR is set, returns that value (used in containerized
146
+ * deployments where the workspace is a separate volume). Otherwise falls back
147
+ * to ~/.vellum/workspace.
148
148
  */
149
149
  export function getWorkspaceDir(): string {
150
- const override = process.env.WORKSPACE_DIR?.trim();
150
+ const override = process.env.VELLUM_WORKSPACE_DIR?.trim();
151
151
  if (override) return override;
152
152
  return join(getRootDir(), "workspace");
153
153
  }
@@ -73,15 +73,27 @@ export class CredentialWatcher {
73
73
  // Watch the protected directory for store.key changes so that
74
74
  // creating or restoring the v2 store key triggers a credential reload.
75
75
  this.startWatcher(protectedDir, "store.key");
76
+
77
+ // Watch keys.enc for credential writes. When credentials are re-saved
78
+ // with the same values (e.g. in-chat credential_store re-entering
79
+ // existing tokens), the serialized credential values won't change —
80
+ // but the encrypted ciphertext will (new IV). Force a full reload so
81
+ // channel listeners restart even when the plaintext values match.
82
+ this.startWatcher(protectedDir, "keys.enc", { forceChanged: true });
76
83
  }
77
84
 
78
- private startWatcher(dir: string, targetFilename: string): void {
85
+ private startWatcher(
86
+ dir: string,
87
+ targetFilename: string,
88
+ opts?: { forceChanged?: boolean },
89
+ ): void {
90
+ const forceChanged = opts?.forceChanged ?? false;
79
91
  try {
80
92
  const watcher = watch(dir, { persistent: false }, (_event, filename) => {
81
93
  if (filename && filename !== targetFilename) {
82
94
  return;
83
95
  }
84
- this.scheduleCheck();
96
+ this.scheduleCheck(forceChanged);
85
97
  });
86
98
  this.watchers.push(watcher);
87
99
 
@@ -106,21 +118,28 @@ export class CredentialWatcher {
106
118
  this.watchers = [];
107
119
  }
108
120
 
109
- private scheduleCheck(): void {
121
+ /** Whether the next scheduled poll should treat all services as changed. */
122
+ private pendingForceChanged = false;
123
+
124
+ private scheduleCheck(forceChanged = false): void {
125
+ if (forceChanged) this.pendingForceChanged = true;
110
126
  if (this.debounceTimer) {
111
127
  clearTimeout(this.debounceTimer);
112
128
  }
113
129
  this.debounceTimer = setTimeout(() => {
114
130
  this.debounceTimer = null;
115
- void this.pollOnce();
131
+ const force = this.pendingForceChanged;
132
+ this.pendingForceChanged = false;
133
+ void this.pollOnce(force);
116
134
  }, DEBOUNCE_MS);
117
135
  }
118
136
 
119
- private async pollOnce(): Promise<void> {
137
+ private async pollOnce(forceChanged = false): Promise<void> {
120
138
  if (this.polling) {
121
139
  // A poll is already in flight — flag that another round is needed
122
140
  // so credential updates arriving mid-poll aren't silently dropped.
123
141
  this.pendingPoll = true;
142
+ if (forceChanged) this.pendingForceChanged = true;
124
143
  return;
125
144
  }
126
145
  this.polling = true;
@@ -141,7 +160,7 @@ export class CredentialWatcher {
141
160
  for (const [name, { creds }] of Object.entries(services)) {
142
161
  const newVal = creds ? JSON.stringify(creds) : undefined;
143
162
  const oldVal = this.lastSerialized.get(name);
144
- if (newVal !== oldVal) {
163
+ if (newVal !== oldVal || (forceChanged && newVal !== undefined)) {
145
164
  changedServices.add(name);
146
165
  if (newVal !== undefined) {
147
166
  this.lastSerialized.set(name, newVal);
@@ -167,7 +186,9 @@ export class CredentialWatcher {
167
186
  this.polling = false;
168
187
  if (this.pendingPoll) {
169
188
  this.pendingPoll = false;
170
- void this.pollOnce();
189
+ const force = this.pendingForceChanged;
190
+ this.pendingForceChanged = false;
191
+ void this.pollOnce(force);
171
192
  }
172
193
  }
173
194
  }
@@ -111,7 +111,7 @@
111
111
  "key": "feature_flags.ces-managed-sidecar.enabled",
112
112
  "label": "CES Managed Sidecar Transport",
113
113
  "description": "Use managed sidecar transport for CES communication when running in a containerized environment",
114
- "defaultEnabled": false
114
+ "defaultEnabled": true
115
115
  },
116
116
  {
117
117
  "id": "ces-credential-backend",