mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
fix(runner): speak WebSocket over Codex Unix sockets
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7eaf1a6f0d
commit
457c9f8ddf
4 files changed
+216
-15
No files matched your search
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createConnection } from "node:net";
|
||||
|
||||
const websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
function socketArgument(argv: string[]): string {
|
||||
const index = argv.indexOf("--socket");
|
||||
const value = index >= 0 ? argv[index + 1] : undefined;
|
||||
@@ -8,11 +11,131 @@ function socketArgument(argv: string[]): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function clientFrame(payload: Buffer, opcode = 0x1): Buffer {
|
||||
const length = payload.length;
|
||||
const extended = length < 126 ? 0 : length <= 0xffff ? 2 : 8;
|
||||
const frame = Buffer.alloc(2 + extended + 4 + length);
|
||||
frame[0] = 0x80 | opcode;
|
||||
frame[1] = 0x80 | (length < 126 ? length : extended === 2 ? 126 : 127);
|
||||
let offset = 2;
|
||||
if (extended === 2) {
|
||||
frame.writeUInt16BE(length, offset);
|
||||
offset += 2;
|
||||
} else if (extended === 8) {
|
||||
frame.writeBigUInt64BE(BigInt(length), offset);
|
||||
offset += 8;
|
||||
}
|
||||
const mask = randomBytes(4);
|
||||
mask.copy(frame, offset);
|
||||
offset += 4;
|
||||
for (let index = 0; index < length; index += 1)
|
||||
frame[offset + index] = payload[index]! ^ mask[index % 4]!;
|
||||
return frame;
|
||||
}
|
||||
|
||||
try {
|
||||
const socket = createConnection(socketArgument(process.argv.slice(2)));
|
||||
const websocketKey = randomBytes(16).toString("base64");
|
||||
const expectedAccept = createHash("sha1")
|
||||
.update(`${websocketKey}${websocketGuid}`)
|
||||
.digest("base64");
|
||||
let connected = false;
|
||||
let networkBuffer = Buffer.alloc(0);
|
||||
let inputBuffer = "";
|
||||
let fragmentedPayloads: Buffer[] = [];
|
||||
|
||||
function emitMessage(payload: Buffer): void {
|
||||
process.stdout.write(payload);
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
|
||||
function readFrames(): void {
|
||||
while (networkBuffer.length >= 2) {
|
||||
const first = networkBuffer[0]!;
|
||||
const second = networkBuffer[1]!;
|
||||
const masked = (second & 0x80) !== 0;
|
||||
let length = second & 0x7f;
|
||||
let offset = 2;
|
||||
if (length === 126) {
|
||||
if (networkBuffer.length < 4) return;
|
||||
length = networkBuffer.readUInt16BE(2);
|
||||
offset = 4;
|
||||
} else if (length === 127) {
|
||||
if (networkBuffer.length < 10) return;
|
||||
const wideLength = networkBuffer.readBigUInt64BE(2);
|
||||
if (wideLength > BigInt(Number.MAX_SAFE_INTEGER))
|
||||
throw new Error("Codex app-server WebSocket frame is too large");
|
||||
length = Number(wideLength);
|
||||
offset = 10;
|
||||
}
|
||||
const maskLength = masked ? 4 : 0;
|
||||
if (networkBuffer.length < offset + maskLength + length) return;
|
||||
const mask = masked
|
||||
? networkBuffer.subarray(offset, offset + 4)
|
||||
: null;
|
||||
offset += maskLength;
|
||||
const payload = Buffer.from(
|
||||
networkBuffer.subarray(offset, offset + length),
|
||||
);
|
||||
networkBuffer = networkBuffer.subarray(offset + length);
|
||||
if (mask)
|
||||
for (let index = 0; index < payload.length; index += 1)
|
||||
payload[index] = payload[index]! ^ mask[index % 4]!;
|
||||
|
||||
const final = (first & 0x80) !== 0;
|
||||
const opcode = first & 0x0f;
|
||||
if (opcode === 0x8) {
|
||||
socket.end(clientFrame(payload, 0x8));
|
||||
return;
|
||||
}
|
||||
if (opcode === 0x9) {
|
||||
socket.write(clientFrame(payload, 0xa));
|
||||
continue;
|
||||
}
|
||||
if (opcode === 0x1 || opcode === 0x2 || opcode === 0x0) {
|
||||
fragmentedPayloads.push(payload);
|
||||
if (final) {
|
||||
emitMessage(Buffer.concat(fragmentedPayloads));
|
||||
fragmentedPayloads = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket.once("connect", () => {
|
||||
process.stdin.pipe(socket);
|
||||
socket.pipe(process.stdout);
|
||||
socket.write(
|
||||
`GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${websocketKey}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
|
||||
);
|
||||
});
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
networkBuffer = Buffer.concat([networkBuffer, chunk]);
|
||||
if (!connected) {
|
||||
const boundary = networkBuffer.indexOf("\r\n\r\n");
|
||||
if (boundary < 0) return;
|
||||
const headers = networkBuffer.subarray(0, boundary).toString("utf8");
|
||||
networkBuffer = networkBuffer.subarray(boundary + 4);
|
||||
if (!headers.startsWith("HTTP/1.1 101 "))
|
||||
throw new Error("Codex app-server rejected the WebSocket upgrade");
|
||||
const accept = headers
|
||||
.match(/^sec-websocket-accept:\s*(.+)$/im)?.[1]
|
||||
?.trim();
|
||||
if (accept !== expectedAccept)
|
||||
throw new Error("Codex app-server returned an invalid WebSocket accept value");
|
||||
connected = true;
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => {
|
||||
inputBuffer += chunk;
|
||||
for (;;) {
|
||||
const newline = inputBuffer.indexOf("\n");
|
||||
if (newline < 0) break;
|
||||
const line = inputBuffer.slice(0, newline).trim();
|
||||
inputBuffer = inputBuffer.slice(newline + 1);
|
||||
if (line) socket.write(clientFrame(Buffer.from(line)));
|
||||
}
|
||||
});
|
||||
process.stdin.resume();
|
||||
}
|
||||
readFrames();
|
||||
});
|
||||
socket.once("error", (error) => {
|
||||
process.stderr.write(`Codex app-server socket error: ${error.message}\n`);
|
||||
|
||||
@@ -2596,8 +2596,14 @@ export async function runDurableEvalSession(
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 20));
|
||||
}
|
||||
if (nativeResumeCall === null) {
|
||||
handle.child.kill("SIGKILL");
|
||||
const timedOutRunner = await handle.completion;
|
||||
const diagnostics =
|
||||
timedOutRunner.stderr.trim() ||
|
||||
sharedCodexServerStderr.trim() ||
|
||||
"no process diagnostics";
|
||||
throw new Error(
|
||||
`native resume operation ${input.nativeResume.operationId} did not commit before timeout`,
|
||||
`native resume operation ${input.nativeResume.operationId} did not commit before timeout: ${diagnostics}`,
|
||||
);
|
||||
}
|
||||
handle.child.kill("SIGKILL");
|
||||
|
||||
@@ -394,8 +394,13 @@ describe.sequential("Durable transport and recovery", () => {
|
||||
],
|
||||
providerCommand: process.execPath,
|
||||
providerArgs: [
|
||||
providerFixture,
|
||||
"proxy",
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../dist/cli/codex-app-server-unix-proxy.js",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
"--socket",
|
||||
resolve(root, "provider.sock"),
|
||||
],
|
||||
seed: {
|
||||
|
||||
+77
-10
@@ -1,7 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { createConnection, createServer } from "node:net";
|
||||
|
||||
const websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
function serverFrame(payload, opcode = 0x1) {
|
||||
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
||||
const extended = body.length < 126 ? 0 : body.length <= 0xffff ? 2 : 8;
|
||||
const frame = Buffer.alloc(2 + extended + body.length);
|
||||
frame[0] = 0x80 | opcode;
|
||||
frame[1] = body.length < 126 ? body.length : extended === 2 ? 126 : 127;
|
||||
let offset = 2;
|
||||
if (extended === 2) {
|
||||
frame.writeUInt16BE(body.length, offset);
|
||||
offset += 2;
|
||||
} else if (extended === 8) {
|
||||
frame.writeBigUInt64BE(BigInt(body.length), offset);
|
||||
offset += 8;
|
||||
}
|
||||
body.copy(frame, offset);
|
||||
return frame;
|
||||
}
|
||||
|
||||
const [mode, socketPath, statePath] = process.argv.slice(2);
|
||||
if (!mode || !socketPath) throw new Error("mode and socket path are required");
|
||||
|
||||
@@ -40,7 +61,7 @@ if (mode === "proxy") {
|
||||
}
|
||||
|
||||
function send(socket, value) {
|
||||
socket.write(`${JSON.stringify(value)}\n`);
|
||||
socket.write(serverFrame(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
function sendPendingTool(socket) {
|
||||
@@ -190,16 +211,62 @@ if (mode === "proxy") {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
const server = createServer((socket) => {
|
||||
let buffer = "";
|
||||
socket.setEncoding("utf8");
|
||||
let upgraded = false;
|
||||
let buffer = Buffer.alloc(0);
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
for (;;) {
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) break;
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line) handle(socket, JSON.parse(line));
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
if (!upgraded) {
|
||||
const boundary = buffer.indexOf("\r\n\r\n");
|
||||
if (boundary < 0) return;
|
||||
const headers = buffer.subarray(0, boundary).toString("utf8");
|
||||
const key = headers.match(/^sec-websocket-key:\s*(.+)$/im)?.[1]?.trim();
|
||||
if (!key) throw new Error("WebSocket upgrade omitted Sec-WebSocket-Key");
|
||||
const accept = createHash("sha1")
|
||||
.update(`${key}${websocketGuid}`)
|
||||
.digest("base64");
|
||||
socket.write(
|
||||
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
||||
);
|
||||
upgraded = true;
|
||||
buffer = buffer.subarray(boundary + 4);
|
||||
}
|
||||
|
||||
while (buffer.length >= 2) {
|
||||
const first = buffer[0];
|
||||
const second = buffer[1];
|
||||
let length = second & 0x7f;
|
||||
let offset = 2;
|
||||
if (length === 126) {
|
||||
if (buffer.length < 4) return;
|
||||
length = buffer.readUInt16BE(2);
|
||||
offset = 4;
|
||||
} else if (length === 127) {
|
||||
if (buffer.length < 10) return;
|
||||
length = Number(buffer.readBigUInt64BE(2));
|
||||
offset = 10;
|
||||
}
|
||||
if ((second & 0x80) === 0)
|
||||
throw new Error("WebSocket client frame was not masked");
|
||||
if (buffer.length < offset + 4 + length) return;
|
||||
const mask = buffer.subarray(offset, offset + 4);
|
||||
offset += 4;
|
||||
const payload = Buffer.from(buffer.subarray(offset, offset + length));
|
||||
buffer = buffer.subarray(offset + length);
|
||||
for (let index = 0; index < payload.length; index += 1)
|
||||
payload[index] ^= mask[index % 4];
|
||||
|
||||
const opcode = first & 0x0f;
|
||||
if (opcode === 0x8) {
|
||||
socket.end(serverFrame(payload, 0x8));
|
||||
return;
|
||||
}
|
||||
if (opcode === 0x9) {
|
||||
socket.write(serverFrame(payload, 0xa));
|
||||
continue;
|
||||
}
|
||||
if (opcode !== 0x1)
|
||||
throw new Error(`unsupported WebSocket opcode ${opcode}`);
|
||||
handle(socket, JSON.parse(payload.toString("utf8")));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user