@vellumai/credential-executor 0.11.2 → 0.11.3-staging.2

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.
@@ -40,3 +40,42 @@ export function isChannelId(value: unknown): value is ChannelId {
40
40
  (CHANNEL_IDS as readonly string[]).includes(value)
41
41
  );
42
42
  }
43
+
44
+ /**
45
+ * The provider key holding each channel's bot credential.
46
+ *
47
+ * Two senses of "connected" share the provider registry: a provider the user
48
+ * authorized so the assistant can act **as them** (`slack`, `discord`,
49
+ * `google`), and a bot credential letting people reach the assistant **as
50
+ * itself**. Nothing in a provider key says which, and the naming actively
51
+ * misleads: `slack` and `discord` name the user integration while their bots
52
+ * take a `_channel` suffix, yet `telegram` *is* the bot, because Telegram has
53
+ * no user-identity integration.
54
+ *
55
+ * That irregularity is why this is stated rather than derived from the key,
56
+ * and it is stated here because this file already owns what a channel is.
57
+ *
58
+ * Deliberately only the key. What fields each credential requires is declared
59
+ * once already, per service, in the gateway's credential specs; restating it
60
+ * here would be a second copy of a different fact.
61
+ *
62
+ * Channels absent from this map reach the assistant without a bot credential
63
+ * of their own: `phone` through the voice provider, `vellum` and `platform`
64
+ * internally.
65
+ */
66
+ export const CHANNEL_BOT_PROVIDER = {
67
+ slack: "slack_channel",
68
+ discord: "discord_channel",
69
+ telegram: "telegram",
70
+ } as const satisfies Partial<Record<ChannelId, string>>;
71
+
72
+ /**
73
+ * Whether a provider key names a bot the assistant is reached through, rather
74
+ * than a grant letting it act as the user. This is the "which sense of
75
+ * connected" question, for any provider key.
76
+ */
77
+ export function isChannelBotProvider(providerKey: string): boolean {
78
+ return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
79
+ providerKey,
80
+ );
81
+ }
@@ -20,6 +20,16 @@ export function normalizePublicBaseUrl(value: unknown): string | undefined {
20
20
  return normalized.length > 0 ? normalized : undefined;
21
21
  }
22
22
 
23
+ /**
24
+ * Application close code the gateway's velay bridge sends to proxied
25
+ * WebSockets when the tunnel itself is lost (velay disconnect, gateway
26
+ * shutdown). A dedicated code because the natural 1001 (going away) cannot
27
+ * be sent through the JS `close()` API (the bridge would remap it to a
28
+ * misleading 4001), and Bun's WebSocket client drops close reasons, so the
29
+ * code is the only signal that survives the relay to the daemon.
30
+ */
31
+ export const GATEWAY_TUNNEL_LOST_WS_CLOSE_CODE = 4801;
32
+
23
33
  export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
24
34
  if (typeof value !== "string") return undefined;
25
35
  const trimmed = value.trim();
@@ -21,6 +21,12 @@
21
21
  * `Date#toISOString()`.
22
22
  */
23
23
 
24
+ /**
25
+ * Pairing-challenge TTL in milliseconds (10 minutes): the gateway's challenge
26
+ * store enforces it and the `vellum pair` CLI renders it in user-facing copy.
27
+ */
28
+ export const REMOTE_WEB_PAIRING_CODE_TTL_MS = 10 * 60 * 1000;
29
+
24
30
  /** `POST /v1/remote-web/pairing-challenge` request body. */
