@vellumai/credential-executor 0.11.2-dev.202608060701.ede4331 → 0.11.2-dev.202608062308.5442cf1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +1 -0
- package/knip.json +2 -1
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +4 -2
- package/src/main.ts +11 -12
package/Dockerfile
CHANGED
|
@@ -56,6 +56,7 @@ COPY --from=builder --chown=ces:ces /app /app
|
|
|
56
56
|
COPY --chown=ces:ces packages/service-contracts /app/packages/service-contracts
|
|
57
57
|
COPY --chown=ces:ces packages/credential-storage /app/packages/credential-storage
|
|
58
58
|
COPY --chown=ces:ces packages/egress-proxy /app/packages/egress-proxy
|
|
59
|
+
COPY --chown=ces:ces packages/ipc-server-utils /app/packages/ipc-server-utils
|
|
59
60
|
COPY --chown=ces:ces credential-executor ./
|
|
60
61
|
|
|
61
62
|
# Pre-create /ces-data so the non-root ces user can write to it
|
package/knip.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vellumai/ipc-server-utils",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "bunx tsc --noEmit",
|
|
12
|
+
"test": "bun test src/"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/bun": "1.3.10",
|
|
16
|
+
"typescript": "5.9.3"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { removeIpcEndpointFile, resolveIpcEndpoint } from "./endpoint.js";
|
|
6
|
+
const windowsEndpoint = (name: string, workspaceDir: string) => resolveIpcEndpoint(name, { workspaceDir, platform: "win32" });
|
|
7
|
+
describe("resolveIpcEndpoint", () => {
|
|
8
|
+
test("keeps long POSIX fallbacks deterministic and bounded", () => {
|
|
9
|
+
const options = {
|
|
10
|
+
workspaceDir: `/tmp/${"a".repeat(120)}`,
|
|
11
|
+
platform: "darwin" as const,
|
|
12
|
+
};
|
|
13
|
+
const endpoint = resolveIpcEndpoint("assistant", options);
|
|
14
|
+
expect(resolveIpcEndpoint("assistant", options)).toEqual(endpoint);
|
|
15
|
+
expect(Buffer.byteLength(endpoint.path)).toBeLessThanOrEqual(103);
|
|
16
|
+
});
|
|
17
|
+
test("returns normalized, isolated, bounded Windows pipes", () => {
|
|
18
|
+
const first = windowsEndpoint("assistant", "C:\\one");
|
|
19
|
+
expect(resolveIpcEndpoint("assistant", { workspaceDir: "c:\\ONE\\", platform: "win32", env: { ASSISTANT_IPC_SOCKET_DIR: "C:\\ignored" } })).toEqual(first);
|
|
20
|
+
expect(first.path.length).toBeLessThanOrEqual(256);
|
|
21
|
+
expect(windowsEndpoint("assistant", "C:\\two").path).not.toBe(first.path);
|
|
22
|
+
expect(windowsEndpoint("gateway", "C:\\one").path).not.toBe(first.path);
|
|
23
|
+
});
|
|
24
|
+
test("cleans socket files but skips named pipes", () => {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-ipc-cleanup-"));
|
|
26
|
+
const socketPath = join(dir, "assistant.sock");
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(socketPath, "stale");
|
|
29
|
+
removeIpcEndpointFile(socketPath);
|
|
30
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
31
|
+
removeIpcEndpointFile("\\\\.\\pipe\\vellum-assistant-test");
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { posix, win32 } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DARWIN_UNIX_SOCKET_MAX_PATH_BYTES = 103;
|
|
7
|
+
const DEFAULT_UNIX_SOCKET_MAX_PATH_BYTES = 107;
|
|
8
|
+
const IPC_TMP_DIR_NAME = "vellum-ipc";
|
|
9
|
+
const join = posix.join;
|
|
10
|
+
|
|
11
|
+
export type IpcSocketPathSource =
|
|
12
|
+
| "env-override"
|
|
13
|
+
| "workspace"
|
|
14
|
+
| "tmp-hash"
|
|
15
|
+
| "tmp-short-hash"
|
|
16
|
+
| "windows-named-pipe";
|
|
17
|
+
|
|
18
|
+
export interface IpcSocketPathResolution {
|
|
19
|
+
path: string;
|
|
20
|
+
source: IpcSocketPathSource;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IpcEndpointOptions {
|
|
24
|
+
workspaceDir: string;
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
26
|
+
platform?: NodeJS.Platform;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getUnixSocketMaxPathBytes(platform: NodeJS.Platform): number {
|
|
30
|
+
return platform === "darwin"
|
|
31
|
+
? DARWIN_UNIX_SOCKET_MAX_PATH_BYTES
|
|
32
|
+
: DEFAULT_UNIX_SOCKET_MAX_PATH_BYTES;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPathWithinSocketLimit(path: string, maxPathBytes: number): boolean {
|
|
36
|
+
return Buffer.byteLength(path, "utf8") <= maxPathBytes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Derive the env var name and socket filename from a socket name.
|
|
41
|
+
*
|
|
42
|
+
* Examples (hyphens in the name become underscores in the env var):
|
|
43
|
+
*/
|
|
44
|
+
function deriveSocketNames(socketName: string): {
|
|
45
|
+
envVar: string;
|
|
46
|
+
fileName: string;
|
|
47
|
+
} {
|
|
48
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(socketName)) {
|
|
49
|
+
throw new Error(`Invalid IPC endpoint name: ${socketName}`);
|
|
50
|
+
}
|
|
51
|
+
const envVar = `${socketName.toUpperCase().replace(/-/g, "_")}_IPC_SOCKET_DIR`;
|
|
52
|
+
const fileName = `${socketName}.sock`;
|
|
53
|
+
return { envVar, fileName };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveWindowsNamedPipe(
|
|
57
|
+
socketName: string,
|
|
58
|
+
workspaceDir: string,
|
|
59
|
+
): IpcSocketPathResolution {
|
|
60
|
+
const workspaceIdentity = win32
|
|
61
|
+
.normalize(workspaceDir)
|
|
62
|
+
.replace(/\\+$/, "")
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
const hash = createHash("sha256")
|
|
65
|
+
.update(`${workspaceIdentity}\0${socketName}`)
|
|
66
|
+
.digest("hex")
|
|
67
|
+
.slice(0, 24);
|
|
68
|
+
return {
|
|
69
|
+
path: `\\\\.\\pipe\\vellum-${socketName}-${hash}`,
|
|
70
|
+
source: "windows-named-pipe",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isNamedPipePath(endpointPath: string): boolean {
|
|
75
|
+
return /^\\\\[.?]\\pipe\\/.test(endpointPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function removeIpcEndpointFile(endpointPath: string): void {
|
|
79
|
+
if (isNamedPipePath(endpointPath)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(endpointPath);
|
|
84
|
+
} catch {
|
|
85
|
+
// Already absent.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the path to an IPC socket file.
|
|
91
|
+
*
|
|
92
|
+
* Resolution order:
|
|
93
|
+
* POSIX uses overrides, workspace paths, then bounded temporary paths.
|
|
94
|
+
* Windows uses deterministic named pipes.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveIpcEndpoint(
|
|
97
|
+
socketName: string,
|
|
98
|
+
options: IpcEndpointOptions,
|
|
99
|
+
): IpcSocketPathResolution {
|
|
100
|
+
const { envVar, fileName } = deriveSocketNames(socketName);
|
|
101
|
+
const platform = options.platform ?? process.platform;
|
|
102
|
+
if (platform === "win32") {
|
|
103
|
+
return resolveWindowsNamedPipe(socketName, options.workspaceDir);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Explicit override via env var.
|
|
107
|
+
const envSocketDir = (options.env ?? process.env)[envVar]?.trim();
|
|
108
|
+
if (envSocketDir) {
|
|
109
|
+
return {
|
|
110
|
+
path: join(envSocketDir, fileName),
|
|
111
|
+
source: "env-override",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const maxPathBytes = getUnixSocketMaxPathBytes(platform);
|
|
116
|
+
const workspacePath = join(options.workspaceDir, fileName);
|
|
117
|
+
|
|
118
|
+
if (isPathWithinSocketLimit(workspacePath, maxPathBytes)) {
|
|
119
|
+
return {
|
|
120
|
+
path: workspacePath,
|
|
121
|
+
source: "workspace",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Workspace path exceeds AF_UNIX limit - fall back to tmpdir.
|
|
126
|
+
const hash = createHash("sha256")
|
|
127
|
+
.update(workspacePath)
|
|
128
|
+
.digest("hex")
|
|
129
|
+
.slice(0, 12);
|
|
130
|
+
const hashedPath = join(tmpdir(), IPC_TMP_DIR_NAME, `${hash}-${fileName}`);
|
|
131
|
+
if (isPathWithinSocketLimit(hashedPath, maxPathBytes)) {
|
|
132
|
+
return {
|
|
133
|
+
path: hashedPath,
|
|
134
|
+
source: "tmp-hash",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
path: join(tmpdir(), `v-${hash}.sock`),
|
|
140
|
+
source: "tmp-short-hash",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SocketWatchdog,
|
|
3
|
+
ensureSocketDir,
|
|
4
|
+
type SocketWatchdogOptions,
|
|
5
|
+
type SocketWatchdogLogger,
|
|
6
|
+
} from "./socket-watchdog.js";
|
|
7
|
+
export { isNamedPipePath, removeIpcEndpointFile, resolveIpcEndpoint } from "./endpoint.js";
|
|
8
|
+
export { ipcListenOptions } from "./listen-options.js";
|
|
9
|
+
export {
|
|
10
|
+
IpcFrameReader,
|
|
11
|
+
writeLegacyMessage,
|
|
12
|
+
writeMessage,
|
|
13
|
+
writeStreamChunk,
|
|
14
|
+
writeStreamEnd,
|
|
15
|
+
type IpcEnvelope,
|
|
16
|
+
type OnMessageCallback,
|
|
17
|
+
type StreamCallbacks,
|
|
18
|
+
} from "./ipc-framing.js";
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Length-prefixed binary framing for the IPC protocol.
|
|
3
|
+
*
|
|
4
|
+
* Wire format: [4-byte big-endian length][payload bytes]
|
|
5
|
+
*
|
|
6
|
+
* Messages use a JSON envelope. When the envelope's `headers` map contains
|
|
7
|
+
* a `content-length` key, a single binary data frame immediately follows
|
|
8
|
+
* the JSON frame.
|
|
9
|
+
*
|
|
10
|
+
* Chunked streaming: when `headers["transfer-encoding"]` is `"chunked"`,
|
|
11
|
+
* multiple binary data frames follow the JSON envelope. A zero-length
|
|
12
|
+
* frame (4 bytes of 0x00) terminates the stream. This enables streaming
|
|
13
|
+
* responses (e.g. audio, SSE) over IPC without buffering the full payload.
|
|
14
|
+
*
|
|
15
|
+
* Backward compatibility: the reader detects legacy newline-delimited JSON
|
|
16
|
+
* by checking if the first byte is `{` (0x7B). New-format frames always
|
|
17
|
+
* start with a 4-byte length prefix whose first byte is < 0x7B for any
|
|
18
|
+
* realistic message size (< 2 GB).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Socket } from "node:net";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface IpcEnvelope {
|
|
28
|
+
id: string;
|
|
29
|
+
// Request fields
|
|
30
|
+
method?: string;
|
|
31
|
+
params?: Record<string, unknown>;
|
|
32
|
+
// Response fields
|
|
33
|
+
result?: unknown;
|
|
34
|
+
error?: string;
|
|
35
|
+
// Shared — when headers["content-length"] is present, a binary frame follows.
|
|
36
|
+
// When headers["transfer-encoding"] is "chunked", multiple binary frames
|
|
37
|
+
// follow until a zero-length terminator.
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Writing
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Write a length-prefixed frame to a socket. */
|
|
46
|
+
function writeFrame(socket: Socket, data: Buffer | Uint8Array): void {
|
|
47
|
+
const header = Buffer.alloc(4);
|
|
48
|
+
header.writeUInt32BE(data.length, 0);
|
|
49
|
+
socket.write(header);
|
|
50
|
+
socket.write(data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write an IPC envelope, optionally followed by a binary data frame.
|
|
55
|
+
* If `binary` is provided, the envelope's headers must include content-length.
|
|
56
|
+
*/
|
|
57
|
+
export function writeMessage(
|
|
58
|
+
socket: Socket,
|
|
59
|
+
envelope: IpcEnvelope,
|
|
60
|
+
binary?: Uint8Array,
|
|
61
|
+
): void {
|
|
62
|
+
const json = Buffer.from(JSON.stringify(envelope), "utf-8");
|
|
63
|
+
writeFrame(socket, json);
|
|
64
|
+
if (binary) {
|
|
65
|
+
writeFrame(socket, binary);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Write a legacy newline-delimited JSON message.
|
|
71
|
+
* Used when the client connected with the legacy protocol.
|
|
72
|
+
*/
|
|
73
|
+
export function writeLegacyMessage(
|
|
74
|
+
socket: Socket,
|
|
75
|
+
envelope: IpcEnvelope,
|
|
76
|
+
): void {
|
|
77
|
+
socket.write(JSON.stringify(envelope) + "\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write a single chunk in a chunked transfer stream.
|
|
82
|
+
* The envelope must have already been sent with transfer-encoding: chunked.
|
|
83
|
+
*/
|
|
84
|
+
export function writeStreamChunk(socket: Socket, chunk: Uint8Array): void {
|
|
85
|
+
writeFrame(socket, chunk);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Write a zero-length frame to signal the end of a chunked transfer stream.
|
|
90
|
+
*/
|
|
91
|
+
export function writeStreamEnd(socket: Socket): void {
|
|
92
|
+
const terminator = Buffer.alloc(4); // 4 bytes of 0x00 = length 0
|
|
93
|
+
socket.write(terminator);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Reading
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** Callback for complete messages (non-streaming). */
|
|
101
|
+
export type OnMessageCallback = (
|
|
102
|
+
envelope: IpcEnvelope,
|
|
103
|
+
binary: Uint8Array | undefined,
|
|
104
|
+
) => void;
|
|
105
|
+
|
|
106
|
+
/** Callbacks for chunked streaming responses. */
|
|
107
|
+
export interface StreamCallbacks {
|
|
108
|
+
onStreamStart: (envelope: IpcEnvelope) => void;
|
|
109
|
+
onStreamChunk: (chunk: Uint8Array) => void;
|
|
110
|
+
onStreamEnd: () => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Streaming reader that accumulates socket data and emits parsed messages.
|
|
115
|
+
* Handles both legacy newline-delimited JSON and new length-prefixed frames.
|
|
116
|
+
*
|
|
117
|
+
* Supports three response modes:
|
|
118
|
+
* 1. JSON-only: envelope with no binary follow-up
|
|
119
|
+
* 2. Binary: envelope with content-length → single binary frame
|
|
120
|
+
* 3. Chunked: envelope with transfer-encoding: chunked → multiple binary
|
|
121
|
+
* frames terminated by a zero-length frame
|
|
122
|
+
*/
|
|
123
|
+
export class IpcFrameReader {
|
|
124
|
+
private buffer = Buffer.alloc(0);
|
|
125
|
+
private onMessage: OnMessageCallback;
|
|
126
|
+
private onError: (err: Error) => void;
|
|
127
|
+
private streamCallbacks: StreamCallbacks | undefined;
|
|
128
|
+
|
|
129
|
+
// State machine for length-prefixed reading
|
|
130
|
+
private state:
|
|
131
|
+
| "detect"
|
|
132
|
+
| "read-length"
|
|
133
|
+
| "read-payload"
|
|
134
|
+
| "read-binary"
|
|
135
|
+
| "read-stream-chunk-length"
|
|
136
|
+
| "read-stream-chunk" = "detect";
|
|
137
|
+
private pendingLength = 0;
|
|
138
|
+
private pendingEnvelope: IpcEnvelope | null = null;
|
|
139
|
+
private expectBinary = false;
|
|
140
|
+
|
|
141
|
+
/** Whether this connection uses the legacy newline-delimited protocol. */
|
|
142
|
+
isLegacy = false;
|
|
143
|
+
|
|
144
|
+
constructor(
|
|
145
|
+
onMessage: OnMessageCallback,
|
|
146
|
+
onError?: (err: Error) => void,
|
|
147
|
+
streamCallbacks?: StreamCallbacks,
|
|
148
|
+
) {
|
|
149
|
+
this.onMessage = onMessage;
|
|
150
|
+
this.onError = onError ?? (() => {});
|
|
151
|
+
this.streamCallbacks = streamCallbacks;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Feed incoming socket data into the reader. */
|
|
155
|
+
push(chunk: Buffer): void {
|
|
156
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
157
|
+
this.drain();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private drain(): void {
|
|
161
|
+
while (true) {
|
|
162
|
+
if (this.state === "detect") {
|
|
163
|
+
if (this.buffer.length === 0) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Legacy detection: first byte is '{' (0x7B)
|
|
167
|
+
if (this.buffer[0] === 0x7b) {
|
|
168
|
+
this.isLegacy = true;
|
|
169
|
+
this.drainLegacy();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// New format — fall through to read-length
|
|
173
|
+
this.state = "read-length";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this.state === "read-length") {
|
|
177
|
+
if (this.buffer.length < 4) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this.pendingLength = this.buffer.readUInt32BE(0);
|
|
181
|
+
this.buffer = this.buffer.subarray(4);
|
|
182
|
+
this.state = this.expectBinary ? "read-binary" : "read-payload";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (this.state === "read-payload") {
|
|
186
|
+
if (this.buffer.length < this.pendingLength) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const payload = this.buffer.subarray(0, this.pendingLength);
|
|
190
|
+
this.buffer = this.buffer.subarray(this.pendingLength);
|
|
191
|
+
|
|
192
|
+
let envelope: IpcEnvelope;
|
|
193
|
+
try {
|
|
194
|
+
envelope = JSON.parse(payload.toString("utf-8")) as IpcEnvelope;
|
|
195
|
+
} catch {
|
|
196
|
+
this.onError(new Error("Invalid JSON in IPC frame"));
|
|
197
|
+
this.state = "detect";
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const transferEncoding = envelope.headers?.["transfer-encoding"];
|
|
202
|
+
if (transferEncoding === "chunked") {
|
|
203
|
+
// Chunked streaming — emit start, then read chunks until terminator
|
|
204
|
+
this.pendingEnvelope = envelope;
|
|
205
|
+
this.streamCallbacks?.onStreamStart(envelope);
|
|
206
|
+
this.state = "read-stream-chunk-length";
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const contentLength = envelope.headers?.["content-length"];
|
|
211
|
+
if (contentLength != null) {
|
|
212
|
+
// Binary frame follows
|
|
213
|
+
this.pendingEnvelope = envelope;
|
|
214
|
+
this.expectBinary = true;
|
|
215
|
+
this.state = "read-length";
|
|
216
|
+
} else {
|
|
217
|
+
this.onMessage(envelope, undefined);
|
|
218
|
+
this.expectBinary = false;
|
|
219
|
+
this.state = "detect";
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (this.state === "read-binary") {
|
|
225
|
+
if (this.buffer.length < this.pendingLength) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const binary = new Uint8Array(
|
|
229
|
+
this.buffer.subarray(0, this.pendingLength),
|
|
230
|
+
);
|
|
231
|
+
this.buffer = this.buffer.subarray(this.pendingLength);
|
|
232
|
+
|
|
233
|
+
this.onMessage(this.pendingEnvelope!, binary);
|
|
234
|
+
this.pendingEnvelope = null;
|
|
235
|
+
this.expectBinary = false;
|
|
236
|
+
this.state = "detect";
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Chunked streaming states
|
|
241
|
+
if (this.state === "read-stream-chunk-length") {
|
|
242
|
+
if (this.buffer.length < 4) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.pendingLength = this.buffer.readUInt32BE(0);
|
|
246
|
+
this.buffer = this.buffer.subarray(4);
|
|
247
|
+
|
|
248
|
+
if (this.pendingLength === 0) {
|
|
249
|
+
// Zero-length frame = end of stream
|
|
250
|
+
this.streamCallbacks?.onStreamEnd();
|
|
251
|
+
this.pendingEnvelope = null;
|
|
252
|
+
this.state = "detect";
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.state = "read-stream-chunk";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (this.state === "read-stream-chunk") {
|
|
260
|
+
if (this.buffer.length < this.pendingLength) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const chunk = new Uint8Array(
|
|
264
|
+
this.buffer.subarray(0, this.pendingLength),
|
|
265
|
+
);
|
|
266
|
+
this.buffer = this.buffer.subarray(this.pendingLength);
|
|
267
|
+
|
|
268
|
+
this.streamCallbacks?.onStreamChunk(chunk);
|
|
269
|
+
this.state = "read-stream-chunk-length";
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Legacy mode: parse newline-delimited JSON lines.
|
|
277
|
+
* Once we enter legacy mode, we stay in it for the lifetime of the connection.
|
|
278
|
+
*/
|
|
279
|
+
private drainLegacy(): void {
|
|
280
|
+
let newlineIdx: number;
|
|
281
|
+
while ((newlineIdx = this.buffer.indexOf(0x0a)) !== -1) {
|
|
282
|
+
const line = this.buffer.subarray(0, newlineIdx).toString("utf-8").trim();
|
|
283
|
+
this.buffer = this.buffer.subarray(newlineIdx + 1);
|
|
284
|
+
if (!line) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const envelope = JSON.parse(line) as IpcEnvelope;
|
|
289
|
+
this.onMessage(envelope, undefined);
|
|
290
|
+
} catch {
|
|
291
|
+
this.onError(new Error("Invalid JSON in legacy IPC line"));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
test,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { SocketWatchdog, type SocketWatchdogLogger } from "./socket-watchdog.js";
|
|
20
|
+
|
|
21
|
+
// macOS caps Unix-socket paths at sizeof(sun_path)-1 == 103 bytes, so the
|
|
22
|
+
// shared test-preload temp dir is too long. Mint a short path under tmpdir
|
|
23
|
+
// for these tests.
|
|
24
|
+
const shortRoot = mkdtempSync(join(tmpdir(), "vmw-"));
|
|
25
|
+
const socketPath = join(shortRoot, "g.sock");
|
|
26
|
+
|
|
27
|
+
afterAll(() => {
|
|
28
|
+
try {
|
|
29
|
+
rmSync(shortRoot, { recursive: true, force: true });
|
|
30
|
+
} catch {
|
|
31
|
+
// best-effort
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
interface TestHarness {
|
|
36
|
+
watchdog: SocketWatchdog;
|
|
37
|
+
/** Mutated by tests to simulate stop()/restart. */
|
|
38
|
+
serverRef: { current: Server | null };
|
|
39
|
+
/** Servers handed to onRebind, captured for assertions + cleanup. */
|
|
40
|
+
rebinds: Array<{ newServer: Server; oldServer: Server }>;
|
|
41
|
+
log: SocketWatchdogLogger;
|
|
42
|
+
loggedErrors: Array<{ obj: object; msg?: string }>;
|
|
43
|
+
/** Tracks every server the harness factory produced, for cleanup. */
|
|
44
|
+
spawnedServers: Server[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BuildOptions {
|
|
48
|
+
intervalMs?: number;
|
|
49
|
+
socketPathOverride?: string;
|
|
50
|
+
createServerOverride?: () => Server;
|
|
51
|
+
/** Override `getServer` to simulate races. */
|
|
52
|
+
getServerOverride?: () => Server | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildHarness(opts: BuildOptions): TestHarness {
|
|
56
|
+
const serverRef: { current: Server | null } = { current: null };
|
|
57
|
+
const rebinds: Array<{ newServer: Server; oldServer: Server }> = [];
|
|
58
|
+
const loggedErrors: Array<{ obj: object; msg?: string }> = [];
|
|
59
|
+
const spawnedServers: Server[] = [];
|
|
60
|
+
|
|
61
|
+
const log: SocketWatchdogLogger = {
|
|
62
|
+
info: () => {},
|
|
63
|
+
warn: () => {},
|
|
64
|
+
error: (obj, msg) => {
|
|
65
|
+
loggedErrors.push({ obj, msg });
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const defaultFactory = () => {
|
|
70
|
+
const s = createServer();
|
|
71
|
+
s.on("error", () => {
|
|
72
|
+
/* tests don't care; suppress */
|
|
73
|
+
});
|
|
74
|
+
spawnedServers.push(s);
|
|
75
|
+
return s;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const watchdog = new SocketWatchdog({
|
|
79
|
+
socketPath: opts.socketPathOverride ?? socketPath,
|
|
80
|
+
intervalMs: opts.intervalMs ?? 0,
|
|
81
|
+
getServer: opts.getServerOverride ?? (() => serverRef.current),
|
|
82
|
+
createServer: opts.createServerOverride ?? defaultFactory,
|
|
83
|
+
onRebind: (newServer, oldServer) => {
|
|
84
|
+
rebinds.push({ newServer, oldServer });
|
|
85
|
+
serverRef.current = newServer;
|
|
86
|
+
// Mirror gateway behavior: close old server gracefully so its
|
|
87
|
+
// accept-loop drains. Close errors are not the watchdog's concern.
|
|
88
|
+
oldServer.close(() => {
|
|
89
|
+
/* drained */
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
log,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return { watchdog, serverRef, rebinds, log, loggedErrors, spawnedServers };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Spin up a real listening server and install it into the harness. Returns
|
|
100
|
+
* once the kernel reports the socket file present on disk.
|
|
101
|
+
*/
|
|
102
|
+
async function startInitialServer(harness: TestHarness): Promise<Server> {
|
|
103
|
+
const server = createServer();
|
|
104
|
+
server.on("error", () => {
|
|
105
|
+
/* ignore */
|
|
106
|
+
});
|
|
107
|
+
harness.spawnedServers.push(server);
|
|
108
|
+
await new Promise<void>((resolve, reject) => {
|
|
109
|
+
server.once("error", reject);
|
|
110
|
+
server.once("listening", () => resolve());
|
|
111
|
+
server.listen(socketPath);
|
|
112
|
+
});
|
|
113
|
+
harness.serverRef.current = server;
|
|
114
|
+
return server;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function connectClient(path: string): Promise<Socket> {
|
|
118
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
119
|
+
const client: Socket = createConnection(path, () => resolve(client));
|
|
120
|
+
client.on("error", reject);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function closeServer(server: Server): Promise<void> {
|
|
125
|
+
await new Promise<void>((resolve) => {
|
|
126
|
+
server.close(() => resolve());
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("SocketWatchdog", () => {
|
|
131
|
+
let harness: TestHarness | undefined;
|
|
132
|
+
const sockets: Socket[] = [];
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
harness = undefined;
|
|
136
|
+
// Defensive: clean up any leftover socket file from a previous test
|
|
137
|
+
// whose afterEach didn't fully drain.
|
|
138
|
+
if (existsSync(socketPath)) {
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(socketPath);
|
|
141
|
+
} catch {
|
|
142
|
+
/* ignore */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(async () => {
|
|
148
|
+
for (const s of sockets) {
|
|
149
|
+
if (!s.destroyed) s.destroy();
|
|
150
|
+
}
|
|
151
|
+
sockets.length = 0;
|
|
152
|
+
|
|
153
|
+
if (harness) {
|
|
154
|
+
harness.watchdog.stop();
|
|
155
|
+
// Close every server the harness produced, regardless of how the
|
|
156
|
+
// test left things. Closing an already-closed server is a no-op.
|
|
157
|
+
for (const s of harness.spawnedServers) {
|
|
158
|
+
try {
|
|
159
|
+
await closeServer(s);
|
|
160
|
+
} catch {
|
|
161
|
+
/* already closed */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
harness = undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (existsSync(socketPath)) {
|
|
168
|
+
try {
|
|
169
|
+
unlinkSync(socketPath);
|
|
170
|
+
} catch {
|
|
171
|
+
/* ignore */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("rebindIfMissing is a no-op when the socket path exists", async () => {
|
|
177
|
+
harness = buildHarness({});
|
|
178
|
+
await startInitialServer(harness);
|
|
179
|
+
|
|
180
|
+
const rebound = await harness.watchdog.rebindIfMissing();
|
|
181
|
+
expect(rebound).toBe(false);
|
|
182
|
+
expect(harness.rebinds).toHaveLength(0);
|
|
183
|
+
expect(existsSync(socketPath)).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("rebindIfMissing is a no-op when getServer returns null", async () => {
|
|
187
|
+
harness = buildHarness({});
|
|
188
|
+
// serverRef.current stays null.
|
|
189
|
+
const rebound = await harness.watchdog.rebindIfMissing();
|
|
190
|
+
expect(rebound).toBe(false);
|
|
191
|
+
expect(harness.rebinds).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("named pipes skip filesystem watchdog behavior", async () => {
|
|
195
|
+
const server = createServer();
|
|
196
|
+
harness = buildHarness({
|
|
197
|
+
socketPathOverride: "\\\\.\\pipe\\vellum-assistant-test",
|
|
198
|
+
getServerOverride: () => server,
|
|
199
|
+
createServerOverride: () => {
|
|
200
|
+
throw new Error("must not rebind");
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
harness.spawnedServers.push(server);
|
|
204
|
+
expect(await harness.watchdog.rebindIfMissing()).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("rebindIfMissing recreates the listener when the path is gone", async () => {
|
|
208
|
+
harness = buildHarness({});
|
|
209
|
+
const initial = await startInitialServer(harness);
|
|
210
|
+
expect(existsSync(socketPath)).toBe(true);
|
|
211
|
+
|
|
212
|
+
// Simulate the cleanup that wipes /run/* — unlink the path while the
|
|
213
|
+
// listener fd is still alive in the kernel.
|
|
214
|
+
unlinkSync(socketPath);
|
|
215
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
216
|
+
|
|
217
|
+
const rebound = await harness.watchdog.rebindIfMissing();
|
|
218
|
+
expect(rebound).toBe(true);
|
|
219
|
+
expect(existsSync(socketPath)).toBe(true);
|
|
220
|
+
expect(harness.rebinds).toHaveLength(1);
|
|
221
|
+
expect(harness.rebinds[0]!.oldServer).toBe(initial);
|
|
222
|
+
expect(harness.serverRef.current).toBe(harness.rebinds[0]!.newServer);
|
|
223
|
+
|
|
224
|
+
// A fresh client can connect to the re-bound listener.
|
|
225
|
+
const client = await connectClient(socketPath);
|
|
226
|
+
sockets.push(client);
|
|
227
|
+
expect(client.destroyed).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("connected clients survive a rebind", async () => {
|
|
231
|
+
harness = buildHarness({});
|
|
232
|
+
await startInitialServer(harness);
|
|
233
|
+
|
|
234
|
+
const survivor = await connectClient(socketPath);
|
|
235
|
+
sockets.push(survivor);
|
|
236
|
+
expect(survivor.destroyed).toBe(false);
|
|
237
|
+
|
|
238
|
+
unlinkSync(socketPath);
|
|
239
|
+
const rebound = await harness.watchdog.rebindIfMissing();
|
|
240
|
+
expect(rebound).toBe(true);
|
|
241
|
+
|
|
242
|
+
// Give the close-callback a moment to settle without churning the EL.
|
|
243
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
244
|
+
expect(survivor.destroyed).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("rebindIfMissing aborts when getServer changes mid-listen (shutdown race)", async () => {
|
|
248
|
+
// Drive the race deterministically by mutating what getServer returns
|
|
249
|
+
// between its first call (precondition check) and its second call
|
|
250
|
+
// (post-listen race guard).
|
|
251
|
+
const initial = createServer();
|
|
252
|
+
initial.on("error", () => {});
|
|
253
|
+
await new Promise<void>((r) => {
|
|
254
|
+
initial.once("listening", () => r());
|
|
255
|
+
initial.listen(socketPath);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
let getServerCalls = 0;
|
|
259
|
+
const rebinds: Array<{ newServer: Server; oldServer: Server }> = [];
|
|
260
|
+
const spawnedNewServers: Server[] = [];
|
|
261
|
+
|
|
262
|
+
const watchdog = new SocketWatchdog({
|
|
263
|
+
socketPath,
|
|
264
|
+
intervalMs: 0,
|
|
265
|
+
getServer: () => {
|
|
266
|
+
getServerCalls++;
|
|
267
|
+
// First call: precondition — initialServer is still around.
|
|
268
|
+
// Subsequent calls (race guard): null, simulating stop().
|
|
269
|
+
return getServerCalls === 1 ? initial : null;
|
|
270
|
+
},
|
|
271
|
+
createServer: () => {
|
|
272
|
+
const s = createServer();
|
|
273
|
+
s.on("error", () => {});
|
|
274
|
+
spawnedNewServers.push(s);
|
|
275
|
+
return s;
|
|
276
|
+
},
|
|
277
|
+
onRebind: (n, o) => {
|
|
278
|
+
rebinds.push({ newServer: n, oldServer: o });
|
|
279
|
+
},
|
|
280
|
+
log: { info: () => {}, warn: () => {}, error: () => {} },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
unlinkSync(socketPath);
|
|
284
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
285
|
+
|
|
286
|
+
const rebound = await watchdog.rebindIfMissing();
|
|
287
|
+
expect(rebound).toBe(false);
|
|
288
|
+
expect(rebinds).toHaveLength(0);
|
|
289
|
+
// The race guard should have unlinked the path the discarded server
|
|
290
|
+
// recreated, so a future start() doesn't see a phantom listener.
|
|
291
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
292
|
+
// getServer was called at least twice — once for precondition, once
|
|
293
|
+
// for the race guard.
|
|
294
|
+
expect(getServerCalls).toBeGreaterThanOrEqual(2);
|
|
295
|
+
|
|
296
|
+
// Cleanup: initial is still listening on the unlinked path; close it.
|
|
297
|
+
await closeServer(initial);
|
|
298
|
+
for (const s of spawnedNewServers) {
|
|
299
|
+
try {
|
|
300
|
+
await closeServer(s);
|
|
301
|
+
} catch {
|
|
302
|
+
/* already closed by race guard */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("rebindIfMissing returns false and logs when listen() rejects", async () => {
|
|
308
|
+
// Provide a factory whose listen() always errors, so rebindIfMissing
|
|
309
|
+
// hits the catch branch.
|
|
310
|
+
const initial = createServer();
|
|
311
|
+
initial.on("error", () => {});
|
|
312
|
+
await new Promise<void>((r) => {
|
|
313
|
+
initial.once("listening", () => r());
|
|
314
|
+
initial.listen(socketPath);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const rebinds: Array<{ newServer: Server; oldServer: Server }> = [];
|
|
318
|
+
const loggedErrors: Array<{ obj: object; msg?: string }> = [];
|
|
319
|
+
const failingFactory = () => {
|
|
320
|
+
const s = createServer();
|
|
321
|
+
s.on("error", () => {});
|
|
322
|
+
// Replace listen to immediately error.
|
|
323
|
+
const realListen = s.listen.bind(s);
|
|
324
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
325
|
+
(s as any).listen = (_path: string) => {
|
|
326
|
+
queueMicrotask(() => s.emit("error", new Error("simulated EADDRINUSE")));
|
|
327
|
+
return s;
|
|
328
|
+
};
|
|
329
|
+
// Keep realListen reference alive so TS doesn't complain
|
|
330
|
+
void realListen;
|
|
331
|
+
return s;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const watchdog = new SocketWatchdog({
|
|
335
|
+
socketPath,
|
|
336
|
+
intervalMs: 0,
|
|
337
|
+
getServer: () => initial,
|
|
338
|
+
createServer: failingFactory,
|
|
339
|
+
onRebind: (n, o) => rebinds.push({ newServer: n, oldServer: o }),
|
|
340
|
+
log: {
|
|
341
|
+
info: () => {},
|
|
342
|
+
warn: () => {},
|
|
343
|
+
error: (obj, msg) => loggedErrors.push({ obj, msg }),
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
unlinkSync(socketPath);
|
|
348
|
+
const rebound = await watchdog.rebindIfMissing();
|
|
349
|
+
expect(rebound).toBe(false);
|
|
350
|
+
expect(rebinds).toHaveLength(0);
|
|
351
|
+
expect(loggedErrors.length).toBeGreaterThan(0);
|
|
352
|
+
|
|
353
|
+
await closeServer(initial);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("watchdog timer catches synchronous rebind errors so unhandled rejections don't escape", async () => {
|
|
357
|
+
// createServer factory throws synchronously — simulates EACCES on
|
|
358
|
+
// mkdir / a broken factory dependency.
|
|
359
|
+
const throwingFactory = () => {
|
|
360
|
+
throw new Error("boom — synchronous factory failure");
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const initial = createServer();
|
|
364
|
+
initial.on("error", () => {});
|
|
365
|
+
await new Promise<void>((r) => {
|
|
366
|
+
initial.once("listening", () => r());
|
|
367
|
+
initial.listen(socketPath);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const loggedErrors: Array<{ obj: object; msg?: string }> = [];
|
|
371
|
+
const watchdog = new SocketWatchdog({
|
|
372
|
+
socketPath,
|
|
373
|
+
intervalMs: 5,
|
|
374
|
+
getServer: () => initial,
|
|
375
|
+
createServer: throwingFactory,
|
|
376
|
+
onRebind: () => {},
|
|
377
|
+
log: {
|
|
378
|
+
info: () => {},
|
|
379
|
+
warn: () => {},
|
|
380
|
+
error: (obj, msg) => loggedErrors.push({ obj, msg }),
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
unlinkSync(socketPath);
|
|
385
|
+
|
|
386
|
+
const seenRejections: unknown[] = [];
|
|
387
|
+
const onRejection = (reason: unknown) => seenRejections.push(reason);
|
|
388
|
+
process.on("unhandledRejection", onRejection);
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
watchdog.start();
|
|
392
|
+
// Let the timer fire several times.
|
|
393
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
394
|
+
watchdog.stop();
|
|
395
|
+
} finally {
|
|
396
|
+
process.off("unhandledRejection", onRejection);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
expect(seenRejections).toHaveLength(0);
|
|
400
|
+
expect(loggedErrors.length).toBeGreaterThan(0);
|
|
401
|
+
|
|
402
|
+
await closeServer(initial);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("start() polls and rebinds without manual ticking", async () => {
|
|
406
|
+
harness = buildHarness({ intervalMs: 10 });
|
|
407
|
+
await startInitialServer(harness);
|
|
408
|
+
harness.watchdog.start();
|
|
409
|
+
|
|
410
|
+
unlinkSync(socketPath);
|
|
411
|
+
|
|
412
|
+
// Wait up to 500ms for the timer to recover.
|
|
413
|
+
const deadline = Date.now() + 500;
|
|
414
|
+
while (harness.rebinds.length === 0 && Date.now() < deadline) {
|
|
415
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
expect(harness.rebinds).toHaveLength(1);
|
|
419
|
+
expect(existsSync(socketPath)).toBe(true);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("stop() prevents future rebinds from firing", async () => {
|
|
423
|
+
harness = buildHarness({ intervalMs: 10 });
|
|
424
|
+
await startInitialServer(harness);
|
|
425
|
+
harness.watchdog.start();
|
|
426
|
+
|
|
427
|
+
// First recovery cycle.
|
|
428
|
+
unlinkSync(socketPath);
|
|
429
|
+
let deadline = Date.now() + 500;
|
|
430
|
+
while (harness.rebinds.length < 1 && Date.now() < deadline) {
|
|
431
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
432
|
+
}
|
|
433
|
+
expect(harness.rebinds).toHaveLength(1);
|
|
434
|
+
|
|
435
|
+
harness.watchdog.stop();
|
|
436
|
+
const stoppedAt = harness.rebinds.length;
|
|
437
|
+
|
|
438
|
+
// Unlink again. Wait three intervals; no new rebind should appear.
|
|
439
|
+
unlinkSync(socketPath);
|
|
440
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
441
|
+
expect(harness.rebinds).toHaveLength(stoppedAt);
|
|
442
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resilience helper for Unix-domain-socket IPC servers: re-binds the
|
|
3
|
+
* listening socket when its on-disk path entry has been removed (e.g. by a
|
|
4
|
+
* tmpfs sweep or rogue cleanup of `/run/*`).
|
|
5
|
+
*
|
|
6
|
+
* Existing connected sockets survive the re-bind because the kernel keeps
|
|
7
|
+
* connection inodes alive independently of the listener path; only new
|
|
8
|
+
* `connect()` calls require the path to exist.
|
|
9
|
+
*
|
|
10
|
+
* Consumers wire their `Server` reference into the watchdog via callbacks
|
|
11
|
+
* rather than passing the server directly so the watchdog can guard against
|
|
12
|
+
* shutdown/restart races mid-rebind.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
16
|
+
import type { Server } from "node:net";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { isNamedPipePath } from "./endpoint.js";
|
|
20
|
+
import { ipcListenOptions } from "./listen-options.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minimal logger surface (pino-compatible). Each method receives a context
|
|
24
|
+
* object plus an optional human-readable message.
|
|
25
|
+
*/
|
|
26
|
+
export interface SocketWatchdogLogger {
|
|
27
|
+
info(obj: object, msg?: string): void;
|
|
28
|
+
warn(obj: object, msg?: string): void;
|
|
29
|
+
error(obj: object, msg?: string): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SocketWatchdogOptions {
|
|
33
|
+
/** Absolute path to the Unix socket file the consumer is listening on. */
|
|
34
|
+
socketPath: string;
|
|
35
|
+
/**
|
|
36
|
+
* How often to stat the socket path. Set to `0` to disable. Defaults to
|
|
37
|
+
* 5000ms.
|
|
38
|
+
*/
|
|
39
|
+
intervalMs?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Returns the consumer's current listening server. The watchdog uses this
|
|
42
|
+
* both as a precondition (no rebind when null) and as a generation marker
|
|
43
|
+
* to detect shutdown/restart races mid-rebind.
|
|
44
|
+
*/
|
|
45
|
+
getServer: () => Server | null;
|
|
46
|
+
/**
|
|
47
|
+
* Factory for a fresh listening Server. Called by the watchdog when a
|
|
48
|
+
* rebind is needed; the watchdog drives `.listen(socketPath)` and waits
|
|
49
|
+
* for the `listening` event before installing.
|
|
50
|
+
*/
|
|
51
|
+
createServer: () => Server;
|
|
52
|
+
/**
|
|
53
|
+
* Invoked when a rebind succeeds. The consumer is responsible for
|
|
54
|
+
* swapping its primary server reference to `newServer` and disposing of
|
|
55
|
+
* `oldServer` (typically by tracking it as a legacy listener while
|
|
56
|
+
* in-flight clients drain, then closing it).
|
|
57
|
+
*/
|
|
58
|
+
onRebind: (newServer: Server, oldServer: Server) => void;
|
|
59
|
+
/** Pino-compatible logger. */
|
|
60
|
+
log: SocketWatchdogLogger;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_INTERVAL_MS = 5000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ensure the directory containing `socketPath` exists. Created with mode
|
|
67
|
+
* `0o700` so a freshly-spawned dir on a tmpfs mount doesn't leak the IPC
|
|
68
|
+
* surface to other UIDs. Existing directories keep their permissions —
|
|
69
|
+
* `mkdir` only applies the mode to directories it creates.
|
|
70
|
+
*/
|
|
71
|
+
export function ensureSocketDir(socketPath: string): void {
|
|
72
|
+
if (isNamedPipePath(socketPath)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const socketDir = dirname(socketPath);
|
|
76
|
+
if (!existsSync(socketDir)) {
|
|
77
|
+
mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Watchdog that periodically stats a Unix socket file and re-binds the
|
|
83
|
+
* listener when the path has been removed.
|
|
84
|
+
*
|
|
85
|
+
* Lifecycle:
|
|
86
|
+
* - Construct with the consumer's callbacks.
|
|
87
|
+
* - Call {@link start} after the consumer's initial `listen()` succeeds.
|
|
88
|
+
* - Call {@link stop} during shutdown (before closing the underlying
|
|
89
|
+
* server) so an in-flight rebind doesn't resurrect the listener.
|
|
90
|
+
*
|
|
91
|
+
* The watchdog timer is `unref`-ed so it never keeps the event loop alive
|
|
92
|
+
* on its own.
|
|
93
|
+
*/
|
|
94
|
+
export class SocketWatchdog {
|
|
95
|
+
private readonly socketPath: string;
|
|
96
|
+
private readonly intervalMs: number;
|
|
97
|
+
private readonly getServer: () => Server | null;
|
|
98
|
+
private readonly createServer: () => Server;
|
|
99
|
+
private readonly onRebind: (newServer: Server, oldServer: Server) => void;
|
|
100
|
+
private readonly log: SocketWatchdogLogger;
|
|
101
|
+
|
|
102
|
+
private handle: ReturnType<typeof setInterval> | null = null;
|
|
103
|
+
|
|
104
|
+
constructor(options: SocketWatchdogOptions) {
|
|
105
|
+
this.socketPath = options.socketPath;
|
|
106
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
107
|
+
this.getServer = options.getServer;
|
|
108
|
+
this.createServer = options.createServer;
|
|
109
|
+
this.onRebind = options.onRebind;
|
|
110
|
+
this.log = options.log;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Begin polling the socket path. No-op if `intervalMs <= 0` or the
|
|
115
|
+
* watchdog is already running.
|
|
116
|
+
*/
|
|
117
|
+
start(): void {
|
|
118
|
+
if (
|
|
119
|
+
this.intervalMs <= 0 ||
|
|
120
|
+
this.handle !== null ||
|
|
121
|
+
isNamedPipePath(this.socketPath)
|
|
122
|
+
) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.handle = setInterval(() => {
|
|
126
|
+
// The async entry path of rebindIfMissing performs filesystem work
|
|
127
|
+
// (ensureSocketDir, createServer) before its inner try/catch, so a
|
|
128
|
+
// synchronous throw — e.g. EACCES on a read-only fs — would surface
|
|
129
|
+
// as an unhandled rejection on every tick. Catch here so the timer
|
|
130
|
+
// stays quiet on persistent failure modes.
|
|
131
|
+
this.rebindIfMissing().catch((err) => {
|
|
132
|
+
this.log.error(
|
|
133
|
+
{ err, path: this.socketPath },
|
|
134
|
+
"Watchdog rebind failed unexpectedly",
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
}, this.intervalMs);
|
|
138
|
+
this.handle.unref?.();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Stop the polling timer. Safe to call multiple times. */
|
|
142
|
+
stop(): void {
|
|
143
|
+
if (this.handle !== null) {
|
|
144
|
+
clearInterval(this.handle);
|
|
145
|
+
this.handle = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Re-bind the listening socket if its path entry is missing on disk.
|
|
151
|
+
*
|
|
152
|
+
* Public for tests so the watchdog can be exercised deterministically
|
|
153
|
+
* without waiting for the interval. Returns `true` when a re-bind was
|
|
154
|
+
* performed, `false` when the socket was already healthy, the consumer
|
|
155
|
+
* is not running, or a shutdown/restart raced the rebind.
|
|
156
|
+
*/
|
|
157
|
+
async rebindIfMissing(): Promise<boolean> {
|
|
158
|
+
if (isNamedPipePath(this.socketPath)) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
const initialServer = this.getServer();
|
|
162
|
+
if (initialServer === null) return false;
|
|
163
|
+
if (existsSync(this.socketPath)) return false;
|
|
164
|
+
|
|
165
|
+
this.log.warn(
|
|
166
|
+
{ path: this.socketPath },
|
|
167
|
+
"IPC socket path missing on disk — re-binding listener",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
ensureSocketDir(this.socketPath);
|
|
171
|
+
|
|
172
|
+
const newServer = this.createServer();
|
|
173
|
+
try {
|
|
174
|
+
await new Promise<void>((resolve, reject) => {
|
|
175
|
+
const onError = (err: unknown) => {
|
|
176
|
+
newServer.off("listening", onListening);
|
|
177
|
+
reject(err);
|
|
178
|
+
};
|
|
179
|
+
const onListening = () => {
|
|
180
|
+
newServer.off("error", onError);
|
|
181
|
+
resolve();
|
|
182
|
+
};
|
|
183
|
+
newServer.once("error", onError);
|
|
184
|
+
newServer.once("listening", onListening);
|
|
185
|
+
newServer.listen(ipcListenOptions(this.socketPath));
|
|
186
|
+
});
|
|
187
|
+
} catch (err) {
|
|
188
|
+
this.log.error(
|
|
189
|
+
{ err, path: this.socketPath },
|
|
190
|
+
"Failed to re-bind IPC socket — will retry on next watchdog tick",
|
|
191
|
+
);
|
|
192
|
+
try {
|
|
193
|
+
newServer.close();
|
|
194
|
+
} catch {
|
|
195
|
+
/* ignore */
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Race guard: while we were awaiting listen(), the consumer may have
|
|
201
|
+
// stopped, restarted, or otherwise replaced its server reference.
|
|
202
|
+
// Installing newServer would resurrect a listener after shutdown
|
|
203
|
+
// (keeping the process alive and accepting IPC again). Discard the
|
|
204
|
+
// new server instead.
|
|
205
|
+
if (this.getServer() !== initialServer) {
|
|
206
|
+
try {
|
|
207
|
+
newServer.close();
|
|
208
|
+
} catch {
|
|
209
|
+
/* ignore */
|
|
210
|
+
}
|
|
211
|
+
// newServer.listen() recreated the path on disk. If our listen won
|
|
212
|
+
// the race, the file is sitting there — clean it up so it doesn't
|
|
213
|
+
// shadow a future start().
|
|
214
|
+
if (existsSync(this.socketPath)) {
|
|
215
|
+
try {
|
|
216
|
+
unlinkSync(this.socketPath);
|
|
217
|
+
} catch {
|
|
218
|
+
/* ignore */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
this.log.warn(
|
|
222
|
+
{ path: this.socketPath },
|
|
223
|
+
"IPC server state changed during rebind — discarded new listener",
|
|
224
|
+
);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.onRebind(newServer, initialServer);
|
|
229
|
+
|
|
230
|
+
this.log.info(
|
|
231
|
+
{ path: this.socketPath },
|
|
232
|
+
"IPC socket re-bound after path loss",
|
|
233
|
+
);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"rootDir": "./src",
|
|
16
|
+
"types": ["bun"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist"]
|
|
20
|
+
}
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
* `Date#toISOString()`.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Pairing-challenge TTL in milliseconds (10 minutes): the gateway's challenge
|
|
26
|
+
* store enforces it and the `vellum pair` CLI renders it in user-facing copy.
|
|
27
|
+
*/
|
|
28
|
+
export const REMOTE_WEB_PAIRING_CODE_TTL_MS = 10 * 60 * 1000;
|
|
29
|
+
|
|
24
30
|
/** `POST /v1/remote-web/pairing-challenge` request body. */
|
|
25
31
|
export interface RemoteWebPairingChallengeRequest {
|
|
26
32
|
/** Public https base URL the scanning device can reach the assistant at. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/credential-executor",
|
|
3
|
-
"version": "0.11.2-dev.
|
|
3
|
+
"version": "0.11.2-dev.202608062308.5442cf1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@vellumai/credential-storage": "file:../packages/credential-storage",
|
|
23
23
|
"@vellumai/egress-proxy": "file:../packages/egress-proxy",
|
|
24
|
+
"@vellumai/ipc-server-utils": "file:../packages/ipc-server-utils",
|
|
24
25
|
"@vellumai/service-contracts": "file:../packages/service-contracts",
|
|
25
26
|
"pino": "9.14.0",
|
|
26
27
|
"pino-pretty": "13.1.3"
|
|
@@ -28,7 +29,8 @@
|
|
|
28
29
|
"bundledDependencies": [
|
|
29
30
|
"@vellumai/service-contracts",
|
|
30
31
|
"@vellumai/credential-storage",
|
|
31
|
-
"@vellumai/egress-proxy"
|
|
32
|
+
"@vellumai/egress-proxy",
|
|
33
|
+
"@vellumai/ipc-server-utils"
|
|
32
34
|
],
|
|
33
35
|
"devDependencies": {
|
|
34
36
|
"@types/bun": "1.3.10",
|
package/src/main.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* optional credential CRUD routes) for Kubernetes liveness/readiness probes.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { mkdirSync
|
|
20
|
+
import { mkdirSync } from "node:fs";
|
|
21
21
|
import { createServer as createNetServer, type Socket } from "node:net";
|
|
22
22
|
import { dirname, join } from "node:path";
|
|
23
23
|
import { Readable, Writable } from "node:stream";
|
|
@@ -27,6 +27,11 @@ import {
|
|
|
27
27
|
CesRpcMethod,
|
|
28
28
|
} from "@vellumai/service-contracts/credential-rpc";
|
|
29
29
|
import type { SecureKeyBackend } from "@vellumai/credential-storage";
|
|
30
|
+
import {
|
|
31
|
+
ipcListenOptions,
|
|
32
|
+
isNamedPipePath,
|
|
33
|
+
removeIpcEndpointFile,
|
|
34
|
+
} from "@vellumai/ipc-server-utils";
|
|
30
35
|
|
|
31
36
|
import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
|
|
32
37
|
import { initLogger, getLogger } from "./logger.js";
|
|
@@ -159,12 +164,10 @@ function serveStandaloneSocket(opts: {
|
|
|
159
164
|
onApiKeyUpdate,
|
|
160
165
|
} = opts;
|
|
161
166
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
unlinkSync(socketPath);
|
|
165
|
-
} catch {
|
|
166
|
-
// stale or absent — fine
|
|
167
|
+
if (!isNamedPipePath(socketPath)) {
|
|
168
|
+
mkdirSync(dirname(socketPath), { recursive: true });
|
|
167
169
|
}
|
|
170
|
+
removeIpcEndpointFile(socketPath);
|
|
168
171
|
|
|
169
172
|
const netServer = createNetServer();
|
|
170
173
|
|
|
@@ -215,7 +218,7 @@ function serveStandaloneSocket(opts: {
|
|
|
215
218
|
});
|
|
216
219
|
});
|
|
217
220
|
|
|
218
|
-
netServer.listen(socketPath, () => {
|
|
221
|
+
netServer.listen(ipcListenOptions(socketPath), () => {
|
|
219
222
|
log.info(`CES socket listening at ${socketPath}`);
|
|
220
223
|
});
|
|
221
224
|
|
|
@@ -223,11 +226,7 @@ function serveStandaloneSocket(opts: {
|
|
|
223
226
|
"abort",
|
|
224
227
|
() => {
|
|
225
228
|
netServer.close();
|
|
226
|
-
|
|
227
|
-
unlinkSync(socketPath);
|
|
228
|
-
} catch {
|
|
229
|
-
// already removed
|
|
230
|
-
}
|
|
229
|
+
removeIpcEndpointFile(socketPath);
|
|
231
230
|
},
|
|
232
231
|
{ once: true },
|
|
233
232
|
);
|