relmio 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +40 -16
- package/docs/architecture.md +21 -9
- package/docs/local-endpoints-spec.md +101 -13
- package/docs/local-endpoints.md +101 -30
- package/docs/security.md +40 -15
- package/package.json +1 -1
- package/src/domain/local-endpoints.js +187 -14
- package/src/gateway/codex-chat.js +754 -0
- package/src/services/codex-login.js +11 -3
- package/src/services/local-installer.js +859 -20
- package/src/ui/local.css +140 -2
- package/src/ui/local.html +89 -9
- package/src/ui/local.js +157 -37
- package/src/web/server.js +247 -18
|
@@ -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
|
-
|
|
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(
|
|
779
|
+
args: createComposeArgs(safeTarget, attested.projectName, [
|
|
443
780
|
"restart",
|
|
444
781
|
"--timeout",
|
|
445
782
|
"10",
|
|
446
|
-
|
|
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(
|
|
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
|
-
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
811
|
-
|
|
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
|
-
|
|
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:
|
|
1339
|
+
target: safeTarget,
|
|
829
1340
|
});
|
|
830
1341
|
if (managed.deploymentMode !== "updated" || !managed.marker) {
|
|
831
|
-
throw new Error(
|
|
1342
|
+
throw new Error(missingMessage);
|
|
832
1343
|
}
|
|
833
1344
|
await attestDockerOwnership({
|
|
834
|
-
target:
|
|
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:
|
|
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(
|
|
849
|
-
throw new Error(
|
|
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:
|
|
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
|
}
|