25
31
  export interface RemoteWebPairingChallengeRequest {
26
32
  /** Public https base URL the scanning device can reach the assistant at. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.11.2",
3
+ "version": "0.11.3-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,6 +21,7 @@
21
21
  "dependencies": {
22
22
  "@vellumai/credential-storage": "file:../packages/credential-storage",
23
23
  "@vellumai/egress-proxy": "file:../packages/egress-proxy",
24
+ "@vellumai/ipc-server-utils": "file:../packages/ipc-server-utils",
24
25
  "@vellumai/service-contracts": "file:../packages/service-contracts",
25
26
  "pino": "9.14.0",
26
27
  "pino-pretty": "13.1.3"
@@ -28,7 +29,8 @@
28
29
  "bundledDependencies": [
29
30
  "@vellumai/service-contracts",
30
31
  "@vellumai/credential-storage",
31
- "@vellumai/egress-proxy"
32
+ "@vellumai/egress-proxy",
33
+ "@vellumai/ipc-server-utils"
32
34
  ],
33
35
  "devDependencies": {
34
36
  "@types/bun": "1.3.10",
@@ -6,23 +6,38 @@
6
6
  * tests in `local-materializers.test.ts`.
7
7
  */
8
8
 
9
- import { afterEach, describe, expect, test } from "bun:test";
10
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
9
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
10
+ import * as fs from "node:fs";
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ statSync,
16
+ writeFileSync,
17
+ rmSync,
18
+ } from "node:fs";
11
19
  import { randomBytes } from "node:crypto";
12
20
  import { join } from "node:path";
13
21
  import { tmpdir } from "node:os";
14
22
 
23
+ import { CesRpcMethod } from "@vellumai/service-contracts/credential-rpc";
24
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
25
+
15
26
  import {
16
27
  createLocalSecureKeyBackend,
17
28
  StoreUnavailableError,
18
29
  } from "../materializers/local-secure-key-backend.js";
30
+ import { buildCrudHandlers } from "../main.js";
19
31
 
20
32
  // ---------------------------------------------------------------------------
21
33
  // Helpers
22
34
  // ---------------------------------------------------------------------------
23
35
 
24
36
  function makeTmpDir(): string {
25
- const dir = join(tmpdir(), `ces-backend-test-${randomBytes(8).toString("hex")}`);
37
+ const dir = join(
38
+ tmpdir(),
39
+ `ces-backend-test-${randomBytes(8).toString("hex")}`,
40
+ );
26
41
  mkdirSync(dir, { recursive: true });
27
42
  return dir;
28
43
  }
@@ -100,7 +115,9 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
100
115
  expect(Buffer.compare(keyAfterFirst, keyAfterSecond)).toBe(0);
101
116
 
102
117
  // keys.enc has exactly 2 entries
103
- const store = JSON.parse(readFileSync(join(securityDir, "keys.enc"), "utf-8"));
118
+ const store = JSON.parse(
119
+ readFileSync(join(securityDir, "keys.enc"), "utf-8"),
120
+ );
104
121
  expect(Object.keys(store.entries).length).toBe(2);
105
122
 
106
123
  // Both values round-trip
@@ -114,16 +131,22 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
114
131
  // Manually write a v1 store
115
132
  const salt = randomBytes(32).toString("hex");
116
133
  const v1Store = { version: 1, salt, entries: {} };
117
- writeFileSync(join(securityDir, "keys.enc"), JSON.stringify(v1Store, null, 2), {
118
- mode: 0o600,
119
- });
134
+ writeFileSync(
135
+ join(securityDir, "keys.enc"),
136
+ JSON.stringify(v1Store, null, 2),
137
+ {
138
+ mode: 0o600,
139
+ },
140
+ );
120
141
 
121
142
  const backend = createLocalSecureKeyBackend(vellumRoot);
122
143
  const result = await backend.set("v1-key", "v1-value");
123
144
  expect(result).toBe(true);
124
145
 
125
146
  // Read back and verify format preserved
126
- const storeAfter = JSON.parse(readFileSync(join(securityDir, "keys.enc"), "utf-8"));
147
+ const storeAfter = JSON.parse(
148
+ readFileSync(join(securityDir, "keys.enc"), "utf-8"),
149
+ );
127
150
  expect(storeAfter.version).toBe(1);
