relmio 0.4.0 → 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.
- package/CHANGELOG.md +46 -0
- package/README.md +16 -2
- package/docs/local-endpoints.md +22 -13
- package/docs/troubleshooting.md +1 -1
- package/package.json +1 -1
- package/src/domain/local-endpoints.js +4 -1
- package/src/services/local-installer.js +772 -13
- package/src/services/oauth.js +323 -28
- package/src/ui/app.js +133 -6
- package/src/ui/index.html +3 -0
- package/src/ui/local.css +140 -2
- package/src/ui/local.html +69 -1
- package/src/ui/local.js +58 -0
- package/src/web/server.js +461 -50
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1297
|
+
target: safeTarget,
|
|
829
1298
|
});
|
|
830
1299
|
if (managed.deploymentMode !== "updated" || !managed.marker) {
|
|
831
|
-
throw new Error(
|
|
1300
|
+
throw new Error(missingMessage);
|
|
832
1301
|
}
|
|
833
1302
|
await attestDockerOwnership({
|
|
834
|
-
target:
|
|
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:
|
|
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(
|
|
849
|
-
throw new Error(
|
|
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:
|
|
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
|
}
|