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.
@@ -1,11 +1,15 @@
1
1
  import { createHash, randomBytes as createRandomBytes, randomUUID } from "node:crypto";
2
2
  import * as defaultFileSystem from "node:fs/promises";
3
- import { createServer } from "node:net";
3
+ import { createConnection, createServer } from "node:net";
4
4
  import { homedir } from "node:os";
5
5
  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,
@@ -27,6 +31,7 @@ const ROOT_MARKER = ".managed-by-relmio-root.json";
27
31
  const MARKER_SCHEMA_VERSION = 2;
28
32
  const ROOT_MARKER_SCHEMA_VERSION = 1;
29
33
  const COMPOSE_FILENAME = "docker-compose.yml";
34
+ const INCOMPLETE_LOCK_STALE_MS = 30_000;
30
35
  const PROJECTS = Object.freeze({
31
36
  "openai-api": Object.freeze({
32
37
  projectPrefix: "relmio-openai-api",
@@ -38,6 +43,11 @@ const PROJECTS = Object.freeze({
38
43
  serviceName: "codex",
39
44
  containerPort: 4_500,
40
45
  }),
46
+ "codex-chat": Object.freeze({
47
+ projectPrefix: "relmio-codex-chat",
48
+ serviceName: "codex-chat",
49
+ containerPort: 14_501,
50
+ }),
41
51
  });
42
52
  const DOCKER_SELECTION_VARIABLES = Object.freeze([
43
53
  "DOCKER_HOST",
@@ -291,10 +301,12 @@ async function writeManagedFile(fileSystem, path, contents, mode) {
291
301
  }
292
302
 
293
303
  const temporaryPath = `${path}.tmp-${randomUUID()}`;
304
+ let committed = false;
294
305
  try {
295
306
  await fileSystem.writeFile(temporaryPath, contents, { flag: "wx", mode });
296
307
  await fileSystem.chmod(temporaryPath, mode);
297
308
  await fileSystem.rename(temporaryPath, path);
309
+ committed = true;
298
310
  await fileSystem.chmod(path, mode);
299
311
  } catch {
300
312
  try {
@@ -302,10 +314,324 @@ async function writeManagedFile(fileSystem, path, contents, mode) {
302
314
  } catch {
303
315
  // The temporary file may not have been created.
304
316
  }
305
- throw new Error("Relmio could not write its local managed files.");
317
+ const error = new Error("Relmio could not write its local managed files.");
318
+ error.committed = committed;
319
+ throw error;
320
+ }
321
+ }
322
+
323
+ function createClientCredential(randomBytes) {
324
+ const capabilityBytes = randomBytes(32);
325
+ if (!Buffer.isBuffer(capabilityBytes) || capabilityBytes.length !== 32) {
326
+ throw new Error("Relmio could not generate a strong local capability.");
327
+ }
328
+ const clientCredential = capabilityBytes.toString("base64url");
329
+ const tokenSha256 = createHash("sha256")
330
+ .update(clientCredential)
331
+ .digest("hex");
332
+ return { clientCredential, tokenSha256 };
333
+ }
334
+
335
+ function validateClientCredentialVerifier(value) {
336
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) {
337
+ throw new TypeError("The staged local client credential is invalid.");
338
+ }
339
+ return value;
340
+ }
341
+
342
+ function defaultIsProcessAlive(processId) {
343
+ try {
344
+ process.kill(processId, 0);
345
+ return true;
346
+ } catch (error) {
347
+ return error?.code !== "ESRCH";
348
+ }
349
+ }
350
+
351
+ function parseLockOwner(value) {
352
+ try {
353
+ const owner = JSON.parse(value);
354
+ if (
355
+ Number.isSafeInteger(owner?.processId) &&
356
+ owner.processId > 0 &&
357
+ typeof owner?.ownerToken === "string" &&
358
+ owner.ownerToken.length > 0 &&
359
+ owner.ownerToken.length <= 128
360
+ ) {
361
+ return owner;
362
+ }
363
+ } catch {
364
+ // An incomplete owner record is recoverable only after its metadata is stale.
365
+ }
366
+ return null;
367
+ }
368
+
369
+ async function readLockOwnerState(fileSystem, ownerPath, directoryPath) {
370
+ let serialized;
371
+ try {
372
+ serialized = await fileSystem.readFile(ownerPath, "utf8");
373
+ } catch (error) {
374
+ if (!isMissing(error)) {
375
+ throw error;
376
+ }
377
+ }
378
+ const metadata = await fileSystem.lstat(
379
+ serialized === undefined ? directoryPath : ownerPath,
380
+ );
381
+ return {
382
+ owner: serialized === undefined ? null : parseLockOwner(serialized),
383
+ serialized,
384
+ modifiedAtMs: metadata.mtimeMs,
385
+ };
386
+ }
387
+
388
+ function isIncompleteLockStale(state) {
389
+ return (
390
+ state.owner === null &&
391
+ Number.isFinite(state.modifiedAtMs) &&
392
+ Date.now() - state.modifiedAtMs >= INCOMPLETE_LOCK_STALE_MS
393
+ );
394
+ }
395
+
396
+ function lockOwnerStateMatches(left, right) {
397
+ if (left.owner && right.owner) {
398
+ return (
399
+ left.owner.processId === right.owner.processId &&
400
+ left.owner.ownerToken === right.owner.ownerToken
401
+ );
402
+ }
403
+ return left.owner === null && right.owner === null && left.serialized === right.serialized;
404
+ }
405
+
406
+ async function assertPublishedLockOwner(
407
+ fileSystem,
408
+ ownerPath,
409
+ directoryPath,
410
+ { processId, ownerToken },
411
+ ) {
412
+ const state = await readLockOwnerState(fileSystem, ownerPath, directoryPath);
413
+ if (
414
+ state.owner?.processId !== processId ||
415
+ state.owner?.ownerToken !== ownerToken
416
+ ) {
417
+ throw new Error("The local lock owner changed during publication.");
418
+ }
419
+ }
420
+
421
+ async function removeDetachedStaleLock(fileSystem, path) {
422
+ try {
423
+ await fileSystem.rm(path, { recursive: true, force: true });
424
+ } catch {
425
+ // A uniquely renamed stale artifact is outside the canonical lock path and
426
+ // must not strand the newly published owner when best-effort cleanup fails.
306
427
  }
307
428
  }
308
429
 
430
+ async function acquireLocalProjectLock(
431
+ { installRoot, target },
432
+ {
433
+ fileSystem = defaultFileSystem,
434
+ processId = process.pid,
435
+ isProcessAlive = defaultIsProcessAlive,
436
+ } = {},
437
+ ) {
438
+ const safeTarget = validateLocalTarget(target);
439
+ const safeInstallRoot = validateInstallDirectory(installRoot, safeTarget);
440
+ const lockPath = join(
441
+ dirname(resolve(safeInstallRoot, "..", "..")),
442
+ `.relmio-local-${safeTarget}.lock`,
443
+ );
444
+ const ownerPath = join(lockPath, "owner.json");
445
+ const ownerToken = randomUUID();
446
+
447
+ async function createLock() {
448
+ await fileSystem.mkdir(lockPath, { mode: 0o700 });
449
+ try {
450
+ await fileSystem.writeFile(
451
+ ownerPath,
452
+ `${JSON.stringify({ processId, ownerToken })}\n`,
453
+ { flag: "wx", mode: 0o600 },
454
+ );
455
+ await assertPublishedLockOwner(fileSystem, ownerPath, lockPath, {
456
+ processId,
457
+ ownerToken,
458
+ });
459
+ } catch {
460
+ // Leave an incomplete directory for stale recovery. Removing the shared
461
+ // path here could delete a successor lock after this creator was paused.
462
+ throw new Error("Relmio could not create its local project lock.");
463
+ }
464
+ }
465
+
466
+ try {
467
+ await createLock();
468
+ } catch (error) {
469
+ if (error?.code !== "EEXIST") {
470
+ throw error;
471
+ }
472
+
473
+ let ownerState;
474
+ try {
475
+ ownerState = await readLockOwnerState(fileSystem, ownerPath, lockPath);
476
+ } catch {
477
+ throw new Error("Another Relmio process is changing this local endpoint.");
478
+ }
479
+ const owner = ownerState.owner;
480
+ if (
481
+ (owner === null && !isIncompleteLockStale(ownerState)) ||
482
+ (owner !== null &&
483
+ (owner.ownerToken === ownerToken ||
484
+ owner.processId === processId ||
485
+ isProcessAlive(owner.processId)))
486
+ ) {
487
+ throw new Error("Another Relmio process is changing this local endpoint.");
488
+ }
489
+
490
+ const reclaimPath = join(lockPath, ".reclaim");
491
+ const reclaimOwnerPath = join(reclaimPath, "owner.json");
492
+ let reclaimClaimed = false;
493
+ async function createReclaimClaim() {
494
+ await fileSystem.mkdir(reclaimPath, { mode: 0o700 });
495
+ try {
496
+ await fileSystem.writeFile(
497
+ reclaimOwnerPath,
498
+ `${JSON.stringify({ processId, ownerToken })}\n`,
499
+ { flag: "wx", mode: 0o600 },
500
+ );
501
+ await assertPublishedLockOwner(
502
+ fileSystem,
503
+ reclaimOwnerPath,
504
+ reclaimPath,
505
+ { processId, ownerToken },
506
+ );
507
+ } catch {
508
+ // The same identity rule applies to reclaim publication: never remove
509
+ // a shared path after an owner write that may have lost a race.
510
+ throw new Error("Relmio could not create its reclaim lock.");
511
+ }
512
+ }
513
+ try {
514
+ try {
515
+ await createReclaimClaim();
516
+ } catch (error) {
517
+ if (error?.code !== "EEXIST") {
518
+ throw error;
519
+ }
520
+ const reclaimState = await readLockOwnerState(
521
+ fileSystem,
522
+ reclaimOwnerPath,
523
+ reclaimPath,
524
+ );
525
+ const reclaimOwner = reclaimState.owner;
526
+ if (
527
+ (reclaimOwner === null && !isIncompleteLockStale(reclaimState)) ||
528
+ (reclaimOwner !== null &&
529
+ (reclaimOwner.processId === processId ||
530
+ isProcessAlive(reclaimOwner.processId)))
531
+ ) {
532
+ throw new Error("Another process owns the reclaim lock.");
533
+ }
534
+ const staleReclaimPath = `${reclaimPath}.stale-${randomUUID()}`;
535
+ await fileSystem.rename(reclaimPath, staleReclaimPath);
536
+ await createReclaimClaim();
537
+ await removeDetachedStaleLock(fileSystem, staleReclaimPath);
538
+ }
539
+ reclaimClaimed = true;
540
+ const reclaimOwner = JSON.parse(
541
+ await fileSystem.readFile(reclaimOwnerPath, "utf8"),
542
+ );
543
+ if (reclaimOwner?.ownerToken !== ownerToken) {
544
+ throw new Error("The reclaim lock owner changed.");
545
+ }
546
+ const currentOwnerState = await readLockOwnerState(
547
+ fileSystem,
548
+ ownerPath,
549
+ lockPath,
550
+ );
551
+ if (!lockOwnerStateMatches(currentOwnerState, ownerState)) {
552
+ throw new Error("The local project lock owner changed.");
553
+ }
554
+ const stalePath = `${lockPath}.stale-${randomUUID()}`;
555
+ await fileSystem.rename(lockPath, stalePath);
556
+ reclaimClaimed = false;
557
+ await createLock();
558
+ await removeDetachedStaleLock(fileSystem, stalePath);
559
+ } catch {
560
+ if (reclaimClaimed) {
561
+ try {
562
+ const reclaimState = await readLockOwnerState(
563
+ fileSystem,
564
+ reclaimOwnerPath,
565
+ reclaimPath,
566
+ );
567
+ if (
568
+ reclaimState.owner?.processId === processId &&
569
+ reclaimState.owner?.ownerToken === ownerToken
570
+ ) {
571
+ await fileSystem.rm(reclaimPath, { recursive: true, force: true });
572
+ }
573
+ } catch {
574
+ // A changed or contended lock remains owned by the other process.
575
+ }
576
+ }
577
+ throw new Error("Another Relmio process is changing this local endpoint.");
578
+ }
579
+ }
580
+
581
+ return async () => {
582
+ try {
583
+ const owner = JSON.parse(await fileSystem.readFile(ownerPath, "utf8"));
584
+ if (owner?.ownerToken === ownerToken) {
585
+ await fileSystem.rm(lockPath, { recursive: true, force: true });
586
+ }
587
+ } catch {
588
+ // Never hide a completed endpoint operation because lock cleanup failed.
589
+ }
590
+ };
591
+ }
592
+
593
+ export async function acquireLocalEndpointChangeLock(
594
+ { target },
595
+ {
596
+ fileSystem = defaultFileSystem,
597
+ env = process.env,
598
+ homeDirectory = homedir(),
599
+ platform = process.platform,
600
+ processId = process.pid,
601
+ isProcessAlive = defaultIsProcessAlive,
602
+ } = {},
603
+ ) {
604
+ assertSupportedPlatform(platform);
605
+ rejectDockerEnvironmentOverrides(env);
606
+ const safeTarget = validateLocalTarget(target);
607
+ const installRoot = await resolveLocalInstallRoot({
608
+ target: safeTarget,
609
+ env,
610
+ homeDirectory,
611
+ fileSystem,
612
+ platform,
613
+ });
614
+ return acquireLocalProjectLock(
615
+ { installRoot, target: safeTarget },
616
+ { fileSystem, processId, isProcessAlive },
617
+ );
618
+ }
619
+
620
+ function replaceClientCredentialVerifier({ target, composeFile, tokenSha256 }) {
621
+ if (typeof composeFile !== "string" || composeFile.length > 512 * 1024) {
622
+ throw new Error("The managed local endpoint configuration is invalid.");
623
+ }
624
+
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;
628
+ const matches = [...composeFile.matchAll(pattern)];
629
+ if (matches.length !== 1) {
630
+ throw new Error("The managed local endpoint configuration is invalid.");
631
+ }
632
+ return composeFile.replace(pattern, `$1${tokenSha256}$2`);
633
+ }
634
+
309
635
  export function isLoopbackPortAvailable(port) {
310
636
  return new Promise((resolvePromise, rejectPromise) => {
311
637
  const server = createServer();
@@ -427,23 +753,34 @@ export async function getLocalDockerStatus({
427
753
  }
428
754
 
429
755
  export async function restartLocalCodex(
430
- { installDirectory },
756
+ { installDirectory, target = "codex-chatgpt" },
431
757
  dependencies = {},
432
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
+ }
433
763
  const runProcess = dependencies.runProcess ?? runLocalProcess;
764
+ const releaseLock = dependencies.changeLockHeld === true
765
+ ? async () => {}
766
+ : await acquireLocalProjectLock(
767
+ { installRoot: installDirectory, target: safeTarget },
768
+ dependencies,
769
+ );
770
+ try {
434
771
  const attested = await attestLocalCodexInstallation(
435
- { installDirectory },
772
+ { installDirectory, target: safeTarget },
436
773
  dependencies,
437
774
  );
438
775
 
439
776
  await runOrThrow(runProcess, {
440
777
  label: "Codex credential reload",
441
778
  file: "docker",
442
- args: createComposeArgs("codex-chatgpt", attested.projectName, [
779
+ args: createComposeArgs(safeTarget, attested.projectName, [
443
780
  "restart",
444
781
  "--timeout",
445
782
  "10",
446
- "codex",
783
+ PROJECTS[safeTarget].serviceName,
447
784
  ]),
448
785
  cwd: installDirectory,
449
786
  dockerHost: attested.dockerHost,
@@ -451,19 +788,47 @@ export async function restartLocalCodex(
451
788
  await runOrThrow(runProcess, {
452
789
  label: "Codex readiness wait",
453
790
  file: "docker",
454
- args: createComposeArgs("codex-chatgpt", attested.projectName, [
791
+ args: createComposeArgs(safeTarget, attested.projectName, [
455
792
  "up",
456
793
  "-d",
457
794
  "--wait",
458
795
  "--wait-timeout",
459
796
  "90",
460
797
  "--no-deps",
461
- "codex",
798
+ PROJECTS[safeTarget].serviceName,
462
799
  ]),
463
800
  cwd: installDirectory,
464
801
  dockerHost: attested.dockerHost,
465
802
  });
466
803
  return { restarted: true };
804
+ } finally {
805
+ await releaseLock();
806
+ }
807
+ }
808
+
809
+ function createServiceRecreateSpec({
810
+ target,
811
+ installRoot,
812
+ dockerHost,
813
+ projectName,
814
+ }) {
815
+ const project = PROJECTS[target];
816
+ return {
817
+ label: "Local client credential rotation",
818
+ file: "docker",
819
+ args: createComposeArgs(target, projectName, [
820
+ "up",
821
+ "-d",
822
+ "--wait",
823
+ "--wait-timeout",
824
+ "90",
825
+ "--force-recreate",
826
+ "--no-deps",
827
+ project.serviceName,
828
+ ]),
829
+ cwd: installRoot,
830
+ dockerHost,
831
+ };
467
832
  }
468
833
 
469
834
  function createProjectName(target, installId) {
@@ -758,7 +1123,7 @@ function parseModelIds(value) {
758
1123
  }
759
1124
 
760
1125
  async function verifyHttpEndpoint({ plan, clientCredential, fetchImpl }) {
761
- const healthPath = plan.target === "openai-api" ? "/health" : "/readyz";
1126
+ const healthPath = plan.target === "codex-chatgpt" ? "/readyz" : "/health";
762
1127
  const httpEndpoint = `http://127.0.0.1:${plan.port}${healthPath}`;
763
1128
  let health;
764
1129
  try {
@@ -773,7 +1138,30 @@ async function verifyHttpEndpoint({ plan, clientCredential, fetchImpl }) {
773
1138
  throw new Error("The local endpoint did not pass its readiness check.");
774
1139
  }
775
1140
 
776
- if (plan.target !== "openai-api") {
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
+
1164
+ if (plan.target !== "openai-api" || clientCredential === undefined) {
777
1165
  return [];
778
1166
  }
779
1167
 
@@ -800,6 +1188,121 @@ async function verifyHttpEndpoint({ plan, clientCredential, fetchImpl }) {
800
1188
  }
801
1189
  }
802
1190
 
1191
+ export function verifyCodexWebSocketCapability(
1192
+ { port, clientCredential },
1193
+ {
1194
+ connectSocket = createConnection,
1195
+ randomBytes = createRandomBytes,
1196
+ timeoutMs = 10_000,
1197
+ } = {},
1198
+ ) {
1199
+ const plan = createLocalDeploymentPlan({ target: "codex-chatgpt", port });
1200
+ if (
1201
+ typeof clientCredential !== "string" ||
1202
+ !/^[A-Za-z0-9_-]{43}$/u.test(clientCredential)
1203
+ ) {
1204
+ throw new TypeError("The staged local client credential is invalid.");
1205
+ }
1206
+ const keyBytes = randomBytes(16);
1207
+ if (!Buffer.isBuffer(keyBytes) || keyBytes.length !== 16) {
1208
+ throw new Error("Relmio could not verify the Codex client capability.");
1209
+ }
1210
+ const websocketKey = keyBytes.toString("base64");
1211
+ const expectedAccept = createHash("sha1")
1212
+ .update(`${websocketKey}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
1213
+ .digest("base64");
1214
+
1215
+ return new Promise((resolvePromise, rejectPromise) => {
1216
+ let settled = false;
1217
+ let response = "";
1218
+ const socket = connectSocket(
1219
+ { host: "127.0.0.1", port: plan.port },
1220
+ () => {
1221
+ socket.write(
1222
+ [
1223
+ "GET / HTTP/1.1",
1224
+ `Host: 127.0.0.1:${plan.port}`,
1225
+ "Upgrade: websocket",
1226
+ "Connection: Upgrade",
1227
+ `Sec-WebSocket-Key: ${websocketKey}`,
1228
+ "Sec-WebSocket-Version: 13",
1229
+ `Authorization: Bearer ${clientCredential}`,
1230
+ "",
1231
+ "",
1232
+ ].join("\r\n"),
1233
+ );
1234
+ },
1235
+ );
1236
+
1237
+ const finish = (error) => {
1238
+ if (settled) {
1239
+ return;
1240
+ }
1241
+ settled = true;
1242
+ socket.destroy();
1243
+ if (error) {
1244
+ rejectPromise(
1245
+ new Error("The Codex client credential could not be verified."),
1246
+ );
1247
+ } else {
1248
+ resolvePromise();
1249
+ }
1250
+ };
1251
+
1252
+ socket.setTimeout(timeoutMs, () => finish(new Error("timeout")));
1253
+ socket.on("error", finish);
1254
+ socket.on("close", () => finish(new Error("closed")));
1255
+ socket.on("data", (chunk) => {
1256
+ response += chunk.toString("latin1");
1257
+ if (response.length > 16 * 1024) {
1258
+ finish(new Error("oversized"));
1259
+ return;
1260
+ }
1261
+ const headerEnd = response.indexOf("\r\n\r\n");
1262
+ if (headerEnd === -1) {
1263
+ return;
1264
+ }
1265
+ const lines = response.slice(0, headerEnd).split("\r\n");
1266
+ if (!/^HTTP\/1\.1 101(?: |$)/u.test(lines.shift() ?? "")) {
1267
+ finish(new Error("unauthorized"));
1268
+ return;
1269
+ }
1270
+ const headers = new Map();
1271
+ for (const line of lines) {
1272
+ const separator = line.indexOf(":");
1273
+ if (separator <= 0) {
1274
+ finish(new Error("malformed"));
1275
+ return;
1276
+ }
1277
+ const rawName = line.slice(0, separator);
1278
+ if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(rawName)) {
1279
+ finish(new Error("malformed"));
1280
+ return;
1281
+ }
1282
+ const name = rawName.toLowerCase();
1283
+ const value = line.slice(separator + 1).trim();
1284
+ if (headers.has(name)) {
1285
+ finish(new Error("duplicate"));
1286
+ return;
1287
+ }
1288
+ headers.set(name, value);
1289
+ }
1290
+ if (
1291
+ headers.get("upgrade")?.toLowerCase() !== "websocket" ||
1292
+ !headers
1293
+ .get("connection")
1294
+ ?.split(",")
1295
+ .some((value) => value.trim().toLowerCase() === "upgrade") ||
1296
+ headers.get("sec-websocket-accept") !== expectedAccept
1297
+ ) {
1298
+ finish(new Error("invalid upgrade"));
1299
+ return;
1300
+ }
1301
+ finish();
1302
+ });
1303
+ });
1304
+ }
1305
+
803
1306
  async function defaultReadGatewaySource() {
804
1307
  return defaultFileSystem.readFile(
805
1308
  new URL("../gateway/openai.js", import.meta.url),
@@ -807,8 +1310,15 @@ async function defaultReadGatewaySource() {
807
1310
  );
808
1311
  }
809
1312
 
810
- export async function attestLocalCodexInstallation(
811
- { installDirectory },
1313
+ async function defaultReadCodexChatSource() {
1314
+ return defaultFileSystem.readFile(
1315
+ new URL("../gateway/codex-chat.js", import.meta.url),
1316
+ "utf8",
1317
+ );
1318
+ }
1319
+
1320
+ async function attestManagedLocalEndpoint(
1321
+ { target, installDirectory, missingMessage, notRunningMessage },
812
1322
  {
813
1323
  fileSystem = defaultFileSystem,
814
1324
  runProcess = runLocalProcess,
@@ -816,22 +1326,23 @@ export async function attestLocalCodexInstallation(
816
1326
  } = {},
817
1327
  ) {
818
1328
  assertSupportedPlatform(platform);
1329
+ const safeTarget = validateLocalTarget(target);
819
1330
  const safeDirectory = validateInstallDirectory(
820
1331
  installDirectory,
821
- "codex-chatgpt",
1332
+ safeTarget,
822
1333
  );
823
1334
  const relmioHome = resolve(safeDirectory, "..", "..");
824
1335
  const managed = await inspectManagedRoot({
825
1336
  fileSystem,
826
1337
  relmioHome,
827
1338
  installRoot: safeDirectory,
828
- target: "codex-chatgpt",
1339
+ target: safeTarget,
829
1340
  });
830
1341
  if (managed.deploymentMode !== "updated" || !managed.marker) {
831
- throw new Error("Install the local Codex endpoint before signing in.");
1342
+ throw new Error(missingMessage);
832
1343
  }
833
1344
  await attestDockerOwnership({
834
- target: "codex-chatgpt",
1345
+ target: safeTarget,
835
1346
  installRoot: safeDirectory,
836
1347
  dockerHost: managed.marker.dockerHost,
837
1348
  installId: managed.marker.installId,
@@ -839,26 +1350,303 @@ export async function attestLocalCodexInstallation(
839
1350
  runProcess,
840
1351
  });
841
1352
  const verification = createVerificationSpecs({
842
- target: "codex-chatgpt",
1353
+ target: safeTarget,
843
1354
  installRoot: safeDirectory,
844
1355
  dockerHost: managed.marker.dockerHost,
845
1356
  projectName: managed.marker.projectName,
846
1357
  });
847
1358
  const running = await runOrThrow(runProcess, verification.running);
848
- if (!running.stdout.split(/\s+/u).includes("codex")) {
849
- throw new Error("The managed local Codex endpoint is not running.");
1359
+ if (!running.stdout.split(/\s+/u).includes(PROJECTS[safeTarget].serviceName)) {
1360
+ throw new Error(
1361
+ notRunningMessage ?? "The managed local endpoint is not running.",
1362
+ );
850
1363
  }
851
1364
  const publication = await runOrThrow(runProcess, verification.publication);
852
1365
  validatePublishedEndpoint(publication.stdout, {
853
- target: "codex-chatgpt",
1366
+ target: safeTarget,
854
1367
  port: managed.marker.port,
855
1368
  });
856
1369
  return {
1370
+ target: safeTarget,
1371
+ installDirectory: safeDirectory,
1372
+ port: managed.marker.port,
857
1373
  dockerHost: managed.marker.dockerHost,
858
1374
  projectName: managed.marker.projectName,
859
1375
  };
860
1376
  }
861
1377
 
1378
+ export async function attestLocalCodexInstallation(
1379
+ { installDirectory, target = "codex-chatgpt" },
1380
+ dependencies = {},
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
+ }
1386
+ const attested = await attestManagedLocalEndpoint(
1387
+ {
1388
+ target: safeTarget,
1389
+ installDirectory,
1390
+ missingMessage: "Install the local Codex endpoint before signing in.",
1391
+ notRunningMessage: "The managed local Codex endpoint is not running.",
1392
+ },
1393
+ dependencies,
1394
+ );
1395
+ return {
1396
+ dockerHost: attested.dockerHost,
1397
+ projectName: attested.projectName,
1398
+ };
1399
+ }
1400
+
1401
+ export async function prepareLocalClientCredentialRotation(
1402
+ { target },
1403
+ {
1404
+ fileSystem = defaultFileSystem,
1405
+ env = process.env,
1406
+ homeDirectory = homedir(),
1407
+ runProcess = runLocalProcess,
1408
+ randomBytes = createRandomBytes,
1409
+ platform = process.platform,
1410
+ } = {},
1411
+ ) {
1412
+ assertSupportedPlatform(platform);
1413
+ rejectDockerEnvironmentOverrides(env);
1414
+ const safeTarget = validateLocalTarget(target);
1415
+ const installDirectory = await resolveLocalInstallRoot({
1416
+ target: safeTarget,
1417
+ env,
1418
+ homeDirectory,
1419
+ fileSystem,
1420
+ platform,
1421
+ });
1422
+ const attested = await attestManagedLocalEndpoint(
1423
+ {
1424
+ target: safeTarget,
1425
+ installDirectory,
1426
+ missingMessage:
1427
+ "Install the local endpoint before rotating its client credential.",
1428
+ },
1429
+ { fileSystem, runProcess, platform },
1430
+ );
1431
+ const { clientCredential, tokenSha256 } = createClientCredential(randomBytes);
1432
+ const plan = createLocalDeploymentPlan({
1433
+ target: safeTarget,
1434
+ port: attested.port,
1435
+ allowedOrigins: [],
1436
+ });
1437
+ return {
1438
+ target: plan.target,
1439
+ endpoint: plan.endpoint,
1440
+ protocol: plan.protocol,
1441
+ clientCredential,
1442
+ tokenSha256,
1443
+ credentialShownOnce: true,
1444
+ models: [],
1445
+ deploymentMode: "staged",
1446
+ experimental: plan.experimental,
1447
+ browserClients: plan.browserClients,
1448
+ };
1449
+ }
1450
+
1451
+ export async function activateLocalClientCredentialRotation(
1452
+ { target, clientCredential, tokenSha256 },
1453
+ {
1454
+ fileSystem = defaultFileSystem,
1455
+ env = process.env,
1456
+ homeDirectory = homedir(),
1457
+ runProcess = runLocalProcess,
1458
+ fetchImpl = fetch,
1459
+ verifyCodexCapability = verifyCodexWebSocketCapability,
1460
+ platform = process.platform,
1461
+ processId = process.pid,
1462
+ isProcessAlive = defaultIsProcessAlive,
1463
+ } = {},
1464
+ ) {
1465
+ assertSupportedPlatform(platform);
1466
+ rejectDockerEnvironmentOverrides(env);
1467
+ const safeTarget = validateLocalTarget(target);
1468
+ const safeVerifier = validateClientCredentialVerifier(tokenSha256);
1469
+ if (
1470
+ typeof clientCredential !== "string" ||
1471
+ createHash("sha256").update(clientCredential).digest("hex") !== safeVerifier
1472
+ ) {
1473
+ throw new TypeError("The staged local client credential is invalid.");
1474
+ }
1475
+ const installDirectory = await resolveLocalInstallRoot({
1476
+ target: safeTarget,
1477
+ env,
1478
+ homeDirectory,
1479
+ fileSystem,
1480
+ platform,
1481
+ });
1482
+ const releaseLock = await acquireLocalProjectLock(
1483
+ { installRoot: installDirectory, target: safeTarget },
1484
+ { fileSystem, processId, isProcessAlive },
1485
+ );
1486
+ try {
1487
+ const attested = await attestManagedLocalEndpoint(
1488
+ {
1489
+ target: safeTarget,
1490
+ installDirectory,
1491
+ missingMessage:
1492
+ "Install the local endpoint before rotating its client credential.",
1493
+ },
1494
+ { fileSystem, runProcess, platform },
1495
+ );
1496
+ const composePath = join(installDirectory, COMPOSE_FILENAME);
1497
+ await assertRegularManagedMarker(
1498
+ fileSystem,
1499
+ composePath,
1500
+ "The managed local endpoint configuration is invalid.",
1501
+ );
1502
+ let previousCompose;
1503
+ try {
1504
+ previousCompose = await fileSystem.readFile(composePath, "utf8");
1505
+ } catch {
1506
+ throw new Error("The managed local endpoint configuration is invalid.");
1507
+ }
1508
+
1509
+ const replacementCompose = replaceClientCredentialVerifier({
1510
+ target: safeTarget,
1511
+ composeFile: previousCompose,
1512
+ tokenSha256: safeVerifier,
1513
+ });
1514
+ const plan = createLocalDeploymentPlan({
1515
+ target: safeTarget,
1516
+ port: attested.port,
1517
+ allowedOrigins: [],
1518
+ });
1519
+ const validateCompose = () =>
1520
+ runOrThrow(runProcess, {
1521
+ label: "Local Compose validation",
1522
+ file: "docker",
1523
+ args: createComposeArgs(safeTarget, attested.projectName, [
1524
+ "config",
1525
+ "--quiet",
1526
+ ]),
1527
+ cwd: installDirectory,
1528
+ dockerHost: attested.dockerHost,
1529
+ });
1530
+ let configurationWritten = false;
1531
+
1532
+ try {
1533
+ await writeManagedFile(fileSystem, composePath, replacementCompose, 0o600);
1534
+ configurationWritten = true;
1535
+ await validateCompose();
1536
+ await runOrThrow(
1537
+ runProcess,
1538
+ createServiceRecreateSpec({
1539
+ target: safeTarget,
1540
+ installRoot: installDirectory,
1541
+ dockerHost: attested.dockerHost,
1542
+ projectName: attested.projectName,
1543
+ }),
1544
+ );
1545
+ await attestManagedLocalEndpoint(
1546
+ {
1547
+ target: safeTarget,
1548
+ installDirectory,
1549
+ missingMessage:
1550
+ "Install the local endpoint before rotating its client credential.",
1551
+ },
1552
+ { fileSystem, runProcess, platform },
1553
+ );
1554
+ const models = await verifyHttpEndpoint({
1555
+ plan,
1556
+ clientCredential,
1557
+ fetchImpl,
1558
+ });
1559
+ if (safeTarget === "codex-chatgpt") {
1560
+ await verifyCodexCapability({
1561
+ port: plan.port,
1562
+ clientCredential,
1563
+ });
1564
+ }
1565
+ return {
1566
+ target: plan.target,
1567
+ endpoint: plan.endpoint,
1568
+ protocol: plan.protocol,
1569
+ models,
1570
+ deploymentMode: "updated",
1571
+ experimental: plan.experimental,
1572
+ browserClients: plan.browserClients,
1573
+ };
1574
+ } catch (error) {
1575
+ if (error?.committed === true) {
1576
+ configurationWritten = true;
1577
+ }
1578
+ if (!configurationWritten) {
1579
+ throw new Error("Relmio could not rotate the local client credential.");
1580
+ }
1581
+
1582
+ try {
1583
+ await writeManagedFile(fileSystem, composePath, previousCompose, 0o600);
1584
+ await validateCompose();
1585
+ await runOrThrow(
1586
+ runProcess,
1587
+ createServiceRecreateSpec({
1588
+ target: safeTarget,
1589
+ installRoot: installDirectory,
1590
+ dockerHost: attested.dockerHost,
1591
+ projectName: attested.projectName,
1592
+ }),
1593
+ );
1594
+ await attestManagedLocalEndpoint(
1595
+ {
1596
+ target: safeTarget,
1597
+ installDirectory,
1598
+ missingMessage:
1599
+ "Install the local endpoint before rotating its client credential.",
1600
+ },
1601
+ { fileSystem, runProcess, platform },
1602
+ );
1603
+ await verifyHttpEndpoint({ plan, fetchImpl });
1604
+ } catch {
1605
+ let stopped = false;
1606
+ try {
1607
+ await runOrThrow(
1608
+ runProcess,
1609
+ createCleanupSpec({
1610
+ target: safeTarget,
1611
+ installRoot: installDirectory,
1612
+ dockerHost: attested.dockerHost,
1613
+ projectName: attested.projectName,
1614
+ }),
1615
+ );
1616
+ const remaining = await runOrThrow(
1617
+ runProcess,
1618
+ createCleanupVerificationSpec({
1619
+ target: safeTarget,
1620
+ installRoot: installDirectory,
1621
+ dockerHost: attested.dockerHost,
1622
+ projectName: attested.projectName,
1623
+ }),
1624
+ );
1625
+ stopped = !remaining.stdout
1626
+ .split(/\s+/u)
1627
+ .includes(PROJECTS[safeTarget].serviceName);
1628
+ } catch {
1629
+ // The endpoint is left stopped only when Docker confirms the exact service is gone.
1630
+ }
1631
+ if (stopped) {
1632
+ throw new Error(
1633
+ "Local credential rotation failed safely. The local endpoint was stopped.",
1634
+ );
1635
+ }
1636
+ throw new Error(
1637
+ "Relmio could not confirm that the failed credential rotation was stopped. Inspect the Relmio Docker project before retrying.",
1638
+ );
1639
+ }
1640
+
1641
+ throw new Error(
1642
+ "Local credential rotation failed safely. The previous verifier was restored and the managed endpoint was re-attested.",
1643
+ );
1644
+ }
1645
+ } finally {
1646
+ await releaseLock();
1647
+ }
1648
+ }
1649
+
862
1650
  export async function installLocalEndpoint(
863
1651
  { plan, apiKey, confirmed },
864
1652
  {
@@ -869,8 +1657,12 @@ export async function installLocalEndpoint(
869
1657
  randomBytes = createRandomBytes,
870
1658
  isPortAvailable = isLoopbackPortAvailable,
871
1659
  readGatewaySource = defaultReadGatewaySource,
1660
+ readCodexChatSource = defaultReadCodexChatSource,
872
1661
  fetchImpl = fetch,
1662
+ verifyCodexCapability = verifyCodexWebSocketCapability,
873
1663
  platform = process.platform,
1664
+ processId = process.pid,
1665
+ isProcessAlive = defaultIsProcessAlive,
874
1666
  } = {},
875
1667
  ) {
876
1668
  if (confirmed !== true) {
@@ -895,6 +1687,11 @@ export async function installLocalEndpoint(
895
1687
  fileSystem,
896
1688
  platform,
897
1689
  });
1690
+ const releaseLock = await acquireLocalProjectLock(
1691
+ { installRoot, target: normalizedPlan.target },
1692
+ { fileSystem, processId, isProcessAlive },
1693
+ );
1694
+ try {
898
1695
  const relmioHome = resolve(installRoot, "..", "..");
899
1696
  const managed = await inspectManagedRoot({
900
1697
  fileSystem,
@@ -977,6 +1774,39 @@ export async function installLocalEndpoint(
977
1774
  gatewaySource,
978
1775
  0o600,
979
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
+ );
980
1810
  } else {
981
1811
  dockerfile = createCodexDockerfile();
982
1812
  composeFile = createCodexComposeFile({
@@ -1069,6 +1899,12 @@ export async function installLocalEndpoint(
1069
1899
  clientCredential,
1070
1900
  fetchImpl,
1071
1901
  });
1902
+ if (normalizedPlan.target === "codex-chatgpt") {
1903
+ await verifyCodexCapability({
1904
+ port: normalizedPlan.port,
1905
+ clientCredential,
1906
+ });
1907
+ }
1072
1908
  } catch (error) {
1073
1909
  if (deploymentStarted) {
1074
1910
  let cleanupConfirmed = false;
@@ -1117,4 +1953,7 @@ export async function installLocalEndpoint(
1117
1953
  experimental: normalizedPlan.experimental,
1118
1954
  browserClients: normalizedPlan.browserClients,
1119
1955
  };
1956
+ } finally {
1957
+ await releaseLock();
1958
+ }
1120
1959
  }