128
151
  expect(storeAfter.salt).toBe(salt);
129
152
  expect(Object.keys(storeAfter.entries)).toContain("v1-key");
@@ -173,7 +196,9 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
173
196
  mode: 0o600,
174
197
  });
175
198
  const backend = createLocalSecureKeyBackend(vellumRoot);
176
- await expect(backend.get("anything")).rejects.toThrow(StoreUnavailableError);
199
+ await expect(backend.get("anything")).rejects.toThrow(
200
+ StoreUnavailableError,
201
+ );
177
202
  });
178
203
 
179
204
  test("get() returns undefined when the store reads cleanly but the key is absent", async () => {
@@ -201,4 +226,119 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
201
226
  const backend = createLocalSecureKeyBackend(vellumRoot);
202
227
  await expect(backend.list()).rejects.toThrow(StoreUnavailableError);
203
228
  });
229
+
230
+ // -------------------------------------------------------------------------
231
+ // Store-write durability: the store is written to a temp file with
232
+ // `flush: true` so the ack the assistant receives follows an fsync.
233
+ // -------------------------------------------------------------------------
234
+
235
+ test("writeStore fsyncs the temp file (flush: true) so the ack follows a durable write", async () => {
236
+ const { securityDir, vellumRoot } = setup();
237
+ const spy = spyOn(fs, "writeFileSync");
238
+ try {
239
+ const backend = createLocalSecureKeyBackend(vellumRoot);
240
+ await backend.set("durable/key", "v");
241
+
242
+ const keysEncTmpPrefix = join(securityDir, "keys.enc") + ".tmp.";
243
+ const flushedStoreWrite = spy.mock.calls.some((call) => {
244
+ const [path, , opts] = call as [
245
+ unknown,
246
+ unknown,
247
+ { flush?: boolean } | undefined,
248
+ ];
249
+ return (
250
+ typeof path === "string" &&
251
+ path.startsWith(keysEncTmpPrefix) &&
252
+ !!opts &&
253
+ typeof opts === "object" &&
254
+ opts.flush === true
255
+ );
256
+ });
257
+ expect(flushedStoreWrite).toBe(true);
258
+ } finally {
259
+ spy.mockRestore();
260
+ }
261
+ });
262
+
263
+ test("writeStore fsyncs the parent directory so the rename is durable", async () => {
264
+ const { vellumRoot } = setup();
265
+ const fsyncSpy = spyOn(fs, "fsyncSync");
266
+ try {
267
+ const backend = createLocalSecureKeyBackend(vellumRoot);
268
+ await backend.set("durable/key", "v");
269
+ // The directory fsync runs after the rename; at least one fsyncSync call
270
+ // must have happened for the store write.
271
+ expect(fsyncSpy.mock.calls.length).toBeGreaterThan(0);
272
+ } finally {
273
+ fsyncSpy.mockRestore();
274
+ }
275
+ });
276
+ });
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // CES CRUD audit logging (buildCrudHandlers)
280
+ // ---------------------------------------------------------------------------
281
+
282
+ describe("buildCrudHandlers audit logging", () => {
283
+ function fakeBackend(over: Partial<SecureKeyBackend> = {}): SecureKeyBackend {
284
+ return {
285
+ get: async () => undefined,
286
+ set: async () => true,
287
+ delete: async () => "deleted",
288
+ list: async () => [],
289
+ ...over,
290
+ };
291
+ }
292
+
293
+ const ctx = { sessionId: "test-session" };
294
+
295
+ test("SetCredential emits an audit line with account + outcome, never the value", async () => {
296
+ const calls: Array<{ obj: unknown; msg: string }> = [];
297
+ const audit = {
298
+ info: (obj: unknown, msg: string) => {
299
+ calls.push({ obj, msg });
300
+ },
301
+ };
302
+ const handlers = buildCrudHandlers(
303
+ fakeBackend(),
304
+ audit as unknown as Parameters<typeof buildCrudHandlers>[1],
305
+ );
306
+
307
+ const res = await handlers[CesRpcMethod.SetCredential]!(
308
+ { account: "vellum:assistant_api_key", value: "super-secret-value" },
309
+ ctx,
310
+ );
311
+
312
+ expect(res).toEqual({ ok: true });
313
+ expect(calls).toContainEqual({
314
+ obj: { account: "vellum:assistant_api_key", ok: true },
315
+ msg: "CES credential set",
316
+ });
317
+ // The audit line must never carry the credential value.
318
+ expect(JSON.stringify(calls)).not.toContain("super-secret-value");
319
+ });
320
+
321
+ test("DeleteCredential emits an audit line with account + result", async () => {
322
+ const calls: Array<{ obj: unknown; msg: string }> = [];
323
+ const audit = {
324
+ info: (obj: unknown, msg: string) => {
325
+ calls.push({ obj, msg });
326
+ },
327
+ };
328
+ const handlers = buildCrudHandlers(
329
+ fakeBackend({ delete: async () => "deleted" }),
330
+ audit as unknown as Parameters<typeof buildCrudHandlers>[1],
331
+ );
332
+
333
+ const res = await handlers[CesRpcMethod.DeleteCredential]!(
334
+ { account: "github:api_token" },
335
+ ctx,
336
+ );
337
+
338
+ expect(res).toEqual({ result: "deleted" });
339
+ expect(calls).toContainEqual({
340
+ obj: { account: "github:api_token", result: "deleted" },
341
+ msg: "CES credential delete",
342
+ });
343
+ });
204
344
  });
