relmio 0.5.0 → 0.7.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 +50 -0
- package/README.md +42 -414
- package/docs/architecture.md +21 -9
- package/docs/faq.md +45 -0
- package/docs/getting-started.md +38 -0
- package/docs/local-endpoints-spec.md +128 -13
- package/docs/local-endpoints.md +129 -26
- package/docs/reference.md +77 -0
- package/docs/security.md +74 -15
- package/docs/troubleshooting.md +33 -0
- package/docs/vps-and-n8n.md +30 -0
- package/package.json +1 -1
- package/src/domain/local-endpoints.js +183 -13
- package/src/gateway/codex-chat.js +754 -0
- package/src/services/codex-login.js +11 -3
- package/src/services/local-chat-test.js +361 -0
- package/src/services/local-installer.js +94 -14
- package/src/ui/local.css +115 -1
- package/src/ui/local.html +119 -8
- package/src/ui/local.js +362 -38
- package/src/web/server.js +134 -4
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
|
|
10
10
|
const COMPOSE_FILE_NAME = "docker-compose.yml";
|
|
11
11
|
const PROJECT_NAME_PATTERN =
|
|
12
|
-
/^relmio-codex-chatgpt-[a-f0-9]{32}$/u;
|
|
12
|
+
/^relmio-codex-(chatgpt|chat)-[a-f0-9]{32}$/u;
|
|
13
13
|
const DEFAULT_RESPONSE_TIMEOUT_MS = 15_000;
|
|
14
14
|
const DEFAULT_COMPLETION_TIMEOUT_MS = 300_000;
|
|
15
15
|
const DEFAULT_TERMINATION_GRACE_MS = 2_000;
|
|
@@ -88,7 +88,12 @@ function validateOptions({
|
|
|
88
88
|
if (maxLineBytes > maxStdoutBytes) {
|
|
89
89
|
throw new TypeError("maxLineBytes cannot exceed maxStdoutBytes.");
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
const projectKind = PROJECT_NAME_PATTERN.exec(projectName)?.[1];
|
|
92
|
+
return {
|
|
93
|
+
dockerHost: validatedDockerHost,
|
|
94
|
+
projectName,
|
|
95
|
+
serviceName: projectKind === "chat" ? "codex-chat" : "codex",
|
|
96
|
+
};
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
function validateVerificationUrl(value) {
|
|
@@ -205,7 +210,10 @@ export async function startCodexDeviceLogin({
|
|
|
205
210
|
"run",
|
|
206
211
|
"--rm",
|
|
207
212
|
"--no-deps",
|
|
208
|
-
"codex"
|
|
213
|
+
...(validated.serviceName === "codex-chat"
|
|
214
|
+
? ["--entrypoint", "codex"]
|
|
215
|
+
: []),
|
|
216
|
+
validated.serviceName,
|
|
209
217
|
"app-server",
|
|
210
218
|
"--strict-config",
|
|
211
219
|
"--stdio",
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants,
|
|
3
|
+
generateKeyPair as generateKeyPairCallback,
|
|
4
|
+
privateDecrypt,
|
|
5
|
+
randomUUID,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const generateKeyPair = promisify(generateKeyPairCallback);
|
|
10
|
+
|
|
11
|
+
const MAX_SESSIONS = 8;
|
|
12
|
+
const KEY_TTL_MS = 5 * 60 * 1_000;
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
14
|
+
const MAX_ENDPOINT_LENGTH = 64;
|
|
15
|
+
const MAX_CIPHERTEXT_LENGTH = 4_096;
|
|
16
|
+
const MAX_INPUT_LENGTH = 8_192;
|
|
17
|
+
const MAX_CONVERSATION_ID_LENGTH = 160;
|
|
18
|
+
const MAX_RESPONSE_BYTES = 16 * 1_024;
|
|
19
|
+
const MAX_OUTPUT_LENGTH = 12 * 1_024;
|
|
20
|
+
|
|
21
|
+
function requestError(message, statusCode = 400) {
|
|
22
|
+
return Object.assign(new Error(message), { statusCode });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expiredKeyError() {
|
|
26
|
+
return requestError(
|
|
27
|
+
"This test credential has expired or was forgotten. Secure it again.",
|
|
28
|
+
409,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function adapterError(statusCode = 502) {
|
|
33
|
+
return requestError("The local adapter test could not be completed.", statusCode);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPlainObject(value) {
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isBoundedText(value, maximumLength) {
|
|
41
|
+
return (
|
|
42
|
+
typeof value === "string" &&
|
|
43
|
+
value.length > 0 &&
|
|
44
|
+
value.length <= maximumLength &&
|
|
45
|
+
!/[\u0000-\u001f\u007f]/u.test(value)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseLocalAdapterBaseUrl(value) {
|
|
50
|
+
if (typeof value !== "string" || value.length > MAX_ENDPOINT_LENGTH) {
|
|
51
|
+
throw requestError("Enter a valid local adapter address.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const match = /^http:\/\/127\.0\.0\.1:([1-9]\d{0,4})\/?$/u.exec(value);
|
|
55
|
+
if (!match) {
|
|
56
|
+
throw requestError("Enter a valid local adapter address.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const port = Number(match[1]);
|
|
60
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
|
61
|
+
throw requestError("Enter a valid local adapter address.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return `http://127.0.0.1:${port}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateMessageRequest(request) {
|
|
68
|
+
if (!isPlainObject(request)) {
|
|
69
|
+
throw requestError("Enter a valid local adapter test request.");
|
|
70
|
+
}
|
|
71
|
+
const endpointBaseUrl = parseLocalAdapterBaseUrl(request.endpointBaseUrl);
|
|
72
|
+
if (!isBoundedText(request.keyId, 128)) {
|
|
73
|
+
throw expiredKeyError();
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
typeof request.encryptedCredential !== "string" ||
|
|
77
|
+
request.encryptedCredential.length < 32 ||
|
|
78
|
+
request.encryptedCredential.length > MAX_CIPHERTEXT_LENGTH ||
|
|
79
|
+
!/^[A-Za-z0-9+/]+={0,2}$/u.test(request.encryptedCredential)
|
|
80
|
+
) {
|
|
81
|
+
throw requestError("Secure the client credential again before testing.");
|
|
82
|
+
}
|
|
83
|
+
if (!isBoundedText(request.input, MAX_INPUT_LENGTH)) {
|
|
84
|
+
throw requestError("Enter a shorter chat message.");
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
request.conversationId !== undefined &&
|
|
88
|
+
!isBoundedText(request.conversationId, MAX_CONVERSATION_ID_LENGTH)
|
|
89
|
+
) {
|
|
90
|
+
throw requestError("Start a new conversation and try again.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
endpointBaseUrl,
|
|
95
|
+
keyId: request.keyId,
|
|
96
|
+
encryptedCredential: request.encryptedCredential,
|
|
97
|
+
input: request.input,
|
|
98
|
+
...(request.conversationId !== undefined
|
|
99
|
+
? { conversationId: request.conversationId }
|
|
100
|
+
: {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function decodeCiphertext(value) {
|
|
105
|
+
const decoded = Buffer.from(value, "base64");
|
|
106
|
+
if (
|
|
107
|
+
decoded.length === 0 ||
|
|
108
|
+
decoded.toString("base64") !== value
|
|
109
|
+
) {
|
|
110
|
+
decoded.fill(0);
|
|
111
|
+
throw requestError("Secure the client credential again before testing.");
|
|
112
|
+
}
|
|
113
|
+
return decoded;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readBoundedResponse(response) {
|
|
117
|
+
const contentLength = response.headers?.get?.("content-length");
|
|
118
|
+
if (
|
|
119
|
+
contentLength !== null &&
|
|
120
|
+
contentLength !== undefined &&
|
|
121
|
+
(!/^\d+$/u.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)
|
|
122
|
+
) {
|
|
123
|
+
throw adapterError();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const reader = response.body?.getReader?.();
|
|
127
|
+
if (!reader) {
|
|
128
|
+
throw adapterError();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const chunks = [];
|
|
132
|
+
let bytes = 0;
|
|
133
|
+
try {
|
|
134
|
+
for (;;) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const chunk = Buffer.from(value);
|
|
140
|
+
bytes += chunk.length;
|
|
141
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
142
|
+
chunk.fill(0);
|
|
143
|
+
throw adapterError();
|
|
144
|
+
}
|
|
145
|
+
chunks.push(chunk);
|
|
146
|
+
}
|
|
147
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
148
|
+
} finally {
|
|
149
|
+
for (const chunk of chunks) {
|
|
150
|
+
chunk.fill(0);
|
|
151
|
+
}
|
|
152
|
+
reader.releaseLock?.();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseAdapterResponse(text) {
|
|
157
|
+
let value;
|
|
158
|
+
try {
|
|
159
|
+
value = JSON.parse(text);
|
|
160
|
+
} catch {
|
|
161
|
+
throw adapterError();
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
!isPlainObject(value) ||
|
|
165
|
+
!isBoundedText(value.conversationId, MAX_CONVERSATION_ID_LENGTH) ||
|
|
166
|
+
!isBoundedText(value.output, MAX_OUTPUT_LENGTH)
|
|
167
|
+
) {
|
|
168
|
+
throw adapterError();
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
conversationId: value.conversationId,
|
|
172
|
+
output: value.output,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isTimeout(error) {
|
|
177
|
+
return error?.name === "AbortError" || error?.name === "TimeoutError";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function createLocalChatTestService({
|
|
181
|
+
fetchImpl = fetch,
|
|
182
|
+
now = () => Date.now(),
|
|
183
|
+
keyTtlMs = KEY_TTL_MS,
|
|
184
|
+
maxSessions = MAX_SESSIONS,
|
|
185
|
+
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
186
|
+
} = {}) {
|
|
187
|
+
const sessions = new Map();
|
|
188
|
+
let pendingKeyIssuances = 0;
|
|
189
|
+
|
|
190
|
+
function expireSession(keyId, session) {
|
|
191
|
+
if (sessions.get(keyId) !== session) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
clearTimeout(session.expiryTimer);
|
|
195
|
+
session.abortController?.abort();
|
|
196
|
+
session.privateKey = undefined;
|
|
197
|
+
sessions.delete(keyId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function discardExpiredSessions() {
|
|
201
|
+
const currentTime = now();
|
|
202
|
+
for (const [keyId, session] of sessions) {
|
|
203
|
+
if (session.expiresAt <= currentTime) {
|
|
204
|
+
expireSession(keyId, session);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resetAllSessions() {
|
|
210
|
+
for (const [keyId, session] of sessions) {
|
|
211
|
+
expireSession(keyId, session);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getLiveSession(keyId) {
|
|
216
|
+
discardExpiredSessions();
|
|
217
|
+
const session = sessions.get(keyId);
|
|
218
|
+
if (!session || session.expiresAt <= now()) {
|
|
219
|
+
if (session) {
|
|
220
|
+
expireSession(keyId, session);
|
|
221
|
+
}
|
|
222
|
+
throw expiredKeyError();
|
|
223
|
+
}
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
async issueKey() {
|
|
229
|
+
discardExpiredSessions();
|
|
230
|
+
if (sessions.size + pendingKeyIssuances >= maxSessions) {
|
|
231
|
+
throw requestError(
|
|
232
|
+
"Too many open tester sessions. Forget one or wait for it to expire.",
|
|
233
|
+
429,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
pendingKeyIssuances += 1;
|
|
237
|
+
try {
|
|
238
|
+
const { publicKey, privateKey } = await generateKeyPair("rsa", {
|
|
239
|
+
modulusLength: 2_048,
|
|
240
|
+
publicExponent: 0x10001,
|
|
241
|
+
});
|
|
242
|
+
const keyId = randomUUID();
|
|
243
|
+
const expiresAt = now() + keyTtlMs;
|
|
244
|
+
const session = {
|
|
245
|
+
abortController: null,
|
|
246
|
+
expiresAt,
|
|
247
|
+
expiryTimer: null,
|
|
248
|
+
inFlight: false,
|
|
249
|
+
privateKey,
|
|
250
|
+
};
|
|
251
|
+
sessions.set(keyId, session);
|
|
252
|
+
session.expiryTimer = setTimeout(
|
|
253
|
+
() => expireSession(keyId, session),
|
|
254
|
+
keyTtlMs,
|
|
255
|
+
);
|
|
256
|
+
session.expiryTimer.unref?.();
|
|
257
|
+
return {
|
|
258
|
+
keyId,
|
|
259
|
+
publicKeyJwk: publicKey.export({ format: "jwk" }),
|
|
260
|
+
algorithm: "RSA-OAEP-256",
|
|
261
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
262
|
+
};
|
|
263
|
+
} finally {
|
|
264
|
+
pendingKeyIssuances -= 1;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async message(untrustedRequest) {
|
|
269
|
+
const request = validateMessageRequest(untrustedRequest);
|
|
270
|
+
const session = getLiveSession(request.keyId);
|
|
271
|
+
if (session.inFlight) {
|
|
272
|
+
throw requestError("Wait for the current test message to finish.", 409);
|
|
273
|
+
}
|
|
274
|
+
session.inFlight = true;
|
|
275
|
+
let encryptedCredential;
|
|
276
|
+
let decryptedCredential;
|
|
277
|
+
let authorization;
|
|
278
|
+
let timeout;
|
|
279
|
+
try {
|
|
280
|
+
encryptedCredential = decodeCiphertext(request.encryptedCredential);
|
|
281
|
+
try {
|
|
282
|
+
decryptedCredential = privateDecrypt(
|
|
283
|
+
{
|
|
284
|
+
key: session.privateKey,
|
|
285
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
286
|
+
oaepHash: "sha256",
|
|
287
|
+
},
|
|
288
|
+
encryptedCredential,
|
|
289
|
+
);
|
|
290
|
+
} catch {
|
|
291
|
+
throw requestError("Secure the client credential again before testing.");
|
|
292
|
+
}
|
|
293
|
+
if (
|
|
294
|
+
decryptedCredential.length === 0 ||
|
|
295
|
+
decryptedCredential.length > 512 ||
|
|
296
|
+
/[\r\n\u0000]/u.test(decryptedCredential.toString("utf8"))
|
|
297
|
+
) {
|
|
298
|
+
throw requestError("Secure the client credential again before testing.");
|
|
299
|
+
}
|
|
300
|
+
authorization = `Bearer ${decryptedCredential.toString("utf8")}`;
|
|
301
|
+
|
|
302
|
+
let response;
|
|
303
|
+
try {
|
|
304
|
+
session.abortController = new AbortController();
|
|
305
|
+
timeout = setTimeout(
|
|
306
|
+
() => session.abortController?.abort(),
|
|
307
|
+
requestTimeoutMs,
|
|
308
|
+
);
|
|
309
|
+
response = await fetchImpl(`${request.endpointBaseUrl}/chat`, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: {
|
|
312
|
+
Accept: "application/json",
|
|
313
|
+
Authorization: authorization,
|
|
314
|
+
"Content-Type": "application/json",
|
|
315
|
+
},
|
|
316
|
+
body: JSON.stringify({
|
|
317
|
+
input: request.input,
|
|
318
|
+
...(request.conversationId !== undefined
|
|
319
|
+
? { conversationId: request.conversationId }
|
|
320
|
+
: {}),
|
|
321
|
+
}),
|
|
322
|
+
redirect: "error",
|
|
323
|
+
signal: session.abortController.signal,
|
|
324
|
+
});
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw adapterError(isTimeout(error) ? 504 : 502);
|
|
327
|
+
}
|
|
328
|
+
if (!response?.ok || response.status < 200 || response.status >= 300) {
|
|
329
|
+
throw adapterError();
|
|
330
|
+
}
|
|
331
|
+
return parseAdapterResponse(await readBoundedResponse(response));
|
|
332
|
+
} finally {
|
|
333
|
+
clearTimeout(timeout);
|
|
334
|
+
authorization = undefined;
|
|
335
|
+
encryptedCredential?.fill(0);
|
|
336
|
+
decryptedCredential?.fill(0);
|
|
337
|
+
session.abortController = null;
|
|
338
|
+
session.inFlight = false;
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
async reset(request) {
|
|
343
|
+
if (!isPlainObject(request) || !isBoundedText(request.keyId, 128)) {
|
|
344
|
+
throw requestError("Choose a valid tester session to forget.");
|
|
345
|
+
}
|
|
346
|
+
const session = sessions.get(request.keyId);
|
|
347
|
+
if (session) {
|
|
348
|
+
expireSession(request.keyId, session);
|
|
349
|
+
}
|
|
350
|
+
return { forgotten: true };
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
resetAll() {
|
|
354
|
+
resetAllSessions();
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
dispose() {
|
|
358
|
+
resetAllSessions();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
@@ -6,6 +6,10 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
createCodexComposeFile,
|
|
9
|
+
createCodexChatComposeFile,
|
|
10
|
+
createCodexChatConfig,
|
|
11
|
+
createCodexChatDockerfile,
|
|
12
|
+
createCodexChatRequirements,
|
|
9
13
|
createCodexConfig,
|
|
10
14
|
createCodexDockerfile,
|
|
11
15
|
createCodexRequirements,
|
|
@@ -39,6 +43,11 @@ const PROJECTS = Object.freeze({
|
|
|
39
43
|
serviceName: "codex",
|
|
40
44
|
containerPort: 4_500,
|
|
41
45
|
}),
|
|
46
|
+
"codex-chat": Object.freeze({
|
|
47
|
+
projectPrefix: "relmio-codex-chat",
|
|
48
|
+
serviceName: "codex-chat",
|
|
49
|
+
containerPort: 14_501,
|
|
50
|
+
}),
|
|
42
51
|
});
|
|
43
52
|
const DOCKER_SELECTION_VARIABLES = Object.freeze([
|
|
44
53
|
"DOCKER_HOST",
|
|
@@ -613,10 +622,9 @@ function replaceClientCredentialVerifier({ target, composeFile, tokenSha256 }) {
|
|
|
613
622
|
throw new Error("The managed local endpoint configuration is invalid.");
|
|
614
623
|
}
|
|
615
624
|
|
|
616
|
-
const pattern =
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
: /^([ \t]*-[ \t]+--ws-token-sha256[ \t]*\n[ \t]*-[ \t]+)[a-f0-9]{64}([ \t]*)$/gmu;
|
|
625
|
+
const pattern = target === "openai-api" || target === "codex-chat"
|
|
626
|
+
? /^([ \t]*RELMIO_GATEWAY_TOKEN_SHA256:[ \t]*)[a-f0-9]{64}([ \t]*)$/gmu
|
|
627
|
+
: /^([ \t]*-[ \t]+--ws-token-sha256[ \t]*\n[ \t]*-[ \t]+)[a-f0-9]{64}([ \t]*)$/gmu;
|
|
620
628
|
const matches = [...composeFile.matchAll(pattern)];
|
|
621
629
|
if (matches.length !== 1) {
|
|
622
630
|
throw new Error("The managed local endpoint configuration is invalid.");
|
|
@@ -745,30 +753,34 @@ export async function getLocalDockerStatus({
|
|
|
745
753
|
}
|
|
746
754
|
|
|
747
755
|
export async function restartLocalCodex(
|
|
748
|
-
{ installDirectory },
|
|
756
|
+
{ installDirectory, target = "codex-chatgpt" },
|
|
749
757
|
dependencies = {},
|
|
750
758
|
) {
|
|
759
|
+
const safeTarget = validateLocalTarget(target);
|
|
760
|
+
if (safeTarget !== "codex-chatgpt" && safeTarget !== "codex-chat") {
|
|
761
|
+
throw new TypeError("The Codex login target is invalid.");
|
|
762
|
+
}
|
|
751
763
|
const runProcess = dependencies.runProcess ?? runLocalProcess;
|
|
752
764
|
const releaseLock = dependencies.changeLockHeld === true
|
|
753
765
|
? async () => {}
|
|
754
766
|
: await acquireLocalProjectLock(
|
|
755
|
-
{ installRoot: installDirectory, target:
|
|
767
|
+
{ installRoot: installDirectory, target: safeTarget },
|
|
756
768
|
dependencies,
|
|
757
769
|
);
|
|
758
770
|
try {
|
|
759
771
|
const attested = await attestLocalCodexInstallation(
|
|
760
|
-
{ installDirectory },
|
|
772
|
+
{ installDirectory, target: safeTarget },
|
|
761
773
|
dependencies,
|
|
762
774
|
);
|
|
763
775
|
|
|
764
776
|
await runOrThrow(runProcess, {
|
|
765
777
|
label: "Codex credential reload",
|
|
766
778
|
file: "docker",
|
|
767
|
-
args: createComposeArgs(
|
|
779
|
+
args: createComposeArgs(safeTarget, attested.projectName, [
|
|
768
780
|
"restart",
|
|
769
781
|
"--timeout",
|
|
770
782
|
"10",
|
|
771
|
-
|
|
783
|
+
PROJECTS[safeTarget].serviceName,
|
|
772
784
|
]),
|
|
773
785
|
cwd: installDirectory,
|
|
774
786
|
dockerHost: attested.dockerHost,
|
|
@@ -776,14 +788,14 @@ export async function restartLocalCodex(
|
|
|
776
788
|
await runOrThrow(runProcess, {
|
|
777
789
|
label: "Codex readiness wait",
|
|
778
790
|
file: "docker",
|
|
779
|
-
args: createComposeArgs(
|
|
791
|
+
args: createComposeArgs(safeTarget, attested.projectName, [
|
|
780
792
|
"up",
|
|
781
793
|
"-d",
|
|
782
794
|
"--wait",
|
|
783
795
|
"--wait-timeout",
|
|
784
796
|
"90",
|
|
785
797
|
"--no-deps",
|
|
786
|
-
|
|
798
|
+
PROJECTS[safeTarget].serviceName,
|
|
787
799
|
]),
|
|
788
800
|
cwd: installDirectory,
|
|
789
801
|
dockerHost: attested.dockerHost,
|
|
@@ -1111,7 +1123,7 @@ function parseModelIds(value) {
|
|
|
1111
1123
|
}
|
|
1112
1124
|
|
|
1113
1125
|
async function verifyHttpEndpoint({ plan, clientCredential, fetchImpl }) {
|
|
1114
|
-
const healthPath = plan.target === "
|
|
1126
|
+
const healthPath = plan.target === "codex-chatgpt" ? "/readyz" : "/health";
|
|
1115
1127
|
const httpEndpoint = `http://127.0.0.1:${plan.port}${healthPath}`;
|
|
1116
1128
|
let health;
|
|
1117
1129
|
try {
|
|
@@ -1126,6 +1138,29 @@ async function verifyHttpEndpoint({ plan, clientCredential, fetchImpl }) {
|
|
|
1126
1138
|
throw new Error("The local endpoint did not pass its readiness check.");
|
|
1127
1139
|
}
|
|
1128
1140
|
|
|
1141
|
+
if (plan.target === "codex-chat" && clientCredential !== undefined) {
|
|
1142
|
+
let verification;
|
|
1143
|
+
try {
|
|
1144
|
+
verification = await fetchImpl(
|
|
1145
|
+
`http://127.0.0.1:${plan.port}/auth/verify`,
|
|
1146
|
+
{
|
|
1147
|
+
method: "GET",
|
|
1148
|
+
headers: { Authorization: `Bearer ${clientCredential}` },
|
|
1149
|
+
signal: AbortSignal.timeout(10_000),
|
|
1150
|
+
},
|
|
1151
|
+
);
|
|
1152
|
+
} catch {
|
|
1153
|
+
throw new Error(
|
|
1154
|
+
"The Codex Chat client credential could not be verified.",
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
if (!verification.ok) {
|
|
1158
|
+
throw new Error(
|
|
1159
|
+
"The Codex Chat client credential could not be verified.",
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1129
1164
|
if (plan.target !== "openai-api" || clientCredential === undefined) {
|
|
1130
1165
|
return [];
|
|
1131
1166
|
}
|
|
@@ -1275,6 +1310,13 @@ async function defaultReadGatewaySource() {
|
|
|
1275
1310
|
);
|
|
1276
1311
|
}
|
|
1277
1312
|
|
|
1313
|
+
async function defaultReadCodexChatSource() {
|
|
1314
|
+
return defaultFileSystem.readFile(
|
|
1315
|
+
new URL("../gateway/codex-chat.js", import.meta.url),
|
|
1316
|
+
"utf8",
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1278
1320
|
async function attestManagedLocalEndpoint(
|
|
1279
1321
|
{ target, installDirectory, missingMessage, notRunningMessage },
|
|
1280
1322
|
{
|
|
@@ -1334,12 +1376,16 @@ async function attestManagedLocalEndpoint(
|
|
|
1334
1376
|
}
|
|
1335
1377
|
|
|
1336
1378
|
export async function attestLocalCodexInstallation(
|
|
1337
|
-
{ installDirectory },
|
|
1379
|
+
{ installDirectory, target = "codex-chatgpt" },
|
|
1338
1380
|
dependencies = {},
|
|
1339
1381
|
) {
|
|
1382
|
+
const safeTarget = validateLocalTarget(target);
|
|
1383
|
+
if (safeTarget !== "codex-chatgpt" && safeTarget !== "codex-chat") {
|
|
1384
|
+
throw new TypeError("The Codex login target is invalid.");
|
|
1385
|
+
}
|
|
1340
1386
|
const attested = await attestManagedLocalEndpoint(
|
|
1341
1387
|
{
|
|
1342
|
-
target:
|
|
1388
|
+
target: safeTarget,
|
|
1343
1389
|
installDirectory,
|
|
1344
1390
|
missingMessage: "Install the local Codex endpoint before signing in.",
|
|
1345
1391
|
notRunningMessage: "The managed local Codex endpoint is not running.",
|
|
@@ -1611,6 +1657,7 @@ export async function installLocalEndpoint(
|
|
|
1611
1657
|
randomBytes = createRandomBytes,
|
|
1612
1658
|
isPortAvailable = isLoopbackPortAvailable,
|
|
1613
1659
|
readGatewaySource = defaultReadGatewaySource,
|
|
1660
|
+
readCodexChatSource = defaultReadCodexChatSource,
|
|
1614
1661
|
fetchImpl = fetch,
|
|
1615
1662
|
verifyCodexCapability = verifyCodexWebSocketCapability,
|
|
1616
1663
|
platform = process.platform,
|
|
@@ -1727,6 +1774,39 @@ export async function installLocalEndpoint(
|
|
|
1727
1774
|
gatewaySource,
|
|
1728
1775
|
0o600,
|
|
1729
1776
|
);
|
|
1777
|
+
} else if (normalizedPlan.target === "codex-chat") {
|
|
1778
|
+
const gatewaySource = await readCodexChatSource();
|
|
1779
|
+
if (
|
|
1780
|
+
typeof gatewaySource !== "string" ||
|
|
1781
|
+
gatewaySource.length === 0 ||
|
|
1782
|
+
gatewaySource.length > 512 * 1024
|
|
1783
|
+
) {
|
|
1784
|
+
throw new Error("The packaged Codex Chat runtime is invalid.");
|
|
1785
|
+
}
|
|
1786
|
+
dockerfile = createCodexChatDockerfile();
|
|
1787
|
+
composeFile = createCodexChatComposeFile({
|
|
1788
|
+
port: normalizedPlan.port,
|
|
1789
|
+
tokenSha256,
|
|
1790
|
+
installId,
|
|
1791
|
+
});
|
|
1792
|
+
await writeManagedFile(
|
|
1793
|
+
fileSystem,
|
|
1794
|
+
join(installRoot, "gateway.mjs"),
|
|
1795
|
+
gatewaySource,
|
|
1796
|
+
0o600,
|
|
1797
|
+
);
|
|
1798
|
+
await writeManagedFile(
|
|
1799
|
+
fileSystem,
|
|
1800
|
+
join(installRoot, "config.toml"),
|
|
1801
|
+
createCodexChatConfig(),
|
|
1802
|
+
0o600,
|
|
1803
|
+
);
|
|
1804
|
+
await writeManagedFile(
|
|
1805
|
+
fileSystem,
|
|
1806
|
+
join(installRoot, "requirements.toml"),
|
|
1807
|
+
createCodexChatRequirements(),
|
|
1808
|
+
0o600,
|
|
1809
|
+
);
|
|
1730
1810
|
} else {
|
|
1731
1811
|
dockerfile = createCodexDockerfile();
|
|
1732
1812
|
composeFile = createCodexComposeFile({
|