@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.
@@ -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 `buildTwilioRelayUrl` /
17
- * `buildTwilioMediaStreamUrl` can apply the standard `http→ws` scheme
18
- * conversion, producing `wss://__VELLUM_PUBLIC_BASE_URL__/…` in the output.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.10.5",
3
+ "version": "0.10.6-dev.202607062043.72bc40b",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 { getCesDataRoot, getBootstrapSocketPath, getHealthPort } from "../paths.js";
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 { server, input, output, send, collectOutputLines, handshake, rpc, logs };
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 uses process.stdin/stdout, not TCP listeners", () => {
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
- // Uses stdin/stdout for transport
212
+ // Serves the stdio-child transport (default mode).
200
213
  expect(src).toMatch(/process\.stdin/);
201
214
  expect(src).toMatch(/process\.stdout/);
202
- // Does not open any TCP or Unix socket listener
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(/createServer\(/);
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((resp.payload as { success: boolean; error: { code: string } }).error.code).toBe(
314
- "METHOD_NOT_FOUND",
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 { success: boolean; error: { code: string; message: string } };
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
- * In local mode the assistant spawns CES as a child process and communicates
6
- * over stdin/stdout using newline-delimited JSON. This entrypoint:
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
- * 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.
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 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.
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, stdio transport)`,
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
  // ---------------------------------------------------------------------------