@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895
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 -1
- package/node_modules/@vellumai/service-contracts/package.json +1 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
- package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
- package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
- package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
- package/package.json +2 -3
- package/src/__tests__/bulk-set-credentials.test.ts +1 -1
- package/src/__tests__/local-standalone.test.ts +5 -36
- package/src/__tests__/managed-integration.test.ts +112 -91
- package/src/__tests__/managed-reconnect.test.ts +2 -2
- package/src/__tests__/transport.test.ts +23 -27
- package/src/cli.ts +1 -1
- package/src/index.ts +8 -88
- package/src/main.ts +228 -340
- package/src/paths.ts +4 -20
- package/src/server.ts +52 -469
- package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
- package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
- package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
- package/src/__tests__/command-executor.test.ts +0 -1879
- package/src/__tests__/command-validator.test.ts +0 -1405
- package/src/__tests__/command-workspace.test.ts +0 -1050
- package/src/__tests__/grant-store.test.ts +0 -689
- package/src/__tests__/http-executor.test.ts +0 -1336
- package/src/__tests__/http-policy.test.ts +0 -1069
- package/src/__tests__/local-materializers.test.ts +0 -860
- package/src/__tests__/local-token-refresh.test.ts +0 -361
- package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
- package/src/__tests__/managed-lazy-getters.test.ts +0 -359
- package/src/__tests__/managed-materializers.test.ts +0 -1028
- package/src/__tests__/managed-rejection.test.ts +0 -43
- package/src/__tests__/toolstore.test.ts +0 -773
- package/src/audit/store.ts +0 -188
- package/src/commands/auth-adapters.ts +0 -169
- package/src/commands/egress-hooks.ts +0 -203
- package/src/commands/executor.ts +0 -1155
- package/src/commands/output-scan.ts +0 -157
- package/src/commands/profiles.ts +0 -286
- package/src/commands/validator.ts +0 -702
- package/src/commands/workspace.ts +0 -550
- package/src/grants/index.ts +0 -17
- package/src/grants/persistent-store.ts +0 -309
- package/src/grants/rpc-handlers.ts +0 -293
- package/src/grants/temporary-store.ts +0 -289
- package/src/http/audit.ts +0 -84
- package/src/http/executor.ts +0 -684
- package/src/http/path-template.ts +0 -245
- package/src/http/policy.ts +0 -238
- package/src/http/response-filter.ts +0 -233
- package/src/managed-errors.ts +0 -9
- package/src/managed-lazy-getters.ts +0 -106
- package/src/managed-main.ts +0 -822
- package/src/materializers/local-oauth-lookup.ts +0 -98
- package/src/materializers/local-token-refresh.ts +0 -287
- package/src/materializers/local.ts +0 -316
- package/src/materializers/managed-platform.ts +0 -295
- package/src/subjects/local.ts +0 -177
- package/src/subjects/managed.ts +0 -311
- package/src/subjects/policy.ts +0 -79
- package/src/toolstore/integrity.ts +0 -94
- package/src/toolstore/manifest.ts +0 -154
- package/src/toolstore/publish.ts +0 -571
package/src/managed-main.ts
DELETED
|
@@ -1,822 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* Managed CES entrypoint.
|
|
4
|
-
*
|
|
5
|
-
* In managed (sidecar) mode the CES container:
|
|
6
|
-
*
|
|
7
|
-
* 1. Ensures the CES-private data directories exist.
|
|
8
|
-
* 2. Binds a bootstrap Unix socket on the shared bootstrap volume.
|
|
9
|
-
* 3. Accepts a single assistant runtime connection.
|
|
10
|
-
* 4. Unlinks the socket path immediately after the connection is accepted,
|
|
11
|
-
* preventing any second process from connecting while the session is live.
|
|
12
|
-
* 5. Serves RPC on the accepted stream only.
|
|
13
|
-
* 6. When that session ends (the assistant disconnects or its container is
|
|
14
|
-
* restarted), re-binds the socket and awaits a reconnection. CES is a
|
|
15
|
-
* long-lived sidecar — it outlives any single assistant session and only
|
|
16
|
-
* shuts down on SIGTERM/SIGINT. At most one connection is ever active.
|
|
17
|
-
* 7. Simultaneously serves health probes (`/healthz`, `/readyz`) on a
|
|
18
|
-
* dedicated HTTP port for Kubernetes liveness/readiness checks.
|
|
19
|
-
*
|
|
20
|
-
* The managed entrypoint never opens a generic TCP or HTTP command API.
|
|
21
|
-
* All RPC traffic flows exclusively over the accepted Unix socket stream.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { mkdirSync, unlinkSync } from "node:fs";
|
|
25
|
-
import { createServer as createNetServer, type Socket } from "node:net";
|
|
26
|
-
import { dirname, join } from "node:path";
|
|
27
|
-
import { Readable, Writable } from "node:stream";
|
|
28
|
-
|
|
29
|
-
import {
|
|
30
|
-
CES_PROTOCOL_VERSION,
|
|
31
|
-
CesRpcMethod,
|
|
32
|
-
} from "@vellumai/service-contracts/credential-rpc";
|
|
33
|
-
|
|
34
|
-
import { AuditStore } from "./audit/store.js";
|
|
35
|
-
import { PersistentGrantStore } from "./grants/persistent-store.js";
|
|
36
|
-
import {
|
|
37
|
-
createListAuditRecordsHandler,
|
|
38
|
-
createListGrantsHandler,
|
|
39
|
-
createRecordGrantHandler,
|
|
40
|
-
createRevokeGrantHandler,
|
|
41
|
-
} from "./grants/rpc-handlers.js";
|
|
42
|
-
import { TemporaryGrantStore } from "./grants/temporary-store.js";
|
|
43
|
-
import { initLogger, getLogger } from "./logger.js";
|
|
44
|
-
import {
|
|
45
|
-
getBootstrapSocketPath,
|
|
46
|
-
getCesAuditDir,
|
|
47
|
-
getCesDataRoot,
|
|
48
|
-
getCesGrantsDir,
|
|
49
|
-
getCesLogDir,
|
|
50
|
-
getCesToolStoreDir,
|
|
51
|
-
getHealthPort,
|
|
52
|
-
} from "./paths.js";
|
|
53
|
-
import {
|
|
54
|
-
buildHandlersWithHttp,
|
|
55
|
-
CesRpcServer,
|
|
56
|
-
registerCommandExecutionHandler,
|
|
57
|
-
registerManageSecureCommandToolHandler,
|
|
58
|
-
type RpcHandlerRegistry,
|
|
59
|
-
type ServeEndReason,
|
|
60
|
-
} from "./server.js";
|
|
61
|
-
import {
|
|
62
|
-
deleteBundleFromToolstore,
|
|
63
|
-
publishBundle,
|
|
64
|
-
} from "./toolstore/publish.js";
|
|
65
|
-
import { validateSourceUrl } from "./toolstore/manifest.js";
|
|
66
|
-
import { buildCesEgressHooks } from "./commands/egress-hooks.js";
|
|
67
|
-
import { resolveManagedSubject } from "./subjects/managed.js";
|
|
68
|
-
import { materializeManagedToken } from "./materializers/managed-platform.js";
|
|
69
|
-
import {
|
|
70
|
-
HandleType,
|
|
71
|
-
parseHandle,
|
|
72
|
-
} from "@vellumai/service-contracts/credential-rpc";
|
|
73
|
-
import {
|
|
74
|
-
applyManagedCredentialRefs,
|
|
75
|
-
buildLazyGetters,
|
|
76
|
-
type ApiKeyRef,
|
|
77
|
-
type AssistantIdRef,
|
|
78
|
-
} from "./managed-lazy-getters.js";
|
|
79
|
-
import { MANAGED_LOCAL_STATIC_REJECTION_ERROR } from "./managed-errors.js";
|
|
80
|
-
import type { SecureKeyBackend } from "@vellumai/credential-storage";
|
|
81
|
-
import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
|
|
82
|
-
import type { LocalMaterialiser } from "./materializers/local.js";
|
|
83
|
-
import type { LocalSubjectResolverDeps } from "./subjects/local.js";
|
|
84
|
-
import {
|
|
85
|
-
handleCredentialRoute,
|
|
86
|
-
type CredentialRouteDeps,
|
|
87
|
-
} from "./http/credential-routes.js";
|
|
88
|
-
import { handleLogExportRoute } from "./http/log-export-routes.js";
|
|
89
|
-
import { CES_MIGRATIONS } from "./migrations/registry.js";
|
|
90
|
-
import { runCesMigrations } from "./migrations/runner.js";
|
|
91
|
-
|
|
92
|
-
// ---------------------------------------------------------------------------
|
|
93
|
-
// Logging
|
|
94
|
-
// ---------------------------------------------------------------------------
|
|
95
|
-
|
|
96
|
-
// Module-level logger used before initLogger() runs (early bootstrap) and
|
|
97
|
-
// after it runs (structured file + stderr). Before initLogger() the fallback
|
|
98
|
-
// inside getLogger() writes to stderr only, so early messages still appear.
|
|
99
|
-
const log = getLogger("main");
|
|
100
|
-
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
// Data directory bootstrap
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
|
|
105
|
-
function ensureDataDirs(): void {
|
|
106
|
-
const dirs = [
|
|
107
|
-
getCesDataRoot("managed"),
|
|
108
|
-
getCesGrantsDir("managed"),
|
|
109
|
-
getCesAuditDir("managed"),
|
|
110
|
-
getCesToolStoreDir("managed"),
|
|
111
|
-
];
|
|
112
|
-
for (const dir of dirs) {
|
|
113
|
-
mkdirSync(dir, { recursive: true });
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
// Build RPC handler registry (managed mode)
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
function buildHandlers(
|
|
122
|
-
apiKeyRef: ApiKeyRef,
|
|
123
|
-
assistantIdRef: AssistantIdRef,
|
|
124
|
-
secureKeyBackend: SecureKeyBackend,
|
|
125
|
-
): RpcHandlerRegistry {
|
|
126
|
-
// -- Grant stores ----------------------------------------------------------
|
|
127
|
-
const persistentGrantStore = new PersistentGrantStore(
|
|
128
|
-
getCesGrantsDir("managed"),
|
|
129
|
-
);
|
|
130
|
-
persistentGrantStore.init();
|
|
131
|
-
|
|
132
|
-
const temporaryGrantStore = new TemporaryGrantStore();
|
|
133
|
-
|
|
134
|
-
// -- Audit store -----------------------------------------------------------
|
|
135
|
-
const auditStore = new AuditStore(getCesAuditDir("managed"));
|
|
136
|
-
auditStore.init();
|
|
137
|
-
|
|
138
|
-
// -- Managed credential options --------------------------------------------
|
|
139
|
-
// In managed mode, credentials are obtained from the platform via its
|
|
140
|
-
// token-materialization endpoint. The platform URL and assistant ID come
|
|
141
|
-
// from environment variables. The API key may come from the env var OR
|
|
142
|
-
// from the bootstrap handshake (the assistant forwards it after hatch).
|
|
143
|
-
// We use a lazy getter so the handshake-provided key takes effect even
|
|
144
|
-
// though handlers are built before the handshake completes.
|
|
145
|
-
const platformBaseUrl = process.env["VELLUM_PLATFORM_URL"] ?? "";
|
|
146
|
-
|
|
147
|
-
const { getManagedSubjectOptions, getManagedMaterializerOptions } =
|
|
148
|
-
buildLazyGetters({
|
|
149
|
-
platformBaseUrl,
|
|
150
|
-
assistantIdRef,
|
|
151
|
-
apiKeyRef,
|
|
152
|
-
envApiKey: process.env["ASSISTANT_API_KEY"] || "",
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
if (!platformBaseUrl) {
|
|
156
|
-
log.warn(
|
|
157
|
-
"VELLUM_PLATFORM_URL not set. " +
|
|
158
|
-
"Managed credential materialisation will depend on the handshake-provided values.",
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// -- Workspace root for command execution cwd ------------------------------
|
|
163
|
-
// Use VELLUM_WORKSPACE_DIR when set, otherwise fall back to the legacy
|
|
164
|
-
// path derived from the assistant data mount.
|
|
165
|
-
const defaultWorkspaceDir =
|
|
166
|
-
process.env["VELLUM_WORKSPACE_DIR"] ??
|
|
167
|
-
(() => {
|
|
168
|
-
const assistantDataMount =
|
|
169
|
-
process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
|
|
170
|
-
return join(join(assistantDataMount, ".vellum"), "workspace");
|
|
171
|
-
})();
|
|
172
|
-
|
|
173
|
-
// -- Build handler registry ------------------------------------------------
|
|
174
|
-
// NOTE: local_static credential handles are NOT supported in managed mode.
|
|
175
|
-
// v2 stores use a UID-independent `store.key` file that removes the
|
|
176
|
-
// technical barrier (legacy v1 stores relied on PBKDF2 key derivation
|
|
177
|
-
// from user identity, which broke across container users). The managed-
|
|
178
|
-
// mode restriction is now a policy choice: managed deployments use
|
|
179
|
-
// platform_oauth handles exclusively for simpler lifecycle and
|
|
180
|
-
// centralized token management.
|
|
181
|
-
//
|
|
182
|
-
// We provide error-returning stubs for localMaterialiser/localSubjectDeps
|
|
183
|
-
// so the HTTP handler compiles but any local_static request gets a clear
|
|
184
|
-
// rejection message.
|
|
185
|
-
|
|
186
|
-
const localMaterialiserStub = {
|
|
187
|
-
materialise: async () => ({
|
|
188
|
-
ok: false as const,
|
|
189
|
-
error: MANAGED_LOCAL_STATIC_REJECTION_ERROR,
|
|
190
|
-
}),
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
const localSubjectDepsStub: LocalSubjectResolverDeps = {
|
|
194
|
-
metadataStore: {
|
|
195
|
-
getById: () => undefined,
|
|
196
|
-
list: () => [],
|
|
197
|
-
} as unknown as LocalSubjectResolverDeps["metadataStore"],
|
|
198
|
-
oauthConnections: { getById: () => undefined },
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
// Use a deps object with getters so the handshake-provided API key
|
|
202
|
-
// is resolved lazily at RPC call time (after the handshake completes).
|
|
203
|
-
const httpDeps = {
|
|
204
|
-
persistentGrantStore,
|
|
205
|
-
temporaryGrantStore,
|
|
206
|
-
localMaterialiser: localMaterialiserStub as unknown as LocalMaterialiser,
|
|
207
|
-
localSubjectDeps: localSubjectDepsStub,
|
|
208
|
-
get managedSubjectOptions() {
|
|
209
|
-
return getManagedSubjectOptions();
|
|
210
|
-
},
|
|
211
|
-
get managedMaterializerOptions() {
|
|
212
|
-
return getManagedMaterializerOptions();
|
|
213
|
-
},
|
|
214
|
-
auditStore,
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
const handlers = buildHandlersWithHttp(httpDeps);
|
|
218
|
-
|
|
219
|
-
// Register run_authenticated_command handler with managed platform materializer
|
|
220
|
-
registerCommandExecutionHandler(handlers, {
|
|
221
|
-
executorDeps: {
|
|
222
|
-
persistentStore: persistentGrantStore,
|
|
223
|
-
temporaryStore: temporaryGrantStore,
|
|
224
|
-
materializeCredential: async (handle) => {
|
|
225
|
-
// Parse handle to determine type
|
|
226
|
-
const parseResult = parseHandle(handle);
|
|
227
|
-
if (!parseResult.ok) {
|
|
228
|
-
return { ok: false as const, error: parseResult.error };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
switch (parseResult.handle.type) {
|
|
232
|
-
// -- Local static: NOT supported in managed mode -------------------
|
|
233
|
-
case HandleType.LocalStatic: {
|
|
234
|
-
return {
|
|
235
|
-
ok: false as const,
|
|
236
|
-
error: MANAGED_LOCAL_STATIC_REJECTION_ERROR,
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// -- Platform OAuth: materialise via the platform endpoint ----------
|
|
241
|
-
case HandleType.PlatformOAuth: {
|
|
242
|
-
const matOpts = getManagedMaterializerOptions();
|
|
243
|
-
const subOpts = getManagedSubjectOptions();
|
|
244
|
-
if (!matOpts || !subOpts) {
|
|
245
|
-
return {
|
|
246
|
-
ok: false as const,
|
|
247
|
-
error:
|
|
248
|
-
"VELLUM_PLATFORM_URL and/or ASSISTANT_API_KEY not set. " +
|
|
249
|
-
"Managed credential materialisation is not available.",
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const subjectResult = await resolveManagedSubject(handle, subOpts);
|
|
254
|
-
if (!subjectResult.ok) {
|
|
255
|
-
return { ok: false as const, error: subjectResult.error.message };
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const matResult = await materializeManagedToken(
|
|
259
|
-
subjectResult.subject,
|
|
260
|
-
matOpts,
|
|
261
|
-
);
|
|
262
|
-
if (!matResult.ok) {
|
|
263
|
-
return { ok: false as const, error: matResult.error.message };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
ok: true as const,
|
|
268
|
-
value: matResult.token.accessToken,
|
|
269
|
-
handleType: HandleType.PlatformOAuth,
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
default:
|
|
274
|
-
return {
|
|
275
|
-
ok: false as const,
|
|
276
|
-
error:
|
|
277
|
-
`Handle type "${parseResult.handle.type}" is not supported in managed mode. ` +
|
|
278
|
-
`Supported types: platform_oauth.`,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
},
|
|
282
|
-
auditStore,
|
|
283
|
-
cesMode: "managed",
|
|
284
|
-
egressHooks: buildCesEgressHooks(),
|
|
285
|
-
},
|
|
286
|
-
defaultWorkspaceDir,
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// Register manage_secure_command_tool handler
|
|
290
|
-
const toolRegistry = new Map<
|
|
291
|
-
string,
|
|
292
|
-
{
|
|
293
|
-
toolName: string;
|
|
294
|
-
credentialHandle: string;
|
|
295
|
-
description: string;
|
|
296
|
-
bundleDigest: string;
|
|
297
|
-
}
|
|
298
|
-
>();
|
|
299
|
-
|
|
300
|
-
registerManageSecureCommandToolHandler(handlers, {
|
|
301
|
-
downloadBundle: async (sourceUrl: string) => {
|
|
302
|
-
const urlError = validateSourceUrl(sourceUrl);
|
|
303
|
-
if (urlError) {
|
|
304
|
-
throw new Error(urlError);
|
|
305
|
-
}
|
|
306
|
-
const MAX_BUNDLE_SIZE = 100 * 1024 * 1024; // 100 MB
|
|
307
|
-
const resp = await fetch(sourceUrl, {
|
|
308
|
-
signal: AbortSignal.timeout(60_000),
|
|
309
|
-
});
|
|
310
|
-
if (!resp.ok) {
|
|
311
|
-
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
|
312
|
-
}
|
|
313
|
-
const contentLength = resp.headers.get("content-length");
|
|
314
|
-
if (contentLength && parseInt(contentLength, 10) > MAX_BUNDLE_SIZE) {
|
|
315
|
-
throw new Error(
|
|
316
|
-
`Bundle too large: ${contentLength} bytes (max ${MAX_BUNDLE_SIZE})`,
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
// Stream the body and enforce the size limit on actual bytes received,
|
|
320
|
-
// since Content-Length can be absent (chunked encoding) or lie.
|
|
321
|
-
const body = resp.body;
|
|
322
|
-
if (!body) {
|
|
323
|
-
throw new Error("Response body is null");
|
|
324
|
-
}
|
|
325
|
-
const chunks: Uint8Array[] = [];
|
|
326
|
-
let totalBytes = 0;
|
|
327
|
-
for await (const chunk of body) {
|
|
328
|
-
totalBytes += chunk.byteLength;
|
|
329
|
-
if (totalBytes > MAX_BUNDLE_SIZE) {
|
|
330
|
-
throw new Error(
|
|
331
|
-
`Bundle too large: received >${MAX_BUNDLE_SIZE} bytes (max ${MAX_BUNDLE_SIZE})`,
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
chunks.push(chunk);
|
|
335
|
-
}
|
|
336
|
-
return Buffer.concat(chunks);
|
|
337
|
-
},
|
|
338
|
-
publishBundle: (request) =>
|
|
339
|
-
publishBundle({ ...request, cesMode: "managed" }),
|
|
340
|
-
unregisterTool: (toolName: string) => {
|
|
341
|
-
const entry = toolRegistry.get(toolName);
|
|
342
|
-
const removed = toolRegistry.delete(toolName);
|
|
343
|
-
if (removed && entry?.bundleDigest) {
|
|
344
|
-
const stillInUse = Array.from(toolRegistry.values()).some(
|
|
345
|
-
(t) => t.bundleDigest === entry.bundleDigest,
|
|
346
|
-
);
|
|
347
|
-
if (!stillInUse) {
|
|
348
|
-
deleteBundleFromToolstore(entry.bundleDigest, "managed");
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
return removed;
|
|
352
|
-
},
|
|
353
|
-
registerTool: (entry) => {
|
|
354
|
-
toolRegistry.set(entry.toolName, entry);
|
|
355
|
-
},
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
// Register grant management handlers
|
|
359
|
-
handlers[CesRpcMethod.RecordGrant] = createRecordGrantHandler({
|
|
360
|
-
persistentGrantStore,
|
|
361
|
-
temporaryGrantStore,
|
|
362
|
-
}) as (typeof handlers)[string];
|
|
363
|
-
|
|
364
|
-
handlers[CesRpcMethod.ListGrants] = createListGrantsHandler({
|
|
365
|
-
persistentGrantStore,
|
|
366
|
-
}) as (typeof handlers)[string];
|
|
367
|
-
|
|
368
|
-
handlers[CesRpcMethod.RevokeGrant] = createRevokeGrantHandler({
|
|
369
|
-
persistentGrantStore,
|
|
370
|
-
}) as (typeof handlers)[string];
|
|
371
|
-
|
|
372
|
-
// Register audit record handler
|
|
373
|
-
handlers[CesRpcMethod.ListAuditRecords] = createListAuditRecordsHandler({
|
|
374
|
-
auditStore,
|
|
375
|
-
}) as (typeof handlers)[string];
|
|
376
|
-
|
|
377
|
-
// Register credential CRUD handlers
|
|
378
|
-
handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
|
|
379
|
-
const value = await secureKeyBackend.get(req.account);
|
|
380
|
-
return { found: value !== undefined, value };
|
|
381
|
-
}) as (typeof handlers)[string];
|
|
382
|
-
|
|
383
|
-
handlers[CesRpcMethod.SetCredential] = (async (req: {
|
|
384
|
-
account: string;
|
|
385
|
-
value: string;
|
|
386
|
-
}) => {
|
|
387
|
-
const ok = await secureKeyBackend.set(req.account, req.value);
|
|
388
|
-
return { ok };
|
|
389
|
-
}) as (typeof handlers)[string];
|
|
390
|
-
|
|
391
|
-
handlers[CesRpcMethod.DeleteCredential] = (async (req: {
|
|
392
|
-
account: string;
|
|
393
|
-
}) => {
|
|
394
|
-
const result = await secureKeyBackend.delete(req.account);
|
|
395
|
-
return { result };
|
|
396
|
-
}) as (typeof handlers)[string];
|
|
397
|
-
|
|
398
|
-
handlers[CesRpcMethod.ListCredentials] = (async () => {
|
|
399
|
-
const accounts = await secureKeyBackend.list();
|
|
400
|
-
return { accounts };
|
|
401
|
-
}) as (typeof handlers)[string];
|
|
402
|
-
|
|
403
|
-
handlers[CesRpcMethod.BulkSetCredentials] = (async (req: {
|
|
404
|
-
credentials: Array<{ account: string; value: string }>;
|
|
405
|
-
}) => {
|
|
406
|
-
const results = [];
|
|
407
|
-
for (const { account, value } of req.credentials) {
|
|
408
|
-
const ok = await secureKeyBackend.set(account, value);
|
|
409
|
-
results.push({ account, ok });
|
|
410
|
-
}
|
|
411
|
-
return { results };
|
|
412
|
-
}) as (typeof handlers)[string];
|
|
413
|
-
|
|
414
|
-
return handlers;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// ---------------------------------------------------------------------------
|
|
418
|
-
// Health server
|
|
419
|
-
// ---------------------------------------------------------------------------
|
|
420
|
-
|
|
421
|
-
let rpcConnected = false;
|
|
422
|
-
|
|
423
|
-
function startHealthServer(
|
|
424
|
-
port: number,
|
|
425
|
-
signal: AbortSignal,
|
|
426
|
-
credentialDeps: CredentialRouteDeps | null,
|
|
427
|
-
): ReturnType<typeof Bun.serve> {
|
|
428
|
-
const server = Bun.serve({
|
|
429
|
-
port,
|
|
430
|
-
async fetch(req) {
|
|
431
|
-
const url = new URL(req.url);
|
|
432
|
-
if (url.pathname === "/healthz") {
|
|
433
|
-
return new Response(JSON.stringify({ status: "ok" }), {
|
|
434
|
-
headers: { "Content-Type": "application/json" },
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
if (url.pathname === "/readyz") {
|
|
438
|
-
// Always return 200 — pod readiness must not depend on whether the
|
|
439
|
-
// assistant has connected. When the CES feature flag is off the
|
|
440
|
-
// assistant never connects, and a 503 here would block pod
|
|
441
|
-
// scheduling during dark-launch. The sidecar can't do useful work
|
|
442
|
-
// without a connection anyway, so readiness is purely about the
|
|
443
|
-
// process being up and able to accept a future connection.
|
|
444
|
-
return new Response(JSON.stringify({ status: "ok", rpcConnected }), {
|
|
445
|
-
status: 200,
|
|
446
|
-
headers: { "Content-Type": "application/json" },
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Credential CRUD routes (only if service token is configured)
|
|
451
|
-
if (credentialDeps) {
|
|
452
|
-
const credentialResponse = await handleCredentialRoute(
|
|
453
|
-
req,
|
|
454
|
-
credentialDeps,
|
|
455
|
-
);
|
|
456
|
-
if (credentialResponse) return credentialResponse;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// Log export route
|
|
460
|
-
const logExportResponse = await handleLogExportRoute(
|
|
461
|
-
req,
|
|
462
|
-
getCesLogDir("managed"),
|
|
463
|
-
);
|
|
464
|
-
if (logExportResponse) return logExportResponse;
|
|
465
|
-
|
|
466
|
-
return new Response("Not Found", { status: 404 });
|
|
467
|
-
},
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
signal.addEventListener(
|
|
471
|
-
"abort",
|
|
472
|
-
() => {
|
|
473
|
-
server.stop(true);
|
|
474
|
-
},
|
|
475
|
-
{ once: true },
|
|
476
|
-
);
|
|
477
|
-
|
|
478
|
-
return server;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// ---------------------------------------------------------------------------
|
|
482
|
-
// Bootstrap socket server (accepts one connection at a time)
|
|
483
|
-
// ---------------------------------------------------------------------------
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Listen on a Unix socket, accept one connection, unlink the socket path,
|
|
487
|
-
* and return readable/writable streams for the accepted connection.
|
|
488
|
-
*
|
|
489
|
-
* The socket is unlinked while a connection is active so no second process
|
|
490
|
-
* can connect concurrently (only one assistant ever talks to CES at a time).
|
|
491
|
-
* When that session ends, the caller re-invokes this function to re-bind the
|
|
492
|
-
* socket and accept the assistant's reconnection — CES outlives any single
|
|
493
|
-
* assistant session (see `main()`).
|
|
494
|
-
*/
|
|
495
|
-
function acceptOneConnection(
|
|
496
|
-
socketPath: string,
|
|
497
|
-
signal: AbortSignal,
|
|
498
|
-
): Promise<{ readable: Readable; writable: Writable; socket: Socket }> {
|
|
499
|
-
return new Promise((resolve, reject) => {
|
|
500
|
-
// Ensure the socket directory exists
|
|
501
|
-
mkdirSync(dirname(socketPath), { recursive: true });
|
|
502
|
-
|
|
503
|
-
// Clean up any stale socket file
|
|
504
|
-
try {
|
|
505
|
-
unlinkSync(socketPath);
|
|
506
|
-
} catch {
|
|
507
|
-
// Ignore — file may not exist
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const netServer = createNetServer();
|
|
511
|
-
|
|
512
|
-
const cleanup = () => {
|
|
513
|
-
netServer.close();
|
|
514
|
-
try {
|
|
515
|
-
unlinkSync(socketPath);
|
|
516
|
-
} catch {
|
|
517
|
-
// Already unlinked or never created
|
|
518
|
-
}
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
if (signal.aborted) {
|
|
522
|
-
reject(new Error("Aborted before listening"));
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// Remove this listener once the promise settles. Because CES re-binds
|
|
527
|
-
// the socket after each session ends, a long-lived AbortSignal would
|
|
528
|
-
// otherwise accumulate one dangling listener per reconnection.
|
|
529
|
-
const onAbort = () => {
|
|
530
|
-
cleanup();
|
|
531
|
-
reject(new Error("Aborted while waiting for connection"));
|
|
532
|
-
};
|
|
533
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
534
|
-
|
|
535
|
-
netServer.on("error", (err) => {
|
|
536
|
-
signal.removeEventListener("abort", onAbort);
|
|
537
|
-
cleanup();
|
|
538
|
-
reject(err);
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
netServer.listen(socketPath, () => {
|
|
542
|
-
log.info(`Bootstrap socket listening at ${socketPath}`);
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
netServer.on("connection", (socket: Socket) => {
|
|
546
|
-
// Accept the connection, then close the listener and unlink the
|
|
547
|
-
// socket path so no other process can connect while this session
|
|
548
|
-
// is active.
|
|
549
|
-
signal.removeEventListener("abort", onAbort);
|
|
550
|
-
log.info("Assistant connected via bootstrap socket");
|
|
551
|
-
netServer.close();
|
|
552
|
-
try {
|
|
553
|
-
unlinkSync(socketPath);
|
|
554
|
-
} catch {
|
|
555
|
-
// Already unlinked
|
|
556
|
-
}
|
|
557
|
-
log.info("Bootstrap socket unlinked (single active connection enforced)");
|
|
558
|
-
|
|
559
|
-
const readable = new Readable({
|
|
560
|
-
read() {
|
|
561
|
-
// Data is pushed externally
|
|
562
|
-
},
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
const writable = new Writable({
|
|
566
|
-
write(chunk, _encoding, callback) {
|
|
567
|
-
if (socket.writable) {
|
|
568
|
-
socket.write(chunk, callback);
|
|
569
|
-
} else {
|
|
570
|
-
callback(new Error("Socket no longer writable"));
|
|
571
|
-
}
|
|
572
|
-
},
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
socket.on("data", (chunk) => {
|
|
576
|
-
readable.push(chunk);
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
socket.on("end", () => {
|
|
580
|
-
readable.push(null);
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
socket.on("error", (err) => {
|
|
584
|
-
readable.destroy(err);
|
|
585
|
-
writable.destroy(err);
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
resolve({ readable, writable, socket });
|
|
589
|
-
});
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// ---------------------------------------------------------------------------
|
|
594
|
-
// Main
|
|
595
|
-
// ---------------------------------------------------------------------------
|
|
596
|
-
|
|
597
|
-
async function main(): Promise<void> {
|
|
598
|
-
ensureDataDirs();
|
|
599
|
-
|
|
600
|
-
initLogger({ dir: getCesLogDir("managed"), retentionDays: 30 });
|
|
601
|
-
|
|
602
|
-
log.info(`Starting CES v${CES_PROTOCOL_VERSION} (managed mode)`);
|
|
603
|
-
|
|
604
|
-
const controller = new AbortController();
|
|
605
|
-
|
|
606
|
-
// Graceful shutdown — pass the signal as the abort reason so consumers
|
|
607
|
-
// of controller.signal can inspect signal.reason for triage.
|
|
608
|
-
process.on("SIGTERM", () => {
|
|
609
|
-
log.warn(
|
|
610
|
-
{ signal: "SIGTERM", pid: process.pid, uptime: process.uptime() },
|
|
611
|
-
"Received SIGTERM — shutting down",
|
|
612
|
-
);
|
|
613
|
-
controller.abort("SIGTERM");
|
|
614
|
-
});
|
|
615
|
-
process.on("SIGINT", () => {
|
|
616
|
-
log.warn(
|
|
617
|
-
{ signal: "SIGINT", pid: process.pid, uptime: process.uptime() },
|
|
618
|
-
"Received SIGINT — shutting down",
|
|
619
|
-
);
|
|
620
|
-
controller.abort("SIGINT");
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
// Create the secure key backend unconditionally — it's needed by both
|
|
624
|
-
// HTTP credential routes (when CES_SERVICE_TOKEN is set) and RPC
|
|
625
|
-
// credential CRUD handlers (always available).
|
|
626
|
-
const assistantDataMount =
|
|
627
|
-
process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
|
|
628
|
-
const vellumRoot = join(assistantDataMount, ".vellum");
|
|
629
|
-
const secureKeyBackend = createLocalSecureKeyBackend(vellumRoot);
|
|
630
|
-
|
|
631
|
-
// Run one-time credential store migrations before accepting connections.
|
|
632
|
-
await runCesMigrations(
|
|
633
|
-
getCesDataRoot("managed"),
|
|
634
|
-
secureKeyBackend,
|
|
635
|
-
CES_MIGRATIONS,
|
|
636
|
-
);
|
|
637
|
-
log.info("CES managed startup: migrations complete");
|
|
638
|
-
|
|
639
|
-
// Set up credential CRUD routes if a service token is configured.
|
|
640
|
-
// The assistant and gateway use CES_SERVICE_TOKEN to authenticate
|
|
641
|
-
// credential management requests over HTTP.
|
|
642
|
-
const serviceToken = process.env["CES_SERVICE_TOKEN"] ?? "";
|
|
643
|
-
let credentialDeps: CredentialRouteDeps | null = null;
|
|
644
|
-
|
|
645
|
-
if (serviceToken) {
|
|
646
|
-
credentialDeps = { backend: secureKeyBackend, serviceToken };
|
|
647
|
-
log.info("Credential CRUD routes enabled (CES_SERVICE_TOKEN configured)");
|
|
648
|
-
} else {
|
|
649
|
-
log.warn(
|
|
650
|
-
"CES_SERVICE_TOKEN not set — credential CRUD HTTP routes are disabled. " +
|
|
651
|
-
"Set CES_SERVICE_TOKEN to enable credential management over HTTP.",
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// Start health server on dedicated port. The returned handle isn't
|
|
656
|
-
// needed because the server lifetime is bound to controller.signal,
|
|
657
|
-
// which fires on shutdown and triggers Bun.serve's stop().
|
|
658
|
-
const healthPort = getHealthPort();
|
|
659
|
-
startHealthServer(healthPort, controller.signal, credentialDeps);
|
|
660
|
-
log.info(`Health server listening on port ${healthPort}`);
|
|
661
|
-
|
|
662
|
-
// Build the handler registry once, up front, and reuse it across every
|
|
663
|
-
// assistant session. All CES state lives behind these handlers — file-backed
|
|
664
|
-
// grant/audit stores plus the in-memory temporary-grant store and the
|
|
665
|
-
// secure-command tool registry — and must be process-scoped so it survives
|
|
666
|
-
// an assistant reconnection. In particular, the tool registry mirrors the
|
|
667
|
-
// persistent toolstore on disk; rebuilding it per session would let a later
|
|
668
|
-
// `unregister` miss a tool registered in an earlier session and orphan its
|
|
669
|
-
// bundle.
|
|
670
|
-
//
|
|
671
|
-
// The in-memory temporary-grant store instance is also process-scoped and is
|
|
672
|
-
// deliberately reused — contents included — across reconnects. Ephemeral
|
|
673
|
-
// approvals (`allow_once` / `allow_10m` / `allow_conversation`) are keyed by
|
|
674
|
-
// proposal hash (plus a caller-supplied conversation ID), not by the
|
|
675
|
-
// connection that produced them, precisely so a single guardian approval can
|
|
676
|
-
// be shared by any connection entitled to use it. That sharing is what the
|
|
677
|
-
// multi-process daemon model needs: several assistant processes will each
|
|
678
|
-
// talk to CES, and an approval granted while one is connected must remain
|
|
679
|
-
// usable by the others. Grant lifetime is therefore bounded by per-grant TTLs
|
|
680
|
-
// (every kind now carries an expiry), not by tearing the store down on
|
|
681
|
-
// disconnect — so an approval that is never consumed expires on its own
|
|
682
|
-
// instead of surviving indefinitely and being replayed by a much later
|
|
683
|
-
// connection without a fresh guardian prompt (ATL-935). A future
|
|
684
|
-
// multi-connection daemon may additionally evict on quiescence (when the
|
|
685
|
-
// count of live CES connections reaches zero) to scope grants to assistant
|
|
686
|
-
// presence; that is connection-lifecycle machinery the multi-connection work
|
|
687
|
-
// should own, and is intentionally not added here.
|
|
688
|
-
//
|
|
689
|
-
// The mutable refs carry the handshake-provided API key and assistant ID;
|
|
690
|
-
// handlers read them at call time. These don't vary across a daemon's
|
|
691
|
-
// connections, so they stay process-global. The per-connection session ID,
|
|
692
|
-
// by contrast, lives in each CesRpcServer's SessionContext (handlers read it
|
|
693
|
-
// at call time for audit attribution).
|
|
694
|
-
const apiKeyRef: ApiKeyRef = { current: "" };
|
|
695
|
-
const assistantIdRef: AssistantIdRef = { current: "" };
|
|
696
|
-
const handlers = buildHandlers(apiKeyRef, assistantIdRef, secureKeyBackend);
|
|
697
|
-
|
|
698
|
-
// Serve loop. CES is a long-lived sidecar that must outlive any single
|
|
699
|
-
// assistant session: the assistant container can crash and be restarted
|
|
700
|
-
// independently of the CES container (Kubernetes restarts containers, not
|
|
701
|
-
// the whole pod), so when the RPC stream ends we re-bind the bootstrap
|
|
702
|
-
// socket and wait for the assistant to reconnect rather than tearing the
|
|
703
|
-
// sidecar down. The loop only exits on a shutdown signal (SIGTERM/SIGINT),
|
|
704
|
-
// which aborts the controller.
|
|
705
|
-
const rpcLog = getLogger("rpc");
|
|
706
|
-
const socketPath = getBootstrapSocketPath();
|
|
707
|
-
|
|
708
|
-
while (!controller.signal.aborted) {
|
|
709
|
-
log.info(`Waiting for assistant connection on ${socketPath}...`);
|
|
710
|
-
|
|
711
|
-
let connection: Awaited<ReturnType<typeof acceptOneConnection>>;
|
|
712
|
-
try {
|
|
713
|
-
connection = await acceptOneConnection(socketPath, controller.signal);
|
|
714
|
-
} catch (err) {
|
|
715
|
-
if (controller.signal.aborted) {
|
|
716
|
-
log.info("Shutdown before assistant connected.");
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
throw err;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
rpcConnected = true;
|
|
723
|
-
|
|
724
|
-
const server = new CesRpcServer({
|
|
725
|
-
input: connection.readable,
|
|
726
|
-
output: connection.writable,
|
|
727
|
-
handlers,
|
|
728
|
-
logger: {
|
|
729
|
-
log: (msg: string, ...args: unknown[]) => rpcLog.info({ args }, msg),
|
|
730
|
-
warn: (msg: string, ...args: unknown[]) => rpcLog.warn({ args }, msg),
|
|
731
|
-
error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
|
|
732
|
-
},
|
|
733
|
-
signal: controller.signal,
|
|
734
|
-
onHandshakeComplete: (_hsSessionId, hsApiKey, hsAssistantId) => {
|
|
735
|
-
// Overwrite the credential refs on every handshake. The handler
|
|
736
|
-
// registry persists across reconnects, so a new session that omits
|
|
737
|
-
// the API key / assistant ID must fail closed (falling back to the
|
|
738
|
-
// env key, or no key) rather than reusing the previous session's
|
|
739
|
-
// credentials.
|
|
740
|
-
applyManagedCredentialRefs(
|
|
741
|
-
apiKeyRef,
|
|
742
|
-
assistantIdRef,
|
|
743
|
-
hsApiKey,
|
|
744
|
-
hsAssistantId,
|
|
745
|
-
);
|
|
746
|
-
if (hsApiKey) {
|
|
747
|
-
log.info("Received assistant API key via handshake");
|
|
748
|
-
}
|
|
749
|
-
if (hsAssistantId) {
|
|
750
|
-
log.info("Received assistant ID via handshake");
|
|
751
|
-
}
|
|
752
|
-
},
|
|
753
|
-
onApiKeyUpdate: (newKey, newAssistantId) => {
|
|
754
|
-
// Overwrite both refs on every credential update, for the same
|
|
755
|
-
// fail-closed reason as the handshake: the assistant sources the
|
|
756
|
-
// assistant ID from the same place it sources the key, so an update
|
|
757
|
-
// that omits the ID means it has none — CES must clear the stale ID
|
|
758
|
-
// rather than keep materializing for the previous session's assistant.
|
|
759
|
-
applyManagedCredentialRefs(
|
|
760
|
-
apiKeyRef,
|
|
761
|
-
assistantIdRef,
|
|
762
|
-
newKey,
|
|
763
|
-
newAssistantId,
|
|
764
|
-
);
|
|
765
|
-
log.info("Assistant API key updated via RPC");
|
|
766
|
-
if (newAssistantId) {
|
|
767
|
-
log.info("Assistant ID updated via RPC");
|
|
768
|
-
}
|
|
769
|
-
},
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
// `serve()` resolves on a clean stream end or signal abort, and rejects
|
|
773
|
-
// when the transport stream errors — which is precisely what a hard
|
|
774
|
-
// disconnect (connection reset when the assistant container crashes)
|
|
775
|
-
// looks like. Both cases must keep the sidecar up; only a shutdown
|
|
776
|
-
// signal should tear it down. So treat a serve() rejection the same as
|
|
777
|
-
// a session end and fall through to await reconnection.
|
|
778
|
-
let endReason: ServeEndReason | "transport_error";
|
|
779
|
-
try {
|
|
780
|
-
endReason = await server.serve();
|
|
781
|
-
} catch (err) {
|
|
782
|
-
server.close();
|
|
783
|
-
endReason = "transport_error";
|
|
784
|
-
log.warn(
|
|
785
|
-
{ err, uptime: process.uptime(), pid: process.pid },
|
|
786
|
-
"RPC transport errored — treating as session end",
|
|
787
|
-
);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
rpcConnected = false;
|
|
791
|
-
|
|
792
|
-
// A signal-driven end means the process is shutting down; exit the loop.
|
|
793
|
-
// Any other end reason (the assistant disconnected, its stream closed,
|
|
794
|
-
// or the transport errored) means we keep the sidecar up and await a
|
|
795
|
-
// reconnection.
|
|
796
|
-
if (
|
|
797
|
-
controller.signal.aborted ||
|
|
798
|
-
endReason === "signal_aborted" ||
|
|
799
|
-
endReason === "signal_aborted_before_start"
|
|
800
|
-
) {
|
|
801
|
-
log.info(
|
|
802
|
-
{ reason: endReason, uptime: process.uptime(), pid: process.pid },
|
|
803
|
-
"RPC session ended due to shutdown — exiting serve loop",
|
|
804
|
-
);
|
|
805
|
-
break;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
log.warn(
|
|
809
|
-
{ reason: endReason, uptime: process.uptime(), pid: process.pid },
|
|
810
|
-
"RPC session ended (assistant disconnected) — awaiting reconnection",
|
|
811
|
-
);
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
main().catch((err) => {
|
|
816
|
-
try {
|
|
817
|
-
getLogger("main").fatal({ err }, "Fatal error");
|
|
818
|
-
} catch {
|
|
819
|
-
process.stderr.write(`[ces-managed] Fatal: ${err}\n`);
|
|
820
|
-
}
|
|
821
|
-
process.exit(1);
|
|
822
|
-
});
|