relmio 0.4.1 → 0.6.0
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/CHANGELOG.md +51 -0
- package/README.md +40 -16
- package/docs/architecture.md +21 -9
- package/docs/local-endpoints-spec.md +101 -13
- package/docs/local-endpoints.md +101 -30
- package/docs/security.md +40 -15
- package/package.json +1 -1
- package/src/domain/local-endpoints.js +187 -14
- package/src/gateway/codex-chat.js +754 -0
- package/src/services/codex-login.js +11 -3
- package/src/services/local-installer.js +859 -20
- package/src/ui/local.css +140 -2
- package/src/ui/local.html +89 -9
- package/src/ui/local.js +157 -37
- package/src/web/server.js +247 -18
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
const MAX_HEADER_BYTES = 16 * 1024;
|
|
7
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
8
|
+
const MAX_INPUT_BYTES = 12 * 1024;
|
|
9
|
+
const MAX_OUTPUT_BYTES = 128 * 1024;
|
|
10
|
+
const MAX_PROTOCOL_LINE_BYTES = 64 * 1024;
|
|
11
|
+
const MAX_PROTOCOL_STDOUT_BYTES = 256 * 1024;
|
|
12
|
+
const MAX_PROTOCOL_STDERR_BYTES = 64 * 1024;
|
|
13
|
+
const TURN_TIMEOUT_MS = 120_000;
|
|
14
|
+
const TERMINATION_GRACE_MS = 2_000;
|
|
15
|
+
const CONVERSATIONAL_INSTRUCTION =
|
|
16
|
+
"Provide a conversational answer only. Do not inspect or edit files, run commands, call tools, or access external resources.";
|
|
17
|
+
|
|
18
|
+
function isLoopbackListenHost(value) {
|
|
19
|
+
return value === "127.0.0.1" || value === "0.0.0.0" || value === "::1";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateTokenVerifier(value) {
|
|
23
|
+
if (!Buffer.isBuffer(value) || value.length !== 32) {
|
|
24
|
+
throw new TypeError("A SHA-256 local client credential verifier is required.");
|
|
25
|
+
}
|
|
26
|
+
return Buffer.from(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function headerOccurrences(request, expectedName) {
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
32
|
+
if (request.rawHeaders[index].toLowerCase() === expectedName) {
|
|
33
|
+
count += 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return count;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validLoopbackHost(value) {
|
|
40
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 261) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const match = /^(\[[^\]]+\]|[^:[\]]+)(?::([0-9]{1,5}))?$/u.exec(value);
|
|
44
|
+
if (!match || /[\s,@/?#\\]/u.test(value)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (match[2] !== undefined && (Number(match[2]) < 1 || Number(match[2]) > 65535)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return ["127.0.0.1", "localhost", "[::1]"].includes(match[1].toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sendJson(response, status, body) {
|
|
54
|
+
const contents = JSON.stringify(body);
|
|
55
|
+
response.writeHead(status, {
|
|
56
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
57
|
+
"Content-Length": Buffer.byteLength(contents),
|
|
58
|
+
"Cache-Control": "no-store",
|
|
59
|
+
"X-Content-Type-Options": "nosniff",
|
|
60
|
+
});
|
|
61
|
+
response.end(contents);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sendError(response, status, code) {
|
|
65
|
+
sendJson(response, status, { error: { code } });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasValidBearer(request, verifier) {
|
|
69
|
+
if (headerOccurrences(request, "authorization") !== 1) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const value = request.headers.authorization;
|
|
73
|
+
if (typeof value !== "string" || value.length > 512) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const match = /^Bearer ([A-Za-z0-9_-]{1,256})$/u.exec(value);
|
|
77
|
+
if (!match) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const candidate = createHash("sha256").update(match[1], "utf8").digest();
|
|
81
|
+
return timingSafeEqual(candidate, verifier);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isPlainObject(value) {
|
|
85
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isSafeIdentifier(value) {
|
|
89
|
+
return (
|
|
90
|
+
typeof value === "string" &&
|
|
91
|
+
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(value)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validatePackageVersion(value) {
|
|
96
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9.+-]{1,64}$/u.test(value)) {
|
|
97
|
+
throw new TypeError("The Relmio package version is invalid.");
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseContentType(request) {
|
|
103
|
+
if (headerOccurrences(request, "content-type") !== 1) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const value = request.headers["content-type"];
|
|
107
|
+
return (
|
|
108
|
+
typeof value === "string" &&
|
|
109
|
+
/^application\/json(?:\s*;\s*charset=utf-8)?$/iu.test(value)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readChatRequest(request) {
|
|
114
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
115
|
+
const chunks = [];
|
|
116
|
+
let bytes = 0;
|
|
117
|
+
let rejected = false;
|
|
118
|
+
const reject = (code, status = 400) => {
|
|
119
|
+
if (!rejected) {
|
|
120
|
+
rejected = true;
|
|
121
|
+
rejectPromise(Object.assign(new Error(code), { code, status }));
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
request.on("data", (chunk) => {
|
|
125
|
+
if (rejected) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const value = Buffer.from(chunk);
|
|
129
|
+
bytes += value.length;
|
|
130
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
131
|
+
reject("body_too_large", 413);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
chunks.push(value);
|
|
135
|
+
});
|
|
136
|
+
request.once("aborted", () => reject("client_disconnected", 499));
|
|
137
|
+
request.once("error", () => reject("invalid_request"));
|
|
138
|
+
request.once("end", () => {
|
|
139
|
+
if (rejected) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let body;
|
|
143
|
+
try {
|
|
144
|
+
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
145
|
+
} catch {
|
|
146
|
+
reject("invalid_json");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (!isPlainObject(body)) {
|
|
150
|
+
reject("invalid_request");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const keys = Object.keys(body).sort();
|
|
154
|
+
if (
|
|
155
|
+
!keys.includes("input") ||
|
|
156
|
+
keys.some((key) => key !== "input" && key !== "conversationId")
|
|
157
|
+
) {
|
|
158
|
+
reject("invalid_request");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
typeof body.input !== "string" ||
|
|
163
|
+
body.input.trim() === "" ||
|
|
164
|
+
body.input.includes("\0") ||
|
|
165
|
+
Buffer.byteLength(body.input, "utf8") > MAX_INPUT_BYTES
|
|
166
|
+
) {
|
|
167
|
+
reject("invalid_request");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
body.conversationId !== undefined &&
|
|
172
|
+
!isSafeIdentifier(body.conversationId)
|
|
173
|
+
) {
|
|
174
|
+
reject("invalid_request");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
resolvePromise({
|
|
178
|
+
input: body.input,
|
|
179
|
+
conversationId: body.conversationId,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeFinalMessage(value) {
|
|
186
|
+
return (
|
|
187
|
+
typeof value === "string" &&
|
|
188
|
+
value.length > 0 &&
|
|
189
|
+
Buffer.byteLength(value, "utf8") <= MAX_OUTPUT_BYTES
|
|
190
|
+
? value
|
|
191
|
+
: null
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function createAppServerOperation({
|
|
196
|
+
input,
|
|
197
|
+
conversationId,
|
|
198
|
+
packageVersion,
|
|
199
|
+
signal,
|
|
200
|
+
spawnProcess,
|
|
201
|
+
terminationGraceMs,
|
|
202
|
+
turnTimeoutMs,
|
|
203
|
+
}) {
|
|
204
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
205
|
+
let child;
|
|
206
|
+
try {
|
|
207
|
+
child = spawnProcess(
|
|
208
|
+
"codex",
|
|
209
|
+
["app-server", "--strict-config", "--stdio"],
|
|
210
|
+
{
|
|
211
|
+
cwd: "/workspace",
|
|
212
|
+
shell: false,
|
|
213
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
214
|
+
windowsHide: true,
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
rejectPromise(new Error("unavailable"));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
!child ||
|
|
223
|
+
typeof child.kill !== "function" ||
|
|
224
|
+
typeof child.once !== "function" ||
|
|
225
|
+
typeof child.stdout?.on !== "function" ||
|
|
226
|
+
typeof child.stderr?.on !== "function" ||
|
|
227
|
+
typeof child.stdin?.on !== "function" ||
|
|
228
|
+
typeof child.stdin?.write !== "function"
|
|
229
|
+
) {
|
|
230
|
+
rejectPromise(new Error("unavailable"));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let settled = false;
|
|
235
|
+
let closing = false;
|
|
236
|
+
let stdout = Buffer.alloc(0);
|
|
237
|
+
let stdoutBytes = 0;
|
|
238
|
+
let stderrBytes = 0;
|
|
239
|
+
let threadId = null;
|
|
240
|
+
let turnId = null;
|
|
241
|
+
let finalOutput = null;
|
|
242
|
+
let latestDeltaItemId = null;
|
|
243
|
+
let deltaBytes = 0;
|
|
244
|
+
const deltaOutputs = new Map();
|
|
245
|
+
let phase = "initializing";
|
|
246
|
+
let outcome = null;
|
|
247
|
+
let timeout;
|
|
248
|
+
let killTimeout;
|
|
249
|
+
let reapTimeout;
|
|
250
|
+
|
|
251
|
+
const clearTimers = () => {
|
|
252
|
+
clearTimeout(timeout);
|
|
253
|
+
clearTimeout(killTimeout);
|
|
254
|
+
clearTimeout(reapTimeout);
|
|
255
|
+
timeout = undefined;
|
|
256
|
+
killTimeout = undefined;
|
|
257
|
+
reapTimeout = undefined;
|
|
258
|
+
};
|
|
259
|
+
const complete = () => {
|
|
260
|
+
if (settled || !outcome) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
settled = true;
|
|
264
|
+
signal?.removeEventListener?.("abort", abortOperation);
|
|
265
|
+
clearTimers();
|
|
266
|
+
if (outcome.error) {
|
|
267
|
+
rejectPromise(new Error("unavailable"));
|
|
268
|
+
} else {
|
|
269
|
+
resolvePromise(outcome.result);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const terminate = () => {
|
|
273
|
+
if (closing) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
closing = true;
|
|
277
|
+
try {
|
|
278
|
+
child.stdin.end?.();
|
|
279
|
+
} catch {
|
|
280
|
+
// The child is already being terminated.
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
child.kill("SIGTERM");
|
|
284
|
+
} catch {
|
|
285
|
+
// A failed termination still receives a redacted result.
|
|
286
|
+
}
|
|
287
|
+
killTimeout = setTimeout(() => {
|
|
288
|
+
try {
|
|
289
|
+
child.kill("SIGKILL");
|
|
290
|
+
} catch {
|
|
291
|
+
// There is no useful process detail to expose to the client.
|
|
292
|
+
}
|
|
293
|
+
reapTimeout = setTimeout(complete, terminationGraceMs);
|
|
294
|
+
}, terminationGraceMs);
|
|
295
|
+
};
|
|
296
|
+
const settle = (error, result) => {
|
|
297
|
+
if (settled || outcome) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
outcome = { error, result };
|
|
301
|
+
signal?.removeEventListener?.("abort", abortOperation);
|
|
302
|
+
clearTimeout(timeout);
|
|
303
|
+
timeout = undefined;
|
|
304
|
+
terminate();
|
|
305
|
+
};
|
|
306
|
+
const failProtocol = () => settle(new Error("protocol"));
|
|
307
|
+
function abortOperation() {
|
|
308
|
+
settle(new Error("disconnected"));
|
|
309
|
+
}
|
|
310
|
+
const write = (message) => {
|
|
311
|
+
try {
|
|
312
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
313
|
+
} catch {
|
|
314
|
+
failProtocol();
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
const startThread = () => {
|
|
318
|
+
phase = "thread";
|
|
319
|
+
write({
|
|
320
|
+
id: 1,
|
|
321
|
+
method: conversationId ? "thread/resume" : "thread/start",
|
|
322
|
+
params: conversationId
|
|
323
|
+
? {
|
|
324
|
+
approvalPolicy: "never",
|
|
325
|
+
cwd: "/workspace",
|
|
326
|
+
developerInstructions: CONVERSATIONAL_INSTRUCTION,
|
|
327
|
+
permissions: "relmio-chat-readonly",
|
|
328
|
+
threadId: conversationId,
|
|
329
|
+
}
|
|
330
|
+
: {
|
|
331
|
+
approvalPolicy: "never",
|
|
332
|
+
cwd: "/workspace",
|
|
333
|
+
developerInstructions: CONVERSATIONAL_INSTRUCTION,
|
|
334
|
+
permissions: "relmio-chat-readonly",
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
};
|
|
338
|
+
const startTurn = () => {
|
|
339
|
+
phase = "turn";
|
|
340
|
+
write({
|
|
341
|
+
id: 2,
|
|
342
|
+
method: "turn/start",
|
|
343
|
+
params: {
|
|
344
|
+
approvalPolicy: "never",
|
|
345
|
+
cwd: "/workspace",
|
|
346
|
+
input: [{ text: input, type: "text" }],
|
|
347
|
+
permissions: "relmio-chat-readonly",
|
|
348
|
+
threadId,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
};
|
|
352
|
+
const processMessage = (message) => {
|
|
353
|
+
if (!isPlainObject(message) || settled || outcome) {
|
|
354
|
+
failProtocol();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (message.id === 0) {
|
|
358
|
+
if (phase !== "initializing" || message.error !== undefined || !isPlainObject(message.result)) {
|
|
359
|
+
failProtocol();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
write({ method: "initialized", params: {} });
|
|
363
|
+
startThread();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (message.id === 1) {
|
|
367
|
+
const candidate = message.result?.thread?.id;
|
|
368
|
+
const activeProfile = message.result?.activePermissionProfile;
|
|
369
|
+
if (
|
|
370
|
+
phase !== "thread" ||
|
|
371
|
+
message.error !== undefined ||
|
|
372
|
+
!isSafeIdentifier(candidate) ||
|
|
373
|
+
!isPlainObject(activeProfile) ||
|
|
374
|
+
activeProfile.id !== "relmio-chat-readonly" ||
|
|
375
|
+
activeProfile.extends !== ":read-only"
|
|
376
|
+
) {
|
|
377
|
+
failProtocol();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
threadId = candidate;
|
|
381
|
+
startTurn();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (message.id === 2) {
|
|
385
|
+
const candidate = message.result?.turn?.id;
|
|
386
|
+
if (phase !== "turn" || message.error !== undefined || !isSafeIdentifier(candidate)) {
|
|
387
|
+
failProtocol();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
turnId = candidate;
|
|
391
|
+
phase = "waiting";
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (message.id !== undefined) {
|
|
395
|
+
failProtocol();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (typeof message.method !== "string" || !isPlainObject(message.params)) {
|
|
399
|
+
failProtocol();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const params = message.params;
|
|
403
|
+
if (message.method === "item/agentMessage/delta") {
|
|
404
|
+
if (
|
|
405
|
+
phase !== "waiting" ||
|
|
406
|
+
params.threadId !== threadId ||
|
|
407
|
+
params.turnId !== turnId ||
|
|
408
|
+
!isSafeIdentifier(params.itemId) ||
|
|
409
|
+
typeof params.delta !== "string" ||
|
|
410
|
+
Buffer.byteLength(params.delta, "utf8") > MAX_OUTPUT_BYTES
|
|
411
|
+
) {
|
|
412
|
+
failProtocol();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const partBytes = Buffer.byteLength(params.delta, "utf8");
|
|
416
|
+
if (deltaBytes + partBytes > MAX_OUTPUT_BYTES) {
|
|
417
|
+
failProtocol();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
deltaBytes += partBytes;
|
|
421
|
+
latestDeltaItemId = params.itemId;
|
|
422
|
+
deltaOutputs.set(
|
|
423
|
+
params.itemId,
|
|
424
|
+
`${deltaOutputs.get(params.itemId) ?? ""}${params.delta}`,
|
|
425
|
+
);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (message.method === "item/completed") {
|
|
429
|
+
if (phase !== "waiting" || params.threadId !== threadId || params.turnId !== turnId || !isPlainObject(params.item)) {
|
|
430
|
+
failProtocol();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (params.item.type === "agentMessage") {
|
|
434
|
+
if (!isSafeIdentifier(params.item.id)) {
|
|
435
|
+
failProtocol();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const output = normalizeFinalMessage(params.item.text);
|
|
439
|
+
if (!output) {
|
|
440
|
+
failProtocol();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
finalOutput = output;
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (message.method === "turn/completed") {
|
|
448
|
+
if (
|
|
449
|
+
phase !== "waiting" ||
|
|
450
|
+
params.threadId !== threadId ||
|
|
451
|
+
!isPlainObject(params.turn) ||
|
|
452
|
+
params.turn.id !== turnId ||
|
|
453
|
+
params.turn.status !== "completed"
|
|
454
|
+
) {
|
|
455
|
+
failProtocol();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const completedItem = Array.isArray(params.turn.items)
|
|
459
|
+
? [...params.turn.items]
|
|
460
|
+
.reverse()
|
|
461
|
+
.find((item) => isPlainObject(item) && item.type === "agentMessage")
|
|
462
|
+
: null;
|
|
463
|
+
const output =
|
|
464
|
+
finalOutput ??
|
|
465
|
+
normalizeFinalMessage(completedItem?.text) ??
|
|
466
|
+
normalizeFinalMessage(deltaOutputs.get(latestDeltaItemId));
|
|
467
|
+
if (!output) {
|
|
468
|
+
failProtocol();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
settle(null, { conversationId: threadId, output });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// App Server emits normal thread, turn, item, warning, and usage
|
|
475
|
+
// notifications around the response. They are informational for this
|
|
476
|
+
// deliberately narrow adapter and must not break a valid chat turn.
|
|
477
|
+
};
|
|
478
|
+
const processLine = (line) => {
|
|
479
|
+
if (line.length > MAX_PROTOCOL_LINE_BYTES) {
|
|
480
|
+
failProtocol();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const trimmed = line.at(-1) === 0x0d ? line.subarray(0, -1) : line;
|
|
484
|
+
if (trimmed.length === 0) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
processMessage(JSON.parse(trimmed.toString("utf8")));
|
|
489
|
+
} catch {
|
|
490
|
+
failProtocol();
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
child.stdout.on("data", (chunk) => {
|
|
495
|
+
if (settled || outcome) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const bytes = Buffer.from(chunk);
|
|
499
|
+
stdoutBytes += bytes.length;
|
|
500
|
+
if (stdoutBytes > MAX_PROTOCOL_STDOUT_BYTES) {
|
|
501
|
+
failProtocol();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
stdout = Buffer.concat([stdout, bytes]);
|
|
505
|
+
let newline = stdout.indexOf(0x0a);
|
|
506
|
+
while (newline >= 0 && !settled && !outcome) {
|
|
507
|
+
const line = stdout.subarray(0, newline);
|
|
508
|
+
stdout = stdout.subarray(newline + 1);
|
|
509
|
+
processLine(line);
|
|
510
|
+
newline = stdout.indexOf(0x0a);
|
|
511
|
+
}
|
|
512
|
+
if (!settled && !outcome && stdout.length > MAX_PROTOCOL_LINE_BYTES) {
|
|
513
|
+
failProtocol();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
child.stderr.on("data", (chunk) => {
|
|
517
|
+
if (!settled && !outcome) {
|
|
518
|
+
stderrBytes += Buffer.byteLength(chunk);
|
|
519
|
+
if (stderrBytes > MAX_PROTOCOL_STDERR_BYTES) {
|
|
520
|
+
failProtocol();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
const handleStreamError = () => settle(new Error("stream"));
|
|
525
|
+
child.stdin.on("error", handleStreamError);
|
|
526
|
+
child.stdout.on("error", handleStreamError);
|
|
527
|
+
child.stderr.on("error", handleStreamError);
|
|
528
|
+
child.once("error", () => settle(new Error("process")));
|
|
529
|
+
child.once("close", () => {
|
|
530
|
+
if (!outcome) {
|
|
531
|
+
outcome = { error: new Error("closed"), result: undefined };
|
|
532
|
+
}
|
|
533
|
+
complete();
|
|
534
|
+
});
|
|
535
|
+
timeout = setTimeout(() => settle(new Error("timeout")), turnTimeoutMs);
|
|
536
|
+
if (signal?.aborted) {
|
|
537
|
+
abortOperation();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
signal?.addEventListener?.("abort", abortOperation, { once: true });
|
|
541
|
+
write({
|
|
542
|
+
id: 0,
|
|
543
|
+
method: "initialize",
|
|
544
|
+
params: {
|
|
545
|
+
capabilities: {
|
|
546
|
+
experimentalApi: true,
|
|
547
|
+
},
|
|
548
|
+
clientInfo: {
|
|
549
|
+
name: "relmio",
|
|
550
|
+
title: "Relmio",
|
|
551
|
+
version: packageVersion,
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function hashCodexChatCredential(value) {
|
|
559
|
+
if (
|
|
560
|
+
typeof value !== "string" ||
|
|
561
|
+
value.length === 0 ||
|
|
562
|
+
value.length > 256 ||
|
|
563
|
+
!/^[A-Za-z0-9_-]+$/u.test(value)
|
|
564
|
+
) {
|
|
565
|
+
throw new TypeError("The local client credential is invalid.");
|
|
566
|
+
}
|
|
567
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function loadCodexChatGatewayConfig(environment = process.env) {
|
|
571
|
+
const host = environment?.RELMIO_GATEWAY_HOST;
|
|
572
|
+
const portText = environment?.RELMIO_GATEWAY_PORT;
|
|
573
|
+
const verifier = environment?.RELMIO_GATEWAY_TOKEN_SHA256;
|
|
574
|
+
const packageVersion = environment?.RELMIO_PACKAGE_VERSION;
|
|
575
|
+
if (
|
|
576
|
+
!isLoopbackListenHost(host) ||
|
|
577
|
+
typeof portText !== "string" ||
|
|
578
|
+
!/^[1-9][0-9]{0,4}$/u.test(portText) ||
|
|
579
|
+
Number(portText) < 1024 ||
|
|
580
|
+
Number(portText) > 65_535 ||
|
|
581
|
+
typeof verifier !== "string" ||
|
|
582
|
+
!/^[a-f0-9]{64}$/u.test(verifier)
|
|
583
|
+
) {
|
|
584
|
+
throw new TypeError("Codex Chat gateway configuration is invalid.");
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
return {
|
|
588
|
+
host,
|
|
589
|
+
packageVersion: validatePackageVersion(packageVersion),
|
|
590
|
+
port: Number(portText),
|
|
591
|
+
tokenVerifier: Buffer.from(verifier, "hex"),
|
|
592
|
+
};
|
|
593
|
+
} catch {
|
|
594
|
+
throw new TypeError("Codex Chat gateway configuration is invalid.");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export async function startCodexChatGateway({
|
|
599
|
+
host = "127.0.0.1",
|
|
600
|
+
port = 14_501,
|
|
601
|
+
tokenVerifier,
|
|
602
|
+
packageVersion = "unknown",
|
|
603
|
+
spawnProcess = spawn,
|
|
604
|
+
terminationGraceMs = TERMINATION_GRACE_MS,
|
|
605
|
+
turnTimeoutMs = TURN_TIMEOUT_MS,
|
|
606
|
+
} = {}) {
|
|
607
|
+
if (!isLoopbackListenHost(host)) {
|
|
608
|
+
throw new TypeError("Codex Chat must listen on a literal loopback-safe host.");
|
|
609
|
+
}
|
|
610
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
611
|
+
throw new TypeError("The Codex Chat port is invalid.");
|
|
612
|
+
}
|
|
613
|
+
if (typeof spawnProcess !== "function") {
|
|
614
|
+
throw new TypeError("A Codex App Server process boundary is required.");
|
|
615
|
+
}
|
|
616
|
+
if (
|
|
617
|
+
!Number.isSafeInteger(terminationGraceMs) ||
|
|
618
|
+
terminationGraceMs < 1 ||
|
|
619
|
+
terminationGraceMs > 10_000
|
|
620
|
+
) {
|
|
621
|
+
throw new TypeError("The Codex Chat termination grace period is invalid.");
|
|
622
|
+
}
|
|
623
|
+
if (
|
|
624
|
+
!Number.isSafeInteger(turnTimeoutMs) ||
|
|
625
|
+
turnTimeoutMs < 1 ||
|
|
626
|
+
turnTimeoutMs > 600_000
|
|
627
|
+
) {
|
|
628
|
+
throw new TypeError("The Codex Chat turn timeout is invalid.");
|
|
629
|
+
}
|
|
630
|
+
const verifier = validateTokenVerifier(tokenVerifier);
|
|
631
|
+
const safePackageVersion = validatePackageVersion(packageVersion);
|
|
632
|
+
let activeOperation = false;
|
|
633
|
+
const server = createServer({ maxHeaderSize: MAX_HEADER_BYTES }, (request, response) => {
|
|
634
|
+
if (headerOccurrences(request, "host") !== 1 || !validLoopbackHost(request.headers.host)) {
|
|
635
|
+
sendError(response, 421, "host_rejected");
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (headerOccurrences(request, "origin") > 0) {
|
|
639
|
+
sendError(response, 403, "origin_rejected");
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (
|
|
643
|
+
headerOccurrences(request, "authorization") > 1 ||
|
|
644
|
+
headerOccurrences(request, "content-type") > 1
|
|
645
|
+
) {
|
|
646
|
+
sendError(response, 400, "invalid_request");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (request.method === "GET" && request.url === "/health") {
|
|
650
|
+
sendJson(response, 200, { status: "ok" });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (!hasValidBearer(request, verifier)) {
|
|
654
|
+
sendError(response, 401, "unauthorized");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (request.method === "GET" && request.url === "/auth/verify") {
|
|
658
|
+
sendJson(response, 200, { status: "ok" });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (request.method !== "POST" || request.url !== "/chat") {
|
|
662
|
+
sendError(response, 404, "not_found");
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (!parseContentType(request)) {
|
|
666
|
+
sendError(response, 415, "content_type_required");
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (activeOperation) {
|
|
670
|
+
sendError(response, 429, "busy");
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
activeOperation = true;
|
|
674
|
+
const controller = new AbortController();
|
|
675
|
+
let disconnected = false;
|
|
676
|
+
const onDisconnect = () => {
|
|
677
|
+
if (request.aborted || !response.writableEnded) {
|
|
678
|
+
disconnected = true;
|
|
679
|
+
controller.abort();
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
request.once("aborted", onDisconnect);
|
|
683
|
+
response.once("close", onDisconnect);
|
|
684
|
+
void readChatRequest(request)
|
|
685
|
+
.then((chat) => {
|
|
686
|
+
if (disconnected) {
|
|
687
|
+
throw new Error("unavailable");
|
|
688
|
+
}
|
|
689
|
+
return createAppServerOperation({
|
|
690
|
+
...chat,
|
|
691
|
+
packageVersion: safePackageVersion,
|
|
692
|
+
signal: controller.signal,
|
|
693
|
+
spawnProcess,
|
|
694
|
+
terminationGraceMs,
|
|
695
|
+
turnTimeoutMs,
|
|
696
|
+
});
|
|
697
|
+
})
|
|
698
|
+
.then((result) => {
|
|
699
|
+
if (!disconnected && !response.writableEnded) {
|
|
700
|
+
sendJson(response, 200, result);
|
|
701
|
+
}
|
|
702
|
+
})
|
|
703
|
+
.catch((error) => {
|
|
704
|
+
if (disconnected || response.writableEnded) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const status = error?.status;
|
|
708
|
+
const code = error?.code;
|
|
709
|
+
if (status === 413 || status === 499 || code === "invalid_json" || code === "invalid_request") {
|
|
710
|
+
sendError(response, status === 499 ? 400 : status ?? 400, code === "invalid_json" ? "invalid_json" : "invalid_request");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
sendError(response, 503, "unavailable");
|
|
714
|
+
})
|
|
715
|
+
.finally(() => {
|
|
716
|
+
activeOperation = false;
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
server.headersTimeout = 10_000;
|
|
720
|
+
server.requestTimeout = 30_000;
|
|
721
|
+
server.keepAliveTimeout = 5_000;
|
|
722
|
+
server.maxHeadersCount = 32;
|
|
723
|
+
|
|
724
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
725
|
+
server.once("error", rejectPromise);
|
|
726
|
+
server.listen(port, host, () => {
|
|
727
|
+
server.off("error", rejectPromise);
|
|
728
|
+
resolvePromise();
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
const address = server.address();
|
|
732
|
+
if (!address || typeof address === "string") {
|
|
733
|
+
server.close();
|
|
734
|
+
throw new Error("Codex Chat could not determine its listener address.");
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
origin: `http://127.0.0.1:${address.port}`,
|
|
738
|
+
async close() {
|
|
739
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
740
|
+
server.close((error) => (error ? rejectPromise(error) : resolvePromise()));
|
|
741
|
+
});
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (
|
|
747
|
+
process.argv[1] &&
|
|
748
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
749
|
+
) {
|
|
750
|
+
startCodexChatGateway(loadCodexChatGatewayConfig()).catch(() => {
|
|
751
|
+
process.stderr.write("Relmio Codex Chat could not start.\n");
|
|
752
|
+
process.exitCode = 1;
|
|
753
|
+
});
|
|
754
|
+
}
|