@vellumai/credential-executor 0.10.5 → 0.10.6-dev.202607062043.72bc40b
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 +0 -8
- package/node_modules/@vellumai/service-contracts/src/twilio-ingress.ts +3 -14
- package/package.json +1 -1
- package/src/__tests__/local-standalone.test.ts +233 -0
- package/src/__tests__/transport.test.ts +28 -11
- package/src/main.ts +140 -24
- package/src/paths.ts +27 -0
|
@@ -5,10 +5,8 @@ import {
|
|
|
5
5
|
normalizePublicBaseUrl,
|
|
6
6
|
} from "../ingress.js";
|
|
7
7
|
import {
|
|
8
|
-
buildTwilioConnectActionUrl,
|
|
9
8
|
buildTwilioMediaStreamUrl,
|
|
10
9
|
buildTwilioPhoneNumberWebhookUrls,
|
|
11
|
-
buildTwilioRelayUrl,
|
|
12
10
|
buildTwilioVoiceWebhookUrl,
|
|
13
11
|
resolveTwilioPublicBaseUrl,
|
|
14
12
|
} from "../twilio-ingress.js";
|
|
@@ -90,12 +88,6 @@ describe("Twilio ingress helpers", () => {
|
|
|
90
88
|
expect(buildTwilioVoiceWebhookUrl("https://example.test", "call-123")).toBe(
|
|
91
89
|
"https://example.test/webhooks/twilio/voice?callSessionId=call-123",
|
|
92
90
|
);
|
|
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
|
-
);
|
|
99
91
|
expect(buildTwilioMediaStreamUrl("http://example.test")).toBe(
|
|
100
92
|
"ws://example.test/webhooks/twilio/media-stream",
|
|
101
93
|
);
|
|
@@ -2,9 +2,6 @@ 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";
|
|
8
5
|
export const TWILIO_MEDIA_STREAM_WEBHOOK_PATH = "/webhooks/twilio/media-stream";
|
|
9
6
|
|
|
10
7
|
/**
|
|
@@ -13,9 +10,9 @@ export const TWILIO_MEDIA_STREAM_WEBHOOK_PATH = "/webhooks/twilio/media-stream";
|
|
|
13
10
|
* with the actual public URL (from Velay registration, config, or the
|
|
14
11
|
* `X-Vellum-Ingress-URL` header) before returning TwiML to Twilio.
|
|
15
12
|
*
|
|
16
|
-
* The placeholder uses `https://` so that `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* The placeholder uses `https://` so that `buildTwilioMediaStreamUrl` can
|
|
14
|
+
* apply the standard `http→ws` scheme conversion, producing
|
|
15
|
+
* `wss://__VELLUM_PUBLIC_BASE_URL__/…` in the output.
|
|
19
16
|
*/
|
|
20
17
|
export const TWILIO_PUBLIC_BASE_URL_PLACEHOLDER =
|
|
21
18
|
"https://__VELLUM_PUBLIC_BASE_URL__";
|
|
@@ -58,14 +55,6 @@ export function buildTwilioStatusWebhookUrl(baseUrl: string): string {
|
|
|
58
55
|
return `${baseUrl}${TWILIO_STATUS_WEBHOOK_PATH}`;
|
|
59
56
|
}
|
|
60
57
|
|
|
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
|
-
|
|
69
58
|
export function buildTwilioMediaStreamUrl(baseUrl: string): string {
|
|
70
59
|
return `${toTwilioWebSocketBaseUrl(baseUrl)}${TWILIO_MEDIA_STREAM_WEBHOOK_PATH}`;
|
|
71
60
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
});
|
|
@@ -23,7 +23,11 @@ import {
|
|
|
23
23
|
type RpcEnvelope,
|
|
24
24
|
} from "@vellumai/service-contracts/credential-rpc";
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
getCesDataRoot,
|
|
28
|
+
getBootstrapSocketPath,
|
|
29
|
+
getHealthPort,
|
|
30
|
+
} from "../paths.js";
|
|
27
31
|
import { CesRpcServer, type RpcHandlerRegistry } from "../server.js";
|
|
28
32
|
|
|
29
33
|
// ---------------------------------------------------------------------------
|
|
@@ -104,7 +108,16 @@ function createTestServer(handlers: RpcHandlerRegistry = {}) {
|
|
|
104
108
|
return JSON.parse(lines[lines.length - 1]) as RpcEnvelope;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
server,
|
|
113
|
+
input,
|
|
114
|
+
output,
|
|
115
|
+
send,
|
|
116
|
+
collectOutputLines,
|
|
117
|
+
handshake,
|
|
118
|
+
rpc,
|
|
119
|
+
logs,
|
|
120
|
+
};
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
// ---------------------------------------------------------------------------
|
|
@@ -194,15 +207,15 @@ describe("health probes", () => {
|
|
|
194
207
|
// ---------------------------------------------------------------------------
|
|
195
208
|
|
|
196
209
|
describe("local entrypoint transport isolation", () => {
|
|
197
|
-
test("main.ts
|
|
210
|
+
test("main.ts serves over stdio or a Unix socket, never a TCP listener", () => {
|
|
198
211
|
const src = readFileSync(resolve(__dirname, "..", "main.ts"), "utf-8");
|
|
199
|
-
//
|
|
212
|
+
// Serves the stdio-child transport (default mode).
|
|
200
213
|
expect(src).toMatch(/process\.stdin/);
|
|
201
214
|
expect(src).toMatch(/process\.stdout/);
|
|
202
|
-
//
|
|
215
|
+
// Standalone mode (CES_STANDALONE=1) listens on a Unix socket path only —
|
|
216
|
+
// never a numeric TCP port — and never opens an HTTP server.
|
|
203
217
|
expect(src).not.toMatch(/Bun\.serve\(/);
|
|
204
|
-
expect(src).not.toMatch(
|
|
205
|
-
expect(src).not.toMatch(/\.listen\(/);
|
|
218
|
+
expect(src).not.toMatch(/\.listen\(\d+/);
|
|
206
219
|
});
|
|
207
220
|
|
|
208
221
|
test("main.ts logs to stderr, not stdout (avoids polluting transport)", () => {
|
|
@@ -310,9 +323,10 @@ describe("CesRpcServer", () => {
|
|
|
310
323
|
|
|
311
324
|
const resp = await rpc("nonexistent_method", {});
|
|
312
325
|
expect(resp.kind).toBe("response");
|
|
313
|
-
expect(
|
|
314
|
-
|
|
315
|
-
|
|
326
|
+
expect(
|
|
327
|
+
(resp.payload as { success: boolean; error: { code: string } }).error
|
|
328
|
+
.code,
|
|
329
|
+
).toBe("METHOD_NOT_FOUND");
|
|
316
330
|
|
|
317
331
|
server.close();
|
|
318
332
|
input.end();
|
|
@@ -332,7 +346,10 @@ describe("CesRpcServer", () => {
|
|
|
332
346
|
|
|
333
347
|
const resp = await rpc("fail_method", {});
|
|
334
348
|
expect(resp.kind).toBe("response");
|
|
335
|
-
const payload = resp.payload as {
|
|
349
|
+
const payload = resp.payload as {
|
|
350
|
+
success: boolean;
|
|
351
|
+
error: { code: string; message: string };
|
|
352
|
+
};
|
|
336
353
|
expect(payload.error.code).toBe("HANDLER_ERROR");
|
|
337
354
|
expect(payload.error.message).toMatch(/Intentional test failure/);
|
|
338
355
|
|
package/src/main.ts
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Local CES entrypoint.
|
|
3
|
+
* Local CES entrypoint. Two run modes:
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* over stdin/stdout
|
|
5
|
+
* - **stdio child (default):** the assistant spawns CES as a child process and
|
|
6
|
+
* communicates over stdin/stdout. CES shuts down when stdin closes (parent
|
|
7
|
+
* exit) or on SIGTERM. This is today's behavior.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* - **standalone sibling (`CES_STANDALONE=1`):** CES is launched independently
|
|
10
|
+
* by the CLI (the opt-in), serves RPC over a
|
|
11
|
+
* Unix socket (`getLocalSocketPath()`), and runs until SIGTERM — no stdio.
|
|
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.
|
|
11
14
|
*
|
|
12
|
-
* Local mode never opens a TCP listener
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
15
|
+
* Local mode never opens a TCP listener. Neither the stdio transport nor the
|
|
16
|
+
* Unix socket's listening fd is inherited by shell subprocesses spawned by CES
|
|
17
|
+
* (e.g. for `run_authenticated_command`): Bun's `Bun.spawn` defaults to "pipe"
|
|
18
|
+
* for stdio, and the listening socket is not passed to those subprocesses.
|
|
20
19
|
*/
|
|
21
20
|
|
|
22
|
-
import { mkdirSync } from "node:fs";
|
|
21
|
+
import { mkdirSync, unlinkSync } from "node:fs";
|
|
22
|
+
import { createServer as createNetServer, type Socket } from "node:net";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
25
|
+
import { Readable, Writable } from "node:stream";
|
|
25
26
|
|
|
26
27
|
import {
|
|
27
28
|
CES_PROTOCOL_VERSION,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
getCesGrantsDir,
|
|
53
54
|
getCesLogDir,
|
|
54
55
|
getCesToolStoreDir,
|
|
56
|
+
getLocalSocketPath,
|
|
55
57
|
} from "./paths.js";
|
|
56
58
|
import {
|
|
57
59
|
buildHandlersWithHttp,
|
|
@@ -122,9 +124,7 @@ function getSecurityDir(): string {
|
|
|
122
124
|
// Build RPC handler registry
|
|
123
125
|
// ---------------------------------------------------------------------------
|
|
124
126
|
|
|
125
|
-
function buildHandlers(
|
|
126
|
-
secureKeyBackend: SecureKeyBackend,
|
|
127
|
-
): RpcHandlerRegistry {
|
|
127
|
+
function buildHandlers(secureKeyBackend: SecureKeyBackend): RpcHandlerRegistry {
|
|
128
128
|
// -- Grant stores ----------------------------------------------------------
|
|
129
129
|
const persistentGrantStore = new PersistentGrantStore(
|
|
130
130
|
getCesGrantsDir("local"),
|
|
@@ -350,14 +350,103 @@ function buildHandlers(
|
|
|
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
|
+
|
|
353
436
|
async function main(): Promise<void> {
|
|
354
437
|
ensureDataDirs();
|
|
355
438
|
|
|
356
439
|
initLogger({ dir: getCesLogDir(), retentionDays: 30 });
|
|
357
440
|
const log = getLogger("main");
|
|
358
441
|
|
|
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
|
+
|
|
359
446
|
log.info(
|
|
360
|
-
`Starting CES v${CES_PROTOCOL_VERSION} (local mode,
|
|
447
|
+
`Starting CES v${CES_PROTOCOL_VERSION} (local mode, ${
|
|
448
|
+
standalone ? "standalone socket" : "stdio"
|
|
449
|
+
} transport)`,
|
|
361
450
|
);
|
|
362
451
|
|
|
363
452
|
const controller = new AbortController();
|
|
@@ -390,15 +479,42 @@ async function main(): Promise<void> {
|
|
|
390
479
|
const handlers = buildHandlers(secureKeyBackend);
|
|
391
480
|
|
|
392
481
|
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.
|
|
393
513
|
const server = new CesRpcServer({
|
|
394
514
|
input: process.stdin,
|
|
395
515
|
output: process.stdout,
|
|
396
516
|
handlers,
|
|
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
|
-
},
|
|
517
|
+
logger: rpcLogger,
|
|
402
518
|
signal: controller.signal,
|
|
403
519
|
// Local mode reads API keys from env/store directly — no-op handler so
|
|
404
520
|
// update_managed_credential is still registered and returns success.
|
package/src/paths.ts
CHANGED
|
@@ -140,6 +140,33 @@ 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
|
+
|
|
143
170
|
// ---------------------------------------------------------------------------
|
|
144
171
|
// Health port (managed mode only)
|
|
145
172
|
// ---------------------------------------------------------------------------
|