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