@vellumai/credential-executor 0.10.5-dev.202607042025.8a04d0b → 0.10.5-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/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +8 -0
- package/node_modules/@vellumai/service-contracts/src/twilio-ingress.ts +14 -3
- package/package.json +1 -1
- package/src/__tests__/transport.test.ts +11 -28
- package/src/main.ts +24 -140
- package/src/paths.ts +0 -27
- package/src/__tests__/local-standalone.test.ts +0 -233
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
normalizePublicBaseUrl,
|
|
6
6
|
} from "../ingress.js";
|
|
7
7
|
import {
|
|
8
|
+
buildTwilioConnectActionUrl,
|
|
8
9
|
buildTwilioMediaStreamUrl,
|
|
9
10
|
buildTwilioPhoneNumberWebhookUrls,
|
|
11
|
+
buildTwilioRelayUrl,
|
|
10
12
|
buildTwilioVoiceWebhookUrl,
|
|
11
13
|
resolveTwilioPublicBaseUrl,
|
|
12
14
|
} from "../twilio-ingress.js";
|
|
@@ -88,6 +90,12 @@ describe("Twilio ingress helpers", () => {
|
|
|
88
90
|
expect(buildTwilioVoiceWebhookUrl("https://example.test", "call-123")).toBe(
|
|
89
91
|
"https://example.test/webhooks/twilio/voice?callSessionId=call-123",
|
|
90
92
|
);
|
|
93
|
+
expect(buildTwilioConnectActionUrl("https://example.test")).toBe(
|
|
94
|
+
"https://example.test/webhooks/twilio/connect-action",
|
|
95
|
+
);
|
|
96
|
+
expect(buildTwilioRelayUrl("https://example.test")).toBe(
|
|
97
|
+
"wss://example.test/webhooks/twilio/relay",
|
|
98
|
+
);
|
|
91
99
|
expect(buildTwilioMediaStreamUrl("http://example.test")).toBe(
|
|
92
100
|
"ws://example.test/webhooks/twilio/media-stream",
|
|
93
101
|
);
|
|
@@ -2,6 +2,9 @@ import { normalizePublicBaseUrl } from "./ingress.js";
|
|
|
2
2
|
|
|
3
3
|
export const TWILIO_VOICE_WEBHOOK_PATH = "/webhooks/twilio/voice";
|
|
4
4
|
export const TWILIO_STATUS_WEBHOOK_PATH = "/webhooks/twilio/status";
|
|
5
|
+
export const TWILIO_CONNECT_ACTION_WEBHOOK_PATH =
|
|
6
|
+
"/webhooks/twilio/connect-action";
|
|
7
|
+
export const TWILIO_RELAY_WEBHOOK_PATH = "/webhooks/twilio/relay";
|
|
5
8
|
export const TWILIO_MEDIA_STREAM_WEBHOOK_PATH = "/webhooks/twilio/media-stream";
|
|
6
9
|
|
|
7
10
|
/**
|
|
@@ -10,9 +13,9 @@ export const TWILIO_MEDIA_STREAM_WEBHOOK_PATH = "/webhooks/twilio/media-stream";
|
|
|
10
13
|
* with the actual public URL (from Velay registration, config, or the
|
|
11
14
|
* `X-Vellum-Ingress-URL` header) before returning TwiML to Twilio.
|
|
12
15
|
*
|
|
13
|
-
* The placeholder uses `https://` so that `
|
|
14
|
-
* apply the standard `http→ws` scheme
|
|
15
|
-
* `wss://__VELLUM_PUBLIC_BASE_URL__/…` in the output.
|
|
16
|
+
* The placeholder uses `https://` so that `buildTwilioRelayUrl` /
|
|
17
|
+
* `buildTwilioMediaStreamUrl` can apply the standard `http→ws` scheme
|
|
18
|
+
* conversion, producing `wss://__VELLUM_PUBLIC_BASE_URL__/…` in the output.
|
|
16
19
|
*/
|
|
17
20
|
export const TWILIO_PUBLIC_BASE_URL_PLACEHOLDER =
|
|
18
21
|
"https://__VELLUM_PUBLIC_BASE_URL__";
|
|
@@ -55,6 +58,14 @@ export function buildTwilioStatusWebhookUrl(baseUrl: string): string {
|
|
|
55
58
|
return `${baseUrl}${TWILIO_STATUS_WEBHOOK_PATH}`;
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
export function buildTwilioConnectActionUrl(baseUrl: string): string {
|
|
62
|
+
return `${baseUrl}${TWILIO_CONNECT_ACTION_WEBHOOK_PATH}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildTwilioRelayUrl(baseUrl: string): string {
|
|
66
|
+
return `${toTwilioWebSocketBaseUrl(baseUrl)}${TWILIO_RELAY_WEBHOOK_PATH}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
export function buildTwilioMediaStreamUrl(baseUrl: string): string {
|
|
59
70
|
return `${toTwilioWebSocketBaseUrl(baseUrl)}${TWILIO_MEDIA_STREAM_WEBHOOK_PATH}`;
|
|
60
71
|
}
|
package/package.json
CHANGED
|
@@ -23,11 +23,7 @@ import {
|
|
|
23
23
|
type RpcEnvelope,
|
|
24
24
|
} from "@vellumai/service-contracts/credential-rpc";
|
|
25
25
|
|
|
26
|
-
import {
|
|
27
|
-
getCesDataRoot,
|
|
28
|
-
getBootstrapSocketPath,
|
|
29
|
-
getHealthPort,
|
|
30
|
-
} from "../paths.js";
|
|
26
|
+
import { getCesDataRoot, getBootstrapSocketPath, getHealthPort } from "../paths.js";
|
|
31
27
|
import { CesRpcServer, type RpcHandlerRegistry } from "../server.js";
|
|
32
28
|
|
|
33
29
|
// ---------------------------------------------------------------------------
|
|
@@ -108,16 +104,7 @@ function createTestServer(handlers: RpcHandlerRegistry = {}) {
|
|
|
108
104
|
return JSON.parse(lines[lines.length - 1]) as RpcEnvelope;
|
|
109
105
|
}
|
|
110
106
|
|
|
111
|
-
return {
|
|
112
|
-
server,
|
|
113
|
-
input,
|
|
114
|
-
output,
|
|
115
|
-
send,
|
|
116
|
-
collectOutputLines,
|
|
117
|
-
handshake,
|
|
118
|
-
rpc,
|
|
119
|
-
logs,
|
|
120
|
-
};
|
|
107
|
+
return { server, input, output, send, collectOutputLines, handshake, rpc, logs };
|
|
121
108
|
}
|
|
122
109
|
|
|
123
110
|
// ---------------------------------------------------------------------------
|
|
@@ -207,15 +194,15 @@ describe("health probes", () => {
|
|
|
207
194
|
// ---------------------------------------------------------------------------
|
|
208
195
|
|
|
209
196
|
describe("local entrypoint transport isolation", () => {
|
|
210
|
-
test("main.ts
|
|
197
|
+
test("main.ts uses process.stdin/stdout, not TCP listeners", () => {
|
|
211
198
|
const src = readFileSync(resolve(__dirname, "..", "main.ts"), "utf-8");
|
|
212
|
-
//
|
|
199
|
+
// Uses stdin/stdout for transport
|
|
213
200
|
expect(src).toMatch(/process\.stdin/);
|
|
214
201
|
expect(src).toMatch(/process\.stdout/);
|
|
215
|
-
//
|
|
216
|
-
// never a numeric TCP port — and never opens an HTTP server.
|
|
202
|
+
// Does not open any TCP or Unix socket listener
|
|
217
203
|
expect(src).not.toMatch(/Bun\.serve\(/);
|
|
218
|
-
expect(src).not.toMatch(
|
|
204
|
+
expect(src).not.toMatch(/createServer\(/);
|
|
205
|
+
expect(src).not.toMatch(/\.listen\(/);
|
|
219
206
|
});
|
|
220
207
|
|
|
221
208
|
test("main.ts logs to stderr, not stdout (avoids polluting transport)", () => {
|
|
@@ -323,10 +310,9 @@ describe("CesRpcServer", () => {
|
|
|
323
310
|
|
|
324
311
|
const resp = await rpc("nonexistent_method", {});
|
|
325
312
|
expect(resp.kind).toBe("response");
|
|
326
|
-
expect(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
).toBe("METHOD_NOT_FOUND");
|
|
313
|
+
expect((resp.payload as { success: boolean; error: { code: string } }).error.code).toBe(
|
|
314
|
+
"METHOD_NOT_FOUND",
|
|
315
|
+
);
|
|
330
316
|
|
|
331
317
|
server.close();
|
|
332
318
|
input.end();
|
|
@@ -346,10 +332,7 @@ describe("CesRpcServer", () => {
|
|
|
346
332
|
|
|
347
333
|
const resp = await rpc("fail_method", {});
|
|
348
334
|
expect(resp.kind).toBe("response");
|
|
349
|
-
const payload = resp.payload as {
|
|
350
|
-
success: boolean;
|
|
351
|
-
error: { code: string; message: string };
|
|
352
|
-
};
|
|
335
|
+
const payload = resp.payload as { success: boolean; error: { code: string; message: string } };
|
|
353
336
|
expect(payload.error.code).toBe("HANDLER_ERROR");
|
|
354
337
|
expect(payload.error.message).toMatch(/Intentional test failure/);
|
|
355
338
|
|
package/src/main.ts
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Local CES entrypoint.
|
|
3
|
+
* Local CES entrypoint.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* exit) or on SIGTERM. This is today's behavior.
|
|
5
|
+
* In local mode the assistant spawns CES as a child process and communicates
|
|
6
|
+
* over stdin/stdout using newline-delimited JSON. This entrypoint:
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* This is the direction local CES is converging on; the socket-serving here
|
|
13
|
-
* is temporary scaffolding to be folded into a single unified CES entrypoint.
|
|
8
|
+
* 1. Ensures the CES-private data directories exist.
|
|
9
|
+
* 2. Starts the RPC server on process.stdin / process.stdout.
|
|
10
|
+
* 3. Shuts down cleanly when stdin closes (parent exit) or SIGTERM arrives.
|
|
14
11
|
*
|
|
15
|
-
* Local mode never opens a TCP listener
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* Local mode never opens a TCP listener or Unix socket. All communication
|
|
13
|
+
* flows through the inherited stdio file descriptors, which are automatically
|
|
14
|
+
* closed when the parent process exits.
|
|
15
|
+
*
|
|
16
|
+
* The stdio transport ensures that shell subprocesses spawned by CES
|
|
17
|
+
* (e.g. for `run_authenticated_command`) do not accidentally inherit the
|
|
18
|
+
* command channel — Bun's `Bun.spawn` defaults to "pipe" for stdio on
|
|
19
|
+
* child processes, so CES's own stdin/stdout are not leaked to subprocesses.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
|
-
import { mkdirSync
|
|
22
|
-
import { createServer as createNetServer, type Socket } from "node:net";
|
|
22
|
+
import { mkdirSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
25
|
-
import { Readable, Writable } from "node:stream";
|
|
26
25
|
|
|
27
26
|
import {
|
|
28
27
|
CES_PROTOCOL_VERSION,
|
|
@@ -53,7 +52,6 @@ import {
|
|
|
53
52
|
getCesGrantsDir,
|
|
54
53
|
getCesLogDir,
|
|
55
54
|
getCesToolStoreDir,
|
|
56
|
-
getLocalSocketPath,
|
|
57
55
|
} from "./paths.js";
|
|
58
56
|
import {
|
|
59
57
|
buildHandlersWithHttp,
|
|
@@ -124,7 +122,9 @@ function getSecurityDir(): string {
|
|
|
124
122
|
// Build RPC handler registry
|
|
125
123
|
// ---------------------------------------------------------------------------
|
|
126
124
|
|
|
127
|
-
function buildHandlers(
|
|
125
|
+
function buildHandlers(
|
|
126
|
+
secureKeyBackend: SecureKeyBackend,
|
|
127
|
+
): RpcHandlerRegistry {
|
|
128
128
|
// -- Grant stores ----------------------------------------------------------
|
|
129
129
|
const persistentGrantStore = new PersistentGrantStore(
|
|
130
130
|
getCesGrantsDir("local"),
|
|
@@ -350,103 +350,14 @@ function buildHandlers(secureKeyBackend: SecureKeyBackend): RpcHandlerRegistry {
|
|
|
350
350
|
// Main
|
|
351
351
|
// ---------------------------------------------------------------------------
|
|
352
352
|
|
|
353
|
-
/**
|
|
354
|
-
* Serve RPC over a Unix socket for standalone-sibling mode.
|
|
355
|
-
*
|
|
356
|
-
* Binds the socket, accepts connections concurrently (each served by its own
|
|
357
|
-
* CesRpcServer over the shared handler registry), and unlinks the socket when
|
|
358
|
-
* the signal aborts. Temporary scaffolding — this serving path will be folded
|
|
359
|
-
* into a single unified CES entrypoint shared with the managed sidecar.
|
|
360
|
-
*/
|
|
361
|
-
function serveStandaloneSocket(opts: {
|
|
362
|
-
socketPath: string;
|
|
363
|
-
handlers: RpcHandlerRegistry;
|
|
364
|
-
signal: AbortSignal;
|
|
365
|
-
logger: Pick<Console, "log" | "warn" | "error">;
|
|
366
|
-
log: ReturnType<typeof getLogger>;
|
|
367
|
-
}): void {
|
|
368
|
-
const { socketPath, handlers, signal, logger, log } = opts;
|
|
369
|
-
|
|
370
|
-
mkdirSync(dirname(socketPath), { recursive: true });
|
|
371
|
-
try {
|
|
372
|
-
unlinkSync(socketPath);
|
|
373
|
-
} catch {
|
|
374
|
-
// stale or absent — fine
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const netServer = createNetServer();
|
|
378
|
-
|
|
379
|
-
netServer.on("error", (err) => {
|
|
380
|
-
log.warn({ err }, "CES standalone socket server error");
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
netServer.on("connection", (socket: Socket) => {
|
|
384
|
-
const readable = new Readable({ read() {} });
|
|
385
|
-
const writable = new Writable({
|
|
386
|
-
write(chunk, _encoding, callback) {
|
|
387
|
-
if (socket.writable) {
|
|
388
|
-
socket.write(chunk, callback);
|
|
389
|
-
} else {
|
|
390
|
-
callback(new Error("Socket no longer writable"));
|
|
391
|
-
}
|
|
392
|
-
},
|
|
393
|
-
});
|
|
394
|
-
socket.on("data", (chunk) => readable.push(chunk));
|
|
395
|
-
socket.on("end", () => readable.push(null));
|
|
396
|
-
socket.on("error", (err) => {
|
|
397
|
-
readable.destroy(err);
|
|
398
|
-
writable.destroy(err);
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
const server = new CesRpcServer({
|
|
402
|
-
input: readable,
|
|
403
|
-
output: writable,
|
|
404
|
-
handlers,
|
|
405
|
-
logger,
|
|
406
|
-
signal,
|
|
407
|
-
onApiKeyUpdate: () => {},
|
|
408
|
-
});
|
|
409
|
-
void server.serve().catch((err) => {
|
|
410
|
-
server.close();
|
|
411
|
-
log.warn(
|
|
412
|
-
{ err },
|
|
413
|
-
"CES standalone connection ended with a transport error",
|
|
414
|
-
);
|
|
415
|
-
});
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
netServer.listen(socketPath, () => {
|
|
419
|
-
log.info(`CES standalone socket listening at ${socketPath}`);
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
signal.addEventListener(
|
|
423
|
-
"abort",
|
|
424
|
-
() => {
|
|
425
|
-
netServer.close();
|
|
426
|
-
try {
|
|
427
|
-
unlinkSync(socketPath);
|
|
428
|
-
} catch {
|
|
429
|
-
// already removed
|
|
430
|
-
}
|
|
431
|
-
},
|
|
432
|
-
{ once: true },
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
353
|
async function main(): Promise<void> {
|
|
437
354
|
ensureDataDirs();
|
|
438
355
|
|
|
439
356
|
initLogger({ dir: getCesLogDir(), retentionDays: 30 });
|
|
440
357
|
const log = getLogger("main");
|
|
441
358
|
|
|
442
|
-
// `CES_STANDALONE=1` runs CES as an independent, CLI-launched sibling over a
|
|
443
|
-
// Unix socket; otherwise CES is the assistant's stdio child, as today.
|
|
444
|
-
const standalone = process.env["CES_STANDALONE"] === "1";
|
|
445
|
-
|
|
446
359
|
log.info(
|
|
447
|
-
`Starting CES v${CES_PROTOCOL_VERSION} (local mode,
|
|
448
|
-
standalone ? "standalone socket" : "stdio"
|
|
449
|
-
} transport)`,
|
|
360
|
+
`Starting CES v${CES_PROTOCOL_VERSION} (local mode, stdio transport)`,
|
|
450
361
|
);
|
|
451
362
|
|
|
452
363
|
const controller = new AbortController();
|
|
@@ -479,42 +390,15 @@ async function main(): Promise<void> {
|
|
|
479
390
|
const handlers = buildHandlers(secureKeyBackend);
|
|
480
391
|
|
|
481
392
|
const rpcLog = getLogger("rpc");
|
|
482
|
-
const rpcLogger = {
|
|
483
|
-
log: (msg: string, ...args: unknown[]) => rpcLog.info({ args }, msg),
|
|
484
|
-
warn: (msg: string, ...args: unknown[]) => rpcLog.warn({ args }, msg),
|
|
485
|
-
error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
if (standalone) {
|
|
489
|
-
// Serve over a Unix socket and run until a shutdown signal — no stdio
|
|
490
|
-
// parent to anchor the lifecycle.
|
|
491
|
-
serveStandaloneSocket({
|
|
492
|
-
socketPath: getLocalSocketPath(),
|
|
493
|
-
handlers,
|
|
494
|
-
signal: controller.signal,
|
|
495
|
-
logger: rpcLogger,
|
|
496
|
-
log,
|
|
497
|
-
});
|
|
498
|
-
await new Promise<void>((resolve) => {
|
|
499
|
-
if (controller.signal.aborted) {
|
|
500
|
-
resolve();
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
controller.signal.addEventListener("abort", () => resolve(), {
|
|
504
|
-
once: true,
|
|
505
|
-
});
|
|
506
|
-
});
|
|
507
|
-
log.info("Server stopped.");
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// Default: serve the spawning assistant over stdio. stdin closing (parent
|
|
512
|
-
// exit) or SIGTERM shuts CES down.
|
|
513
393
|
const server = new CesRpcServer({
|
|
514
394
|
input: process.stdin,
|
|
515
395
|
output: process.stdout,
|
|
516
396
|
handlers,
|
|
517
|
-
logger:
|
|
397
|
+
logger: {
|
|
398
|
+
log: (msg: string, ...args: unknown[]) => rpcLog.info({ args }, msg),
|
|
399
|
+
warn: (msg: string, ...args: unknown[]) => rpcLog.warn({ args }, msg),
|
|
400
|
+
error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
|
|
401
|
+
},
|
|
518
402
|
signal: controller.signal,
|
|
519
403
|
// Local mode reads API keys from env/store directly — no-op handler so
|
|
520
404
|
// update_managed_credential is still registered and returns success.
|
package/src/paths.ts
CHANGED
|
@@ -140,33 +140,6 @@ export function getBootstrapSocketPath(): string {
|
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
// ---------------------------------------------------------------------------
|
|
144
|
-
// Local-mode standalone socket (temporary — CES_STANDALONE)
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
|
|
147
|
-
/** Default local-mode CES socket filename (under the local data root). */
|
|
148
|
-
const LOCAL_SOCKET_NAME = "ces.sock";
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Return the path to the local-mode CES Unix socket.
|
|
152
|
-
*
|
|
153
|
-
* Used when local CES runs as a standalone sibling (`CES_STANDALONE=1`, the
|
|
154
|
-
* CLI-launched opt-in) rather than as the assistant's stdio
|
|
155
|
-
* child. The socket lives under the CES-private local data root, whose
|
|
156
|
-
* directory permissions are the access boundary.
|
|
157
|
-
*
|
|
158
|
-
* Priority:
|
|
159
|
-
* 1. `CES_LOCAL_SOCKET` env var (full file path override; the CLI sets this
|
|
160
|
-
* when launching the sibling).
|
|
161
|
-
* 2. Default: `<localDataRoot>/ces.sock`.
|
|
162
|
-
*/
|
|
163
|
-
export function getLocalSocketPath(): string {
|
|
164
|
-
return (
|
|
165
|
-
process.env["CES_LOCAL_SOCKET"] ??
|
|
166
|
-
join(getCesDataRoot("local"), LOCAL_SOCKET_NAME)
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
143
|
// ---------------------------------------------------------------------------
|
|
171
144
|
// Health port (managed mode only)
|
|
172
145
|
// ---------------------------------------------------------------------------
|
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local CES standalone-sibling test (real entrypoint subprocess).
|
|
3
|
-
*
|
|
4
|
-
* Spawns the actual `main.ts` entrypoint with `CES_STANDALONE=1` and **stdin
|
|
5
|
-
* closed** — the way the CLI launches the sibling (the `CES_STANDALONE`
|
|
6
|
-
* opt-in) — and verifies that CES:
|
|
7
|
-
*
|
|
8
|
-
* 1. binds its Unix socket and serves RPC despite having no stdio parent
|
|
9
|
-
* (lifecycle anchored to SIGTERM, not stdin), and
|
|
10
|
-
* 2. survives a client disconnecting, and
|
|
11
|
-
* 3. shuts down on SIGTERM.
|
|
12
|
-
*
|
|
13
|
-
* Local mode has no TCP health server, so this runs without binding a TCP port.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
17
|
-
import { createConnection, type Socket } from "node:net";
|
|
18
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
19
|
-
import { tmpdir } from "node:os";
|
|
20
|
-
import { join, resolve } from "node:path";
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
CES_PROTOCOL_VERSION,
|
|
24
|
-
CesRpcMethod,
|
|
25
|
-
type HandshakeAck,
|
|
26
|
-
type RpcEnvelope,
|
|
27
|
-
type ListCredentialsResponse,
|
|
28
|
-
} from "@vellumai/service-contracts/credential-rpc";
|
|
29
|
-
|
|
30
|
-
import type { Subprocess } from "bun";
|
|
31
|
-
|
|
32
|
-
function delay(ms: number): Promise<void> {
|
|
33
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function waitForSocket(
|
|
37
|
-
socketPath: string,
|
|
38
|
-
timeoutMs = 10_000,
|
|
39
|
-
): Promise<void> {
|
|
40
|
-
const deadline = Date.now() + timeoutMs;
|
|
41
|
-
while (Date.now() < deadline) {
|
|
42
|
-
if (existsSync(socketPath)) return;
|
|
43
|
-
await delay(50);
|
|
44
|
-
}
|
|
45
|
-
throw new Error(`Standalone CES socket did not appear within ${timeoutMs}ms`);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function connectToSocket(
|
|
49
|
-
socketPath: string,
|
|
50
|
-
{ maxRetries = 40, baseDelayMs = 25 } = {},
|
|
51
|
-
): Promise<Socket> {
|
|
52
|
-
return new Promise((resolveConn, reject) => {
|
|
53
|
-
let attempt = 0;
|
|
54
|
-
const tryConnect = () => {
|
|
55
|
-
const sock = createConnection(socketPath, () => {
|
|
56
|
-
sock.removeAllListeners("error");
|
|
57
|
-
resolveConn(sock);
|
|
58
|
-
});
|
|
59
|
-
sock.on("error", (err: NodeJS.ErrnoException) => {
|
|
60
|
-
sock.destroy();
|
|
61
|
-
attempt++;
|
|
62
|
-
if (
|
|
63
|
-
attempt < maxRetries &&
|
|
64
|
-
(err.code === "ENOENT" || err.code === "ECONNREFUSED")
|
|
65
|
-
) {
|
|
66
|
-
setTimeout(tryConnect, baseDelayMs);
|
|
67
|
-
} else {
|
|
68
|
-
reject(err);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
};
|
|
72
|
-
tryConnect();
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Read one newline-delimited JSON message from the socket. */
|
|
77
|
-
function readOne<T>(sock: Socket, timeoutMs = 5_000): Promise<T> {
|
|
78
|
-
return new Promise((resolveMsg, reject) => {
|
|
79
|
-
let buffer = "";
|
|
80
|
-
const timer = setTimeout(() => {
|
|
81
|
-
sock.removeAllListeners("data");
|
|
82
|
-
reject(new Error("Timed out waiting for a message"));
|
|
83
|
-
}, timeoutMs);
|
|
84
|
-
const onData = (chunk: Buffer) => {
|
|
85
|
-
buffer += chunk.toString("utf-8");
|
|
86
|
-
const idx = buffer.indexOf("\n");
|
|
87
|
-
if (idx === -1) return;
|
|
88
|
-
clearTimeout(timer);
|
|
89
|
-
sock.removeListener("data", onData);
|
|
90
|
-
try {
|
|
91
|
-
resolveMsg(JSON.parse(buffer.slice(0, idx).trim()) as T);
|
|
92
|
-
} catch (err) {
|
|
93
|
-
reject(err as Error);
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
sock.on("data", onData);
|
|
97
|
-
sock.on("error", (err) => {
|
|
98
|
-
clearTimeout(timer);
|
|
99
|
-
reject(err);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function handshake(
|
|
105
|
-
sock: Socket,
|
|
106
|
-
sessionId: string,
|
|
107
|
-
): Promise<HandshakeAck> {
|
|
108
|
-
sock.write(
|
|
109
|
-
JSON.stringify({
|
|
110
|
-
type: "handshake_request",
|
|
111
|
-
protocolVersion: CES_PROTOCOL_VERSION,
|
|
112
|
-
sessionId,
|
|
113
|
-
}) + "\n",
|
|
114
|
-
);
|
|
115
|
-
return readOne<HandshakeAck>(sock);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
let tmpDir: string | undefined;
|
|
119
|
-
let proc: Subprocess | undefined;
|
|
120
|
-
|
|
121
|
-
afterEach(async () => {
|
|
122
|
-
if (proc) {
|
|
123
|
-
proc.kill("SIGTERM");
|
|
124
|
-
await Promise.race([proc.exited, delay(3_000)]);
|
|
125
|
-
proc = undefined;
|
|
126
|
-
}
|
|
127
|
-
if (tmpDir) {
|
|
128
|
-
try {
|
|
129
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
130
|
-
} catch {
|
|
131
|
-
/* ok */
|
|
132
|
-
}
|
|
133
|
-
tmpDir = undefined;
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe("local CES standalone sibling (real entrypoint)", () => {
|
|
138
|
-
test("serves over a socket with no stdio parent, survives disconnect, exits on SIGTERM", async () => {
|
|
139
|
-
tmpDir = mkdtempSync(join(tmpdir(), "ces-standalone-"));
|
|
140
|
-
const socketPath = join(tmpDir, "ces.sock");
|
|
141
|
-
const securityDir = join(tmpDir, "protected");
|
|
142
|
-
const workspaceDir = join(tmpDir, "workspace");
|
|
143
|
-
mkdirSync(securityDir, { recursive: true });
|
|
144
|
-
mkdirSync(workspaceDir, { recursive: true });
|
|
145
|
-
|
|
146
|
-
const localMain = resolve(__dirname, "..", "main.ts");
|
|
147
|
-
|
|
148
|
-
// CES_STANDALONE=1 + stdin closed is how the CLI launches the sibling.
|
|
149
|
-
proc = Bun.spawn({
|
|
150
|
-
cmd: [process.execPath, localMain],
|
|
151
|
-
env: {
|
|
152
|
-
...process.env,
|
|
153
|
-
CES_STANDALONE: "1",
|
|
154
|
-
CES_LOCAL_SOCKET: socketPath,
|
|
155
|
-
CREDENTIAL_SECURITY_DIR: securityDir,
|
|
156
|
-
VELLUM_WORKSPACE_DIR: workspaceDir,
|
|
157
|
-
},
|
|
158
|
-
stdin: "ignore",
|
|
159
|
-
stdout: "ignore",
|
|
160
|
-
stderr: "ignore",
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
await waitForSocket(socketPath);
|
|
164
|
-
expect(proc.killed).toBe(false);
|
|
165
|
-
|
|
166
|
-
// First client: handshake + a real RPC.
|
|
167
|
-
const first = await connectToSocket(socketPath);
|
|
168
|
-
const ack1 = await handshake(first, "sibling-1");
|
|
169
|
-
expect(ack1.accepted).toBe(true);
|
|
170
|
-
|
|
171
|
-
first.write(
|
|
172
|
-
JSON.stringify({
|
|
173
|
-
type: "rpc",
|
|
174
|
-
id: "rpc-1",
|
|
175
|
-
kind: "request",
|
|
176
|
-
method: CesRpcMethod.ListCredentials,
|
|
177
|
-
payload: {},
|
|
178
|
-
timestamp: new Date().toISOString(),
|
|
179
|
-
}) + "\n",
|
|
180
|
-
);
|
|
181
|
-
const rpcResp = await readOne<RpcEnvelope & { type: "rpc" }>(first);
|
|
182
|
-
expect((rpcResp.payload as ListCredentialsResponse).accounts).toEqual([]);
|
|
183
|
-
|
|
184
|
-
// Disconnect — CES must stay up.
|
|
185
|
-
first.destroy();
|
|
186
|
-
await delay(200);
|
|
187
|
-
expect(proc.killed).toBe(false);
|
|
188
|
-
|
|
189
|
-
// Reconnect proves it survived.
|
|
190
|
-
const second = await connectToSocket(socketPath);
|
|
191
|
-
const ack2 = await handshake(second, "sibling-2");
|
|
192
|
-
expect(ack2.accepted).toBe(true);
|
|
193
|
-
second.destroy();
|
|
194
|
-
|
|
195
|
-
// SIGTERM shuts it down.
|
|
196
|
-
proc.kill("SIGTERM");
|
|
197
|
-
const exited = await Promise.race([
|
|
198
|
-
proc.exited.then(() => true),
|
|
199
|
-
delay(5_000).then(() => false),
|
|
200
|
-
]);
|
|
201
|
-
expect(exited).toBe(true);
|
|
202
|
-
}, 30_000);
|
|
203
|
-
|
|
204
|
-
test("without CES_STANDALONE, exits when stdin closes (stdio child)", async () => {
|
|
205
|
-
tmpDir = mkdtempSync(join(tmpdir(), "ces-stdio-"));
|
|
206
|
-
const securityDir = join(tmpDir, "protected");
|
|
207
|
-
const workspaceDir = join(tmpDir, "workspace");
|
|
208
|
-
mkdirSync(securityDir, { recursive: true });
|
|
209
|
-
mkdirSync(workspaceDir, { recursive: true });
|
|
210
|
-
|
|
211
|
-
const localMain = resolve(__dirname, "..", "main.ts");
|
|
212
|
-
|
|
213
|
-
// Default mode: stdin "ignore" EOFs immediately, so the stdio server's
|
|
214
|
-
// input stream ends and CES shuts down on its own — the today behavior.
|
|
215
|
-
proc = Bun.spawn({
|
|
216
|
-
cmd: [process.execPath, localMain],
|
|
217
|
-
env: {
|
|
218
|
-
...process.env,
|
|
219
|
-
CREDENTIAL_SECURITY_DIR: securityDir,
|
|
220
|
-
VELLUM_WORKSPACE_DIR: workspaceDir,
|
|
221
|
-
},
|
|
222
|
-
stdin: "ignore",
|
|
223
|
-
stdout: "ignore",
|
|
224
|
-
stderr: "ignore",
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
const exited = await Promise.race([
|
|
228
|
-
proc.exited.then(() => true),
|
|
229
|
-
delay(10_000).then(() => false),
|
|
230
|
-
]);
|
|
231
|
-
expect(exited).toBe(true);
|
|
232
|
-
}, 20_000);
|
|
233
|
-
});
|