@ask-llm/plugin 0.15.0 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +966 -0
- package/README.md +2 -0
- package/agents/brainstorm-coordinator.md +1 -1
- package/agents/gemini-reviewer.md +1 -1
- package/dist/antigravity-run.js +0 -0
- package/dist/brainstorm-run.js +0 -0
- package/dist/codex-run.js +0 -0
- package/dist/grok-run.js +0 -0
- package/dist/ollama-run.js +0 -0
- package/dist/run.js +0 -0
- package/package.json +14 -14
- package/pi/extensions/provider-tools.ts +1 -1
- package/scripts/benchmark/README.md +114 -0
- package/scripts/benchmark/fixtures/README.md +29 -0
- package/scripts/codex-pair-debounce-worker.mjs +0 -0
- package/scripts/codex-pair-log.mjs +4 -13
- package/scripts/codex-pair-prompt-drain.mjs +1 -1
- package/scripts/codex-pair-session.mjs +2 -2
- package/scripts/codex-pair-stop-gate.mjs +8 -8
- package/scripts/codex-pair-watch.mjs +20 -39
- package/skills/gemini-review/SKILL.md +1 -1
- package/scripts/lib/broker-lifecycle.mjs +0 -575
- package/scripts/lib/broker-rpc.mjs +0 -203
- package/scripts/lib/broker-transport.mjs +0 -407
- package/scripts/lib/broker.mjs +0 -537
- package/scripts/lib/debounce-state.mjs +0 -208
- package/scripts/lib/parser.d.mts +0 -12
- package/scripts/lib/parser.mjs +0 -229
- package/scripts/lib/process.mjs +0 -56
- package/scripts/lib/prompt.d.mts +0 -8
- package/scripts/lib/prompt.mjs +0 -41
- package/scripts/lib/session-registry.mjs +0 -162
- package/scripts/lib/state.d.mts +0 -58
- package/scripts/lib/state.mjs +0 -733
- package/scripts/lib/stop-gate.mjs +0 -134
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
// JSON-RPC 2.0 client layered on a `broker-transport.mjs` connection
|
|
2
|
-
// (ADR-090 + ADR-093 Milestone 2 PR 1). The transport emits WebSocket
|
|
3
|
-
// TEXT frames whose payloads are JSON-RPC envelopes; this module handles
|
|
4
|
-
// request/response correlation by `id`, per-request timeouts, server-
|
|
5
|
-
// pushed notifications, and graceful close.
|
|
6
|
-
//
|
|
7
|
-
// **Tolerance.** Brainstorm probing of the real `codex app-server`
|
|
8
|
-
// (codex-cli 0.130.0) found that responses lack the `"jsonrpc":"2.0"`
|
|
9
|
-
// discriminator (ADR-093 protocol note). This client accepts envelopes
|
|
10
|
-
// with OR without that field; it dispatches purely on the `id` and
|
|
11
|
-
// `method` properties.
|
|
12
|
-
|
|
13
|
-
// Auto-incrementing request id source. JSON-RPC permits any unique
|
|
14
|
-
// non-null id; integers are simplest. Not cryptographic — exposing the
|
|
15
|
-
// counter doesn't leak anything.
|
|
16
|
-
let nextId = 1;
|
|
17
|
-
function takeNextId() {
|
|
18
|
-
const id = nextId;
|
|
19
|
-
nextId = (nextId + 1) | 0; // wrap at 2^31 (effectively never)
|
|
20
|
-
if (nextId <= 0) nextId = 1;
|
|
21
|
-
return id;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Create a JSON-RPC client over a connected `broker-transport.mjs`
|
|
25
|
-
// WebSocketConnection. The caller is responsible for `connection.close()`.
|
|
26
|
-
// This client manages the protocol layer on top, not the socket lifetime.
|
|
27
|
-
//
|
|
28
|
-
// Options:
|
|
29
|
-
// - defaultTimeoutMs (number, default 30000): per-request budget.
|
|
30
|
-
// - onNotification (function, default no-op): called with
|
|
31
|
-
// `{ method, params }` for server-pushed notifications (envelopes
|
|
32
|
-
// lacking an `id`).
|
|
33
|
-
// - onProtocolError (function, default no-op): called when an inbound
|
|
34
|
-
// text frame can't be parsed as JSON or has neither id nor method.
|
|
35
|
-
export function createRpcClient(connection, options = {}) {
|
|
36
|
-
const { defaultTimeoutMs = 30000, onNotification = () => {}, onProtocolError = () => {} } = options;
|
|
37
|
-
const pending = new Map(); // id -> { resolve, reject, timer }
|
|
38
|
-
// Subscriber list for waitFor — each entry { method, predicate, resolve, reject, timer }.
|
|
39
|
-
// M3 needs to attach a notification listener BEFORE dispatching `turn/start`
|
|
40
|
-
// (race-safe per brainstorm Risk #1: server can emit `turn/completed` between
|
|
41
|
-
// request-send and listener-install if registered after the send).
|
|
42
|
-
const notificationSubscribers = new Set();
|
|
43
|
-
let closed = false;
|
|
44
|
-
|
|
45
|
-
connection.on("message", (text) => {
|
|
46
|
-
let env;
|
|
47
|
-
try {
|
|
48
|
-
env = JSON.parse(text);
|
|
49
|
-
} catch (err) {
|
|
50
|
-
onProtocolError(new Error(`broker-rpc: malformed JSON from server: ${err?.message ?? String(err)}`));
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (env && typeof env === "object" && "id" in env && env.id != null) {
|
|
54
|
-
const entry = pending.get(env.id);
|
|
55
|
-
if (!entry) {
|
|
56
|
-
// Late response after timeout, or an id we didn't send. Ignore;
|
|
57
|
-
// surface as a soft protocol error for diagnostics.
|
|
58
|
-
onProtocolError(new Error(`broker-rpc: response for unknown id ${env.id}`));
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
pending.delete(env.id);
|
|
62
|
-
clearTimeout(entry.timer);
|
|
63
|
-
if (env.error) {
|
|
64
|
-
const err = new Error(env.error.message ?? `JSON-RPC error ${env.error.code ?? "?"}`);
|
|
65
|
-
err.code = env.error.code;
|
|
66
|
-
err.data = env.error.data;
|
|
67
|
-
entry.reject(err);
|
|
68
|
-
} else {
|
|
69
|
-
entry.resolve(env.result);
|
|
70
|
-
}
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (env && typeof env === "object" && typeof env.method === "string") {
|
|
74
|
-
// Server-pushed notification (or a server-initiated request, which
|
|
75
|
-
// codex-pair refuses since approvalPolicy:"never" — but pass it up
|
|
76
|
-
// either way and let the caller decide).
|
|
77
|
-
const notification = { method: env.method, params: env.params, id: env.id };
|
|
78
|
-
// Dispatch to waitFor subscribers first — they capture by method+predicate.
|
|
79
|
-
// Iterate a snapshot since resolved subscribers self-remove during dispatch.
|
|
80
|
-
for (const sub of [...notificationSubscribers]) {
|
|
81
|
-
if (sub.method === env.method) {
|
|
82
|
-
try {
|
|
83
|
-
if (!sub.predicate || sub.predicate(notification)) {
|
|
84
|
-
notificationSubscribers.delete(sub);
|
|
85
|
-
clearTimeout(sub.timer);
|
|
86
|
-
sub.resolve(notification);
|
|
87
|
-
}
|
|
88
|
-
} catch (err) {
|
|
89
|
-
notificationSubscribers.delete(sub);
|
|
90
|
-
clearTimeout(sub.timer);
|
|
91
|
-
sub.reject(err);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
onNotification(notification);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
onProtocolError(new Error(`broker-rpc: envelope has neither id nor method: ${text.slice(0, 120)}`));
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
connection.on("close", () => {
|
|
102
|
-
closed = true;
|
|
103
|
-
for (const [id, entry] of pending.entries()) {
|
|
104
|
-
clearTimeout(entry.timer);
|
|
105
|
-
entry.reject(new Error("broker-rpc: connection closed before response"));
|
|
106
|
-
pending.delete(id);
|
|
107
|
-
}
|
|
108
|
-
// Also reject any notification waiters — they'll never fire post-close.
|
|
109
|
-
for (const sub of notificationSubscribers) {
|
|
110
|
-
clearTimeout(sub.timer);
|
|
111
|
-
sub.reject(new Error("broker-rpc: connection closed before notification"));
|
|
112
|
-
}
|
|
113
|
-
notificationSubscribers.clear();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
connection.on("error", (err) => {
|
|
117
|
-
// Pending requests still time out via their own timers, but surface
|
|
118
|
-
// transport errors immediately too.
|
|
119
|
-
for (const [id, entry] of pending.entries()) {
|
|
120
|
-
clearTimeout(entry.timer);
|
|
121
|
-
entry.reject(err);
|
|
122
|
-
pending.delete(id);
|
|
123
|
-
}
|
|
124
|
-
// Multi-review M3 hotfix: also reject notification waiters — some
|
|
125
|
-
// Node transports emit "error" without a following "close", so the
|
|
126
|
-
// close-handler cleanup wouldn't run otherwise and waitFor() would
|
|
127
|
-
// hang until its own timeout. Surface the real transport error
|
|
128
|
-
// immediately for diagnostics instead of a generic timeout.
|
|
129
|
-
for (const sub of notificationSubscribers) {
|
|
130
|
-
clearTimeout(sub.timer);
|
|
131
|
-
sub.reject(err);
|
|
132
|
-
}
|
|
133
|
-
notificationSubscribers.clear();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
request(method, params, opts = {}) {
|
|
138
|
-
if (closed) return Promise.reject(new Error("broker-rpc: client is closed"));
|
|
139
|
-
const id = takeNextId();
|
|
140
|
-
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
|
|
141
|
-
const envelope = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
142
|
-
return new Promise((resolve, reject) => {
|
|
143
|
-
const timer = setTimeout(() => {
|
|
144
|
-
pending.delete(id);
|
|
145
|
-
reject(new Error(`broker-rpc: timeout after ${timeoutMs}ms (method=${method}, id=${id})`));
|
|
146
|
-
}, timeoutMs);
|
|
147
|
-
timer.unref?.();
|
|
148
|
-
pending.set(id, { resolve, reject, timer });
|
|
149
|
-
try {
|
|
150
|
-
connection.sendText(envelope);
|
|
151
|
-
} catch (err) {
|
|
152
|
-
pending.delete(id);
|
|
153
|
-
clearTimeout(timer);
|
|
154
|
-
reject(err);
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
},
|
|
158
|
-
notify(method, params) {
|
|
159
|
-
// Notifications have no id and expect no response.
|
|
160
|
-
if (closed) throw new Error("broker-rpc: client is closed");
|
|
161
|
-
connection.sendText(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
|
162
|
-
},
|
|
163
|
-
// Register a notification listener with a predicate. Returns a Promise
|
|
164
|
-
// resolving to the matched notification, OR rejecting on timeout / close.
|
|
165
|
-
// CRITICAL: call this BEFORE the request that triggers the notification
|
|
166
|
-
// (per brainstorm Risk #1 — server can emit `turn/completed` between
|
|
167
|
-
// request-send and listener-install if registered after the send).
|
|
168
|
-
//
|
|
169
|
-
// Usage in M3 submitReview:
|
|
170
|
-
// const waiter = rpc.waitFor("turn/completed", n => n.params?.threadId === ourThreadId, timeoutMs);
|
|
171
|
-
// await rpc.request("turn/start", { ... });
|
|
172
|
-
// const completion = await waiter;
|
|
173
|
-
waitFor(method, predicate, timeoutMs) {
|
|
174
|
-
if (closed) return Promise.reject(new Error("broker-rpc: client is closed"));
|
|
175
|
-
return new Promise((resolve, reject) => {
|
|
176
|
-
const sub = { method, predicate, resolve, reject, timer: null };
|
|
177
|
-
sub.timer = setTimeout(() => {
|
|
178
|
-
notificationSubscribers.delete(sub);
|
|
179
|
-
// Multi-review M3 hotfix: attach a structured `.timeout = true`
|
|
180
|
-
// marker so callers don't have to regex-match the message.
|
|
181
|
-
const err = new Error(`broker-rpc: waitFor(${method}) timed out after ${timeoutMs}ms`);
|
|
182
|
-
err.timeout = true;
|
|
183
|
-
reject(err);
|
|
184
|
-
}, timeoutMs);
|
|
185
|
-
sub.timer.unref?.();
|
|
186
|
-
notificationSubscribers.add(sub);
|
|
187
|
-
});
|
|
188
|
-
},
|
|
189
|
-
get pendingCount() {
|
|
190
|
-
return pending.size;
|
|
191
|
-
},
|
|
192
|
-
get closed() {
|
|
193
|
-
return closed;
|
|
194
|
-
},
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Exports for tests
|
|
199
|
-
export const __testing__ = {
|
|
200
|
-
resetIdCounter() {
|
|
201
|
-
nextId = 1;
|
|
202
|
-
},
|
|
203
|
-
};
|
|
@@ -1,407 +0,0 @@
|
|
|
1
|
-
// WebSocket-over-net transport for the codex-pair broker (ADR-090 + ADR-093
|
|
2
|
-
// Milestone 2 PR 1). Minimal hand-rolled RFC 6455 client supporting both
|
|
3
|
-
// unix-domain-socket (`unix://`) and TCP (`ws://`) transports through one
|
|
4
|
-
// code path. Pure Node built-ins per ADR-078 — no `ws` package, no
|
|
5
|
-
// workspace imports.
|
|
6
|
-
//
|
|
7
|
-
// **Scope discipline.** This is the smallest subset of RFC 6455 that
|
|
8
|
-
// satisfies codex `app-server`'s usage:
|
|
9
|
-
// - Single client connection, no multiplexing.
|
|
10
|
-
// - TEXT opcode (0x1) for JSON-RPC frames, no BINARY, no fragmentation.
|
|
11
|
-
// - Client masks all frames (RFC 6455 §5.3). Server does not.
|
|
12
|
-
// - Auto-respond to server PING with PONG mirroring the payload.
|
|
13
|
-
// - Send CLOSE (0x8) on `close()`, handle inbound CLOSE.
|
|
14
|
-
// - No permessage-deflate or other extensions.
|
|
15
|
-
// - No subprotocols.
|
|
16
|
-
//
|
|
17
|
-
// **Tolerance.** codex app-server's JSON-RPC responses observed in the
|
|
18
|
-
// wild lack the `"jsonrpc":"2.0"` discriminator (verified by brainstorm
|
|
19
|
-
// probing — see ADR-093 protocol notes). The JSON parsing here passes
|
|
20
|
-
// raw text up; the RPC layer in `broker-rpc.mjs` does the tolerant
|
|
21
|
-
// matching by id.
|
|
22
|
-
|
|
23
|
-
import { Buffer } from "node:buffer";
|
|
24
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
25
|
-
import { connect } from "node:net";
|
|
26
|
-
|
|
27
|
-
// RFC 6455 frame opcodes we recognize.
|
|
28
|
-
const OPCODE_CONTINUATION = 0x0;
|
|
29
|
-
const OPCODE_TEXT = 0x1;
|
|
30
|
-
const OPCODE_BINARY = 0x2;
|
|
31
|
-
const OPCODE_CLOSE = 0x8;
|
|
32
|
-
const OPCODE_PING = 0x9;
|
|
33
|
-
const OPCODE_PONG = 0xa;
|
|
34
|
-
|
|
35
|
-
// RFC 6455 magic GUID for the Sec-WebSocket-Accept derivation (§4.2.2).
|
|
36
|
-
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
37
|
-
|
|
38
|
-
// Parse a transport URL into `net.connect` options. Supports:
|
|
39
|
-
// unix:///absolute/path/to/socket
|
|
40
|
-
// unix://relative/from/cwd
|
|
41
|
-
// ws://host:port — also accepts host without port (defaults 80)
|
|
42
|
-
// Returns `{ connectOptions, host, isUnix }`.
|
|
43
|
-
export function parseTransportUrl(url) {
|
|
44
|
-
if (typeof url !== "string") {
|
|
45
|
-
throw new TypeError(`broker-transport: transport URL must be string, got ${typeof url}`);
|
|
46
|
-
}
|
|
47
|
-
if (url.startsWith("unix://")) {
|
|
48
|
-
const path = url.slice("unix://".length);
|
|
49
|
-
if (!path) throw new Error(`broker-transport: unix:// URL has empty path`);
|
|
50
|
-
return { connectOptions: { path }, host: "localhost", isUnix: true };
|
|
51
|
-
}
|
|
52
|
-
if (url.startsWith("ws://")) {
|
|
53
|
-
const rest = url.slice("ws://".length);
|
|
54
|
-
const slashIdx = rest.indexOf("/");
|
|
55
|
-
const authority = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
|
|
56
|
-
const colonIdx = authority.lastIndexOf(":");
|
|
57
|
-
const host = colonIdx === -1 ? authority : authority.slice(0, colonIdx);
|
|
58
|
-
const port = colonIdx === -1 ? 80 : Number(authority.slice(colonIdx + 1));
|
|
59
|
-
if (!Number.isFinite(port) || port <= 0) {
|
|
60
|
-
throw new Error(`broker-transport: ws:// URL has invalid port: ${authority}`);
|
|
61
|
-
}
|
|
62
|
-
return { connectOptions: { host, port }, host: authority, isUnix: false };
|
|
63
|
-
}
|
|
64
|
-
throw new Error(`broker-transport: unsupported transport URL scheme: ${url} (need unix:// or ws://)`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Build the HTTP/1.1 upgrade request bytes for a given parsed URL. The
|
|
68
|
-
// `Host:` header is required for the HTTP/1.1 framing even when the
|
|
69
|
-
// transport is a unix socket (it's not used for routing but the codex
|
|
70
|
-
// server's upgrade parser requires it). We send a fixed "localhost" for
|
|
71
|
-
// unix sockets and the authority for TCP.
|
|
72
|
-
function buildUpgradeRequest(host, secKey) {
|
|
73
|
-
return [
|
|
74
|
-
`GET / HTTP/1.1`,
|
|
75
|
-
`Host: ${host}`,
|
|
76
|
-
`Upgrade: websocket`,
|
|
77
|
-
`Connection: Upgrade`,
|
|
78
|
-
`Sec-WebSocket-Key: ${secKey}`,
|
|
79
|
-
`Sec-WebSocket-Version: 13`,
|
|
80
|
-
``,
|
|
81
|
-
``,
|
|
82
|
-
].join("\r\n");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Validate the server's upgrade response (RFC 6455 §4.1 step 4). The
|
|
86
|
-
// 101 status + Sec-WebSocket-Accept header derived from our Sec-WebSocket-Key
|
|
87
|
-
// are the mandatory checks. We ignore optional fields.
|
|
88
|
-
function validateUpgradeResponse(headerText, sentKey) {
|
|
89
|
-
const lines = headerText.split("\r\n");
|
|
90
|
-
if (!lines[0] || !/^HTTP\/1\.[01]\s+101\b/.test(lines[0])) {
|
|
91
|
-
throw new Error(`broker-transport: upgrade rejected, status line: ${lines[0] ?? "(empty)"}`);
|
|
92
|
-
}
|
|
93
|
-
let accept = null;
|
|
94
|
-
for (let i = 1; i < lines.length; i++) {
|
|
95
|
-
const colon = lines[i].indexOf(":");
|
|
96
|
-
if (colon === -1) continue;
|
|
97
|
-
const name = lines[i].slice(0, colon).trim().toLowerCase();
|
|
98
|
-
if (name === "sec-websocket-accept") {
|
|
99
|
-
accept = lines[i].slice(colon + 1).trim();
|
|
100
|
-
break;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const expected = createHash("sha1").update(sentKey + WS_GUID).digest("base64");
|
|
104
|
-
if (accept !== expected) {
|
|
105
|
-
throw new Error(
|
|
106
|
-
`broker-transport: Sec-WebSocket-Accept mismatch (got ${accept ?? "(missing)"}, expected ${expected})`,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Encode a TEXT frame. Client frames MUST be masked per RFC 6455 §5.3.
|
|
112
|
-
// Returns a Buffer ready to write to the socket.
|
|
113
|
-
function encodeTextFrame(text) {
|
|
114
|
-
const payload = Buffer.from(text, "utf-8");
|
|
115
|
-
const mask = randomBytes(4);
|
|
116
|
-
const masked = Buffer.allocUnsafe(payload.length);
|
|
117
|
-
for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4];
|
|
118
|
-
|
|
119
|
-
let lenBytes;
|
|
120
|
-
if (payload.length < 126) {
|
|
121
|
-
lenBytes = Buffer.from([0x80 | payload.length]); // MASK bit + 7-bit length
|
|
122
|
-
} else if (payload.length < 0x10000) {
|
|
123
|
-
lenBytes = Buffer.allocUnsafe(3);
|
|
124
|
-
lenBytes[0] = 0x80 | 126;
|
|
125
|
-
lenBytes.writeUInt16BE(payload.length, 1);
|
|
126
|
-
} else {
|
|
127
|
-
lenBytes = Buffer.allocUnsafe(9);
|
|
128
|
-
lenBytes[0] = 0x80 | 127;
|
|
129
|
-
// 64-bit big-endian. JS BigInt for >32-bit lengths; we cap at Node's
|
|
130
|
-
// Buffer.allocUnsafe practical limit anyway.
|
|
131
|
-
lenBytes.writeBigUInt64BE(BigInt(payload.length), 1);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return Buffer.concat([
|
|
135
|
-
Buffer.from([0x80 | OPCODE_TEXT]), // FIN bit + TEXT opcode
|
|
136
|
-
lenBytes,
|
|
137
|
-
mask,
|
|
138
|
-
masked,
|
|
139
|
-
]);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Maximum control-frame payload per RFC 6455 §5.5 ("All control frames
|
|
143
|
-
// MUST have a payload length of 125 bytes or less"). The 7-bit length
|
|
144
|
-
// field in byte[1] would otherwise overflow into the extended-length
|
|
145
|
-
// encoding flags (126, 127). Both encoders below truncate to this cap
|
|
146
|
-
// to guarantee on-wire correctness.
|
|
147
|
-
const MAX_CONTROL_FRAME_PAYLOAD = 125;
|
|
148
|
-
|
|
149
|
-
// Encode a CLOSE frame with optional status code + reason. Client-masked.
|
|
150
|
-
// Reason is truncated as needed to keep total payload ≤ 125 bytes per
|
|
151
|
-
// RFC 6455 §5.5 (multi-review Finding #3 — previously silently corrupted
|
|
152
|
-
// frames if reason was ≥ 124 bytes).
|
|
153
|
-
function encodeCloseFrame(code = 1000, reason = "") {
|
|
154
|
-
let reasonBuf = Buffer.from(reason, "utf-8");
|
|
155
|
-
// 2 bytes for the status code + reason. Truncate reason if combined
|
|
156
|
-
// would exceed the control-frame cap.
|
|
157
|
-
if (2 + reasonBuf.length > MAX_CONTROL_FRAME_PAYLOAD) {
|
|
158
|
-
reasonBuf = reasonBuf.slice(0, MAX_CONTROL_FRAME_PAYLOAD - 2);
|
|
159
|
-
}
|
|
160
|
-
const payload = Buffer.allocUnsafe(2 + reasonBuf.length);
|
|
161
|
-
payload.writeUInt16BE(code, 0);
|
|
162
|
-
reasonBuf.copy(payload, 2);
|
|
163
|
-
const mask = randomBytes(4);
|
|
164
|
-
const masked = Buffer.allocUnsafe(payload.length);
|
|
165
|
-
for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4];
|
|
166
|
-
return Buffer.concat([Buffer.from([0x80 | OPCODE_CLOSE, 0x80 | payload.length]), mask, masked]);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Encode a PONG frame echoing the server's PING payload. Client-masked.
|
|
170
|
-
// PING payload is truncated to 125 bytes (RFC 6455 §5.5) — a hostile or
|
|
171
|
-
// buggy server sending a > 125-byte PING would otherwise scramble our
|
|
172
|
-
// outgoing frame.
|
|
173
|
-
function encodePongFrame(payload) {
|
|
174
|
-
const capped = payload.length > MAX_CONTROL_FRAME_PAYLOAD ? payload.slice(0, MAX_CONTROL_FRAME_PAYLOAD) : payload;
|
|
175
|
-
const mask = randomBytes(4);
|
|
176
|
-
const masked = Buffer.allocUnsafe(capped.length);
|
|
177
|
-
for (let i = 0; i < capped.length; i++) masked[i] = capped[i] ^ mask[i % 4];
|
|
178
|
-
return Buffer.concat([Buffer.from([0x80 | OPCODE_PONG, 0x80 | capped.length]), mask, masked]);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Stateful frame parser. Accumulates incoming bytes and emits whole frames
|
|
182
|
-
// via `onFrame({ opcode, payload })`. Caller drains via `feed(chunk)`.
|
|
183
|
-
// Server→client frames are NOT masked per RFC 6455 §5.3, so we ignore
|
|
184
|
-
// the MASK bit on parse. On fatal protocol violations (fragmentation we
|
|
185
|
-
// don't support, illegal frame shapes) the parser flips into a "corrupted"
|
|
186
|
-
// state — no further frames are emitted, and onError is called once.
|
|
187
|
-
// The caller (connectWebSocket) destroys the socket so pending RPC
|
|
188
|
-
// requests reject via the close handler. This was a multi-review finding
|
|
189
|
-
// — the previous code did `continue` after fragmentation and the buffer
|
|
190
|
-
// state corrupted forever.
|
|
191
|
-
function createFrameParser(onFrame, onError) {
|
|
192
|
-
let buf = Buffer.alloc(0);
|
|
193
|
-
let corrupted = false;
|
|
194
|
-
return (chunk) => {
|
|
195
|
-
if (corrupted) return; // already reported fatal — discard further bytes
|
|
196
|
-
buf = Buffer.concat([buf, chunk]);
|
|
197
|
-
while (buf.length >= 2) {
|
|
198
|
-
const first = buf[0];
|
|
199
|
-
const second = buf[1];
|
|
200
|
-
const fin = (first & 0x80) !== 0;
|
|
201
|
-
const opcode = first & 0x0f;
|
|
202
|
-
let len = second & 0x7f;
|
|
203
|
-
let offset = 2;
|
|
204
|
-
if (len === 126) {
|
|
205
|
-
if (buf.length < 4) return; // need more
|
|
206
|
-
len = buf.readUInt16BE(2);
|
|
207
|
-
offset = 4;
|
|
208
|
-
} else if (len === 127) {
|
|
209
|
-
if (buf.length < 10) return;
|
|
210
|
-
// Cast BigInt to Number — control frames have len<126 anyway, and
|
|
211
|
-
// for data frames we cap at safe-integer range. 2GB payload is far
|
|
212
|
-
// larger than any conceivable RPC response.
|
|
213
|
-
len = Number(buf.readBigUInt64BE(2));
|
|
214
|
-
offset = 10;
|
|
215
|
-
}
|
|
216
|
-
// Per RFC 6455, server→client frames must NOT be masked (we'd see
|
|
217
|
-
// bit 0x80 set on byte[1]). If we see it, the peer is buggy; just
|
|
218
|
-
// skip the mask bytes if present.
|
|
219
|
-
const maskBit = (second & 0x80) !== 0;
|
|
220
|
-
if (maskBit) offset += 4;
|
|
221
|
-
if (buf.length < offset + len) return; // need more
|
|
222
|
-
if (!fin && opcode !== OPCODE_CONTINUATION) {
|
|
223
|
-
// Fragmentation is not supported. Marking corrupted so subsequent
|
|
224
|
-
// bytes are ignored; the connect handler destroys the socket.
|
|
225
|
-
// codex doesn't fragment JSON-RPC frames in practice, so this
|
|
226
|
-
// path is defensive against a buggy or hostile server.
|
|
227
|
-
corrupted = true;
|
|
228
|
-
buf = Buffer.alloc(0);
|
|
229
|
-
onError(
|
|
230
|
-
new Error(`broker-transport: fragmentation not supported (opcode=${opcode}) — connection terminating`),
|
|
231
|
-
);
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
const payload = buf.slice(offset, offset + len);
|
|
235
|
-
buf = buf.slice(offset + len);
|
|
236
|
-
onFrame({ opcode, payload });
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Top-level connector. Returns a Promise<WebSocketConnection> after a
|
|
242
|
-
// successful HTTP upgrade. The connection exposes `sendText(s)`,
|
|
243
|
-
// `close(code?, reason?)`, and event listeners `on("message", cb)` /
|
|
244
|
-
// `on("close", cb)` / `on("error", cb)`.
|
|
245
|
-
//
|
|
246
|
-
// **Timeout semantics.** Connect + upgrade must complete within
|
|
247
|
-
// `handshakeTimeoutMs` (default 5000). On timeout, the underlying socket
|
|
248
|
-
// is destroyed and the promise rejects. After upgrade success, the caller
|
|
249
|
-
// owns the connection's lifetime.
|
|
250
|
-
export async function connectWebSocket(transportUrl, options = {}) {
|
|
251
|
-
const { handshakeTimeoutMs = 5000 } = options;
|
|
252
|
-
const { connectOptions, host } = parseTransportUrl(transportUrl);
|
|
253
|
-
|
|
254
|
-
return new Promise((resolve, reject) => {
|
|
255
|
-
const socket = connect(connectOptions);
|
|
256
|
-
const listeners = { message: [], close: [], error: [] };
|
|
257
|
-
let upgraded = false;
|
|
258
|
-
let headerBuf = Buffer.alloc(0);
|
|
259
|
-
let parser = null;
|
|
260
|
-
|
|
261
|
-
const timer = setTimeout(() => {
|
|
262
|
-
if (!upgraded) {
|
|
263
|
-
socket.destroy();
|
|
264
|
-
reject(new Error(`broker-transport: handshake timeout after ${handshakeTimeoutMs}ms`));
|
|
265
|
-
}
|
|
266
|
-
}, handshakeTimeoutMs);
|
|
267
|
-
timer.unref?.();
|
|
268
|
-
|
|
269
|
-
const secKey = randomBytes(16).toString("base64");
|
|
270
|
-
|
|
271
|
-
// Use `.on` not `.once` — codex-pair flagged that `.once` removes the
|
|
272
|
-
// only error listener after the first emission. If a second error
|
|
273
|
-
// fires post-upgrade (e.g., RST after CLOSE, or a TCP-level failure
|
|
274
|
-
// mid-stream), it becomes unhandled and crashes the process. The
|
|
275
|
-
// `if (!upgraded)` guard makes the pre-upgrade reject() idempotent
|
|
276
|
-
// (reject is a no-op after the promise settles).
|
|
277
|
-
socket.on("error", (err) => {
|
|
278
|
-
if (!upgraded) {
|
|
279
|
-
clearTimeout(timer);
|
|
280
|
-
reject(err);
|
|
281
|
-
} else {
|
|
282
|
-
for (const cb of listeners.error) cb(err);
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
// Send the HTTP/1.1 upgrade request once the TCP/UDS connection is
|
|
287
|
-
// established. This was missing in the original M2 PR 1 implementation
|
|
288
|
-
// — flagged by the multi-review (both Codex + Gemini caught it). Unit
|
|
289
|
-
// tests mocked around connectWebSocket so the missing write was
|
|
290
|
-
// invisible. The fixture-server test added in this hotfix exercises
|
|
291
|
-
// the real upgrade path so this class of bug can't recur silently.
|
|
292
|
-
socket.once("connect", () => {
|
|
293
|
-
try {
|
|
294
|
-
socket.write(buildUpgradeRequest(host, secKey));
|
|
295
|
-
} catch (err) {
|
|
296
|
-
if (!upgraded) {
|
|
297
|
-
clearTimeout(timer);
|
|
298
|
-
reject(err);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
socket.once("close", () => {
|
|
304
|
-
clearTimeout(timer);
|
|
305
|
-
if (!upgraded) reject(new Error("broker-transport: socket closed before upgrade"));
|
|
306
|
-
else for (const cb of listeners.close) cb();
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
socket.on("data", (chunk) => {
|
|
310
|
-
if (upgraded) {
|
|
311
|
-
parser(chunk);
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
headerBuf = Buffer.concat([headerBuf, chunk]);
|
|
315
|
-
const end = headerBuf.indexOf("\r\n\r\n");
|
|
316
|
-
if (end === -1) return;
|
|
317
|
-
const headerText = headerBuf.slice(0, end).toString("utf-8");
|
|
318
|
-
const tail = headerBuf.slice(end + 4);
|
|
319
|
-
try {
|
|
320
|
-
validateUpgradeResponse(headerText, secKey);
|
|
321
|
-
} catch (err) {
|
|
322
|
-
socket.destroy();
|
|
323
|
-
clearTimeout(timer);
|
|
324
|
-
reject(err);
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
upgraded = true;
|
|
328
|
-
clearTimeout(timer);
|
|
329
|
-
parser = createFrameParser(
|
|
330
|
-
({ opcode, payload }) => {
|
|
331
|
-
if (opcode === OPCODE_TEXT) {
|
|
332
|
-
const text = payload.toString("utf-8");
|
|
333
|
-
for (const cb of listeners.message) cb(text);
|
|
334
|
-
} else if (opcode === OPCODE_PING) {
|
|
335
|
-
// Auto-respond with PONG mirroring the payload (RFC §5.5.2).
|
|
336
|
-
socket.write(encodePongFrame(payload));
|
|
337
|
-
} else if (opcode === OPCODE_CLOSE) {
|
|
338
|
-
// Echo close + half-close (RFC §5.5.1).
|
|
339
|
-
try {
|
|
340
|
-
socket.write(encodeCloseFrame(1000, ""));
|
|
341
|
-
} catch {
|
|
342
|
-
// best-effort
|
|
343
|
-
}
|
|
344
|
-
socket.end();
|
|
345
|
-
} else if (opcode === OPCODE_BINARY) {
|
|
346
|
-
// codex doesn't send binary for JSON-RPC; ignore silently.
|
|
347
|
-
}
|
|
348
|
-
// PONG and CONTINUATION are no-ops here.
|
|
349
|
-
},
|
|
350
|
-
(err) => {
|
|
351
|
-
for (const cb of listeners.error) cb(err);
|
|
352
|
-
// Parser-level errors signal unrecoverable protocol corruption
|
|
353
|
-
// (fragmentation, malformed framing). Destroy the socket so
|
|
354
|
-
// pending RPC requests reject via the close handler. Multi-
|
|
355
|
-
// review finding #4: previously the parser silently corrupted
|
|
356
|
-
// its buffer state and kept "running" against garbage.
|
|
357
|
-
try {
|
|
358
|
-
socket.destroy();
|
|
359
|
-
} catch {
|
|
360
|
-
// best-effort
|
|
361
|
-
}
|
|
362
|
-
},
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
const conn = {
|
|
366
|
-
sendText(text) {
|
|
367
|
-
socket.write(encodeTextFrame(text));
|
|
368
|
-
},
|
|
369
|
-
close(code = 1000, reason = "") {
|
|
370
|
-
try {
|
|
371
|
-
socket.write(encodeCloseFrame(code, reason));
|
|
372
|
-
} catch {
|
|
373
|
-
// best-effort — caller treats close as fire-and-forget
|
|
374
|
-
}
|
|
375
|
-
socket.end();
|
|
376
|
-
},
|
|
377
|
-
on(event, cb) {
|
|
378
|
-
if (!listeners[event]) throw new Error(`broker-transport: unknown event ${event}`);
|
|
379
|
-
listeners[event].push(cb);
|
|
380
|
-
},
|
|
381
|
-
get destroyed() {
|
|
382
|
-
return socket.destroyed;
|
|
383
|
-
},
|
|
384
|
-
// For tests / diagnostics
|
|
385
|
-
_underlyingSocket() {
|
|
386
|
-
return socket;
|
|
387
|
-
},
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
// If the upgrade response had body bytes already buffered, feed them.
|
|
391
|
-
if (tail.length > 0) parser(tail);
|
|
392
|
-
|
|
393
|
-
resolve(conn);
|
|
394
|
-
});
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Exports for tests
|
|
399
|
-
export const __testing__ = {
|
|
400
|
-
encodeTextFrame,
|
|
401
|
-
encodeCloseFrame,
|
|
402
|
-
encodePongFrame,
|
|
403
|
-
createFrameParser,
|
|
404
|
-
validateUpgradeResponse,
|
|
405
|
-
buildUpgradeRequest,
|
|
406
|
-
WS_GUID,
|
|
407
|
-
};
|