@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.
- package/Dockerfile +1 -0
- package/knip.json +2 -1
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +4 -2
- package/src/__tests__/local-secure-key-backend.test.ts +149 -9
- package/src/main.ts +42 -26
- package/src/materializers/local-secure-key-backend.ts +37 -5
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
118
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
? (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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 {
|
|
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), {
|
|
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?: {
|
|
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;
|