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.
@@ -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
- return { dockerHost: validatedDockerHost, projectName };
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
- target === "openai-api"
618
- ? /^([ \t]*RELMIO_GATEWAY_TOKEN_SHA256:[ \t]*)[a-f0-9]{64}([ \t]*)$/gmu
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: "codex-chatgpt" },
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("codex-chatgpt", attested.projectName, [
779
+ args: createComposeArgs(safeTarget, attested.projectName, [
768
780
  "restart",
769
781
  "--timeout",
770
782
  "10",
771
- "codex",
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("codex-chatgpt", attested.projectName, [
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
- "codex",
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 === "openai-api" ? "/health" : "/readyz";
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: "codex-chatgpt",
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({