package/src/main.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * optional credential CRUD routes) for Kubernetes liveness/readiness probes.
18
18
  */
19
19
 
20
- import { mkdirSync, unlinkSync } from "node:fs";
20
+ import { mkdirSync } from "node:fs";
21
21
  import { createServer as createNetServer, type Socket } from "node:net";
22
22
  import { dirname, join } from "node:path";
23
23
  import { Readable, Writable } from "node:stream";
@@ -27,6 +27,11 @@ import {
27
27
  CesRpcMethod,
28
28
  } from "@vellumai/service-contracts/credential-rpc";
29
29
  import type { SecureKeyBackend } from "@vellumai/credential-storage";
30
+ import {
31
+ ipcListenOptions,
32
+ isNamedPipePath,
33
+ removeIpcEndpointFile,
34
+ } from "@vellumai/ipc-server-utils";
30
35
 
31
36
  import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
32
37
  import { initLogger, getLogger } from "./logger.js";
@@ -75,8 +80,9 @@ function ensureDataDirs(mode: CesMode): void {
75
80
  * share the same registry — they differ only in where the secure key backend
76
81
  * reads from and whether the health server is started.
77
82
  */
78
- function buildCrudHandlers(
83
+ export function buildCrudHandlers(
79
84
  secureKeyBackend: SecureKeyBackend,
85
+ audit: Pick<ReturnType<typeof getLogger>, "info"> = log,
80
86
  ): RpcHandlerRegistry {
81
87
  const handlers: RpcHandlerRegistry = {};
82
88
 
@@ -90,6 +96,8 @@ function buildCrudHandlers(
90
96
  value: string;
91
97
  }) => {
92
98
  const ok = await secureKeyBackend.set(req.account, req.value);
99
+ // Audit the mutation: account name and outcome only, never the value.
100
+ audit.info({ account: req.account, ok }, "CES credential set");
93
101
  return { ok };
94
102
  }) as (typeof handlers)[string];
95
103
 
@@ -97,6 +105,7 @@ function buildCrudHandlers(
97
105
  account: string;
98
106
  }) => {
99
107
  const result = await secureKeyBackend.delete(req.account);
108
+ audit.info({ account: req.account, result }, "CES credential delete");
100
109
  return { result };
101
110
  }) as (typeof handlers)[string];
102
111
 
@@ -111,6 +120,7 @@ function buildCrudHandlers(
111
120
  const results = [];
112
121
  for (const { account, value } of req.credentials) {
113
122
  const ok = await secureKeyBackend.set(account, value);
123
+ audit.info({ account, ok }, "CES credential set (bulk)");
114
124
  results.push({ account, ok });
115
125
  }
116
126
  return { results };
@@ -154,12 +164,10 @@ function serveStandaloneSocket(opts: {
154
164
  onApiKeyUpdate,
155
165
  } = opts;
156
166
 
157
- mkdirSync(dirname(socketPath), { recursive: true });
158
- try {
159
- unlinkSync(socketPath);
160
- } catch {
161
- // stale or absent — fine
167
+ if (!isNamedPipePath(socketPath)) {
168
+ mkdirSync(dirname(socketPath), { recursive: true });
162
169
  }
170
+ removeIpcEndpointFile(socketPath);
163
171
 
164
172
  const netServer = createNetServer();
165
173
 
@@ -169,6 +177,7 @@ function serveStandaloneSocket(opts: {
169
177
 
170
178
  netServer.on("connection", (socket: Socket) => {
171
179
  connectionCount++;
180
+ log.info({ connections: connectionCount }, "CES client connected");
172
181
  const readable = new Readable({ read() {} });
173
182
  const writable = new Writable({
174
183
  write(chunk, _encoding, callback) {
@@ -205,10 +214,11 @@ function serveStandaloneSocket(opts: {
205
214
  })
206
215
  .then(() => {
207
216
  connectionCount = Math.max(0, connectionCount - 1);
217
+ log.info({ connections: connectionCount }, "CES client disconnected");
208
218
  });
209
219
  });
210
220
 
211
- netServer.listen(socketPath, () => {
221
+ netServer.listen(ipcListenOptions(socketPath), () => {
212
222
  log.info(`CES socket listening at ${socketPath}`);
213
223
  });
214
224
 
@@ -216,11 +226,7 @@ function serveStandaloneSocket(opts: {
216
226
  "abort",
217
227
  () => {
218
228
  netServer.close();
219
- try {
220
- unlinkSync(socketPath);
221
- } catch {
222
- // already removed
223
- }
229
+ removeIpcEndpointFile(socketPath);
224
230
  },
225
231
  { once: true },
226
232
  );
@@ -392,11 +398,19 @@ async function main(): Promise<void> {
392
398
  log,
393
399
  onApiKeyUpdate:
394
400
  mode === "managed"
395
- ? (_newKey: string, newAssistantId?: string) => {
396
- log.info("Assistant API key updated via RPC");
397
- if (newAssistantId) {
398
- log.info("Assistant ID updated via RPC");
399
- }
401
+ ? (newKey: string, newAssistantId?: string) => {
402
+ // Ack-only. CES reads the durable assistant API key from the shared
403
+ // credential store via the CRUD handlers; this RPC just acknowledges
404
+ // the assistant's notification that the key rotated. The pushed
405
+ // value is intentionally not persisted here (length logged, never
406
+ // the value).
407
+ log.info(
408
+ {
409
+ keyBytes: newKey.length,
410
+ assistantIdUpdated: Boolean(newAssistantId),
411
+ },
412
+ "Acknowledged assistant API key update notification (ack only; value not stored by CES)",
413
+ );
400
414
  }
401
415
  : undefined,
402
416
  });
@@ -413,11 +427,13 @@ async function main(): Promise<void> {
413
427
  log.info("Server stopped.");
414
428
  }
415
429
 
416
- main().catch((err) => {
417
- try {
418
- getLogger("main").fatal({ err }, "Fatal error");
419
- } catch {
420
- process.stderr.write(`[ces-${getCesMode()}] Fatal: ${err}\n`);
421
- }
422
- process.exit(1);
423
- });
430
+ if (import.meta.main) {
431
+ main().catch((err) => {
432
+ try {
433
+ getLogger("main").fatal({ err }, "Fatal error");
434
+ } catch {
435
+ process.stderr.write(`[ces-${getCesMode()}] Fatal: ${err}\n`);
436
+ }
437
+ process.exit(1);
438
+ });
439
+ }
@@ -38,7 +38,17 @@ import {
38
38
  pbkdf2Sync,
39
39
  randomBytes,
40
40
  } from "node:crypto";
41
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
41
+ import {
42
+ chmodSync,
43
+ closeSync,
44
+ existsSync,
45
+ fsyncSync,
46
+ mkdirSync,
47
+ openSync,
48
+ readFileSync,
49
+ renameSync,
50
+ writeFileSync,
51
+ } from "node:fs";
42
52
  import { hostname, userInfo } from "node:os";
43
53
  import { dirname, join } from "node:path";
44
54
 
@@ -55,8 +65,7 @@ const ALGORITHM = "aes-256-gcm";
55
65
  const KEY_LENGTH = 32; // bytes (256 bits)
56
66
  const IV_LENGTH = 16; // bytes (128 bits)
57
67
  const AUTH_TAG_LENGTH = 16; // bytes
58
- const PBKDF2_ITERATIONS =
59
- process.env.BUN_TEST === "1" ? 1 : 100_000;
68
+ const PBKDF2_ITERATIONS = process.env.BUN_TEST === "1" ? 1 : 100_000;
60
69
 
61
70
  // ---------------------------------------------------------------------------
62
71
  // On-disk format (must match assistant/src/security/encrypted-store.ts)
@@ -250,10 +259,30 @@ function writeStore(store: StoreFile, storePath: string): void {
250
259
  const protectedDir = dirname(storePath);
251
260
  mkdirSync(protectedDir, { recursive: true });
252
261
  // Atomic write: write to temp file then rename to avoid partial/corrupt writes.
262
+ // `flush: true` fsyncs the temp file before we return, so the ack the caller
263
+ // receives follows a durable write and survives a crash within the window.
253
264
  const tmpPath = storePath + `.tmp.${process.pid}`;
254
- writeFileSync(tmpPath, JSON.stringify(store, null, 2), { mode: 0o600 });
265
+ writeFileSync(tmpPath, JSON.stringify(store, null, 2), {
266
+ mode: 0o600,
267
+ flush: true,
268
+ });
255
269
  chmodSync(tmpPath, 0o600);
256
270
  renameSync(tmpPath, storePath);
271
+
272
+ // Fsync the parent directory so the rename itself is durable. `{flush:true}`
273
+ // syncs the temp file's contents, not the directory entry the rename updates,
274
+ // so without this a host-level crash could still expose the previous keys.enc.
275
+ // Best-effort: a directory-fsync failure must not fail the write.
276
+ try {
277
+ const dirFd = openSync(protectedDir, "r");
278
+ try {
279
+ fsyncSync(dirFd);
280
+ } finally {
281
+ closeSync(dirFd);
282
+ }
283
+ } catch {
284
+ // Directory fsync is a durability nicety, not required for correctness.
285
+ }
257
286
  }
258
287
 
259
288
  // ---------------------------------------------------------------------------
@@ -330,7 +359,10 @@ export class StoreUnavailableError extends Error {
330
359
  */
331
360
  export function createLocalSecureKeyBackend(
332
361
  vellumRoot: string,
333
- options?: { entropyOverride?: string; entropyGetter?: () => string | undefined },
362
+ options?: {
363
+ entropyOverride?: string;
364
+ entropyGetter?: () => string | undefined;
365
+ },
334
366
  ): SecureKeyBackend {
335
367
  const storePath = join(resolveSecurityDir(vellumRoot), KEYS_ENC_FILENAME);
336
368
  const staticEntropy = options?.entropyOverride;