relmio 0.6.0 → 0.8.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/src/web/server.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  prepareLocalClientCredentialRotation,
35
35
  } from "../services/local-installer.js";
36
36
  import { startCodexDeviceLogin } from "../services/codex-login.js";
37
+ import { createLocalChatTestService } from "../services/local-chat-test.js";
37
38
 
38
39
  const MAX_BODY_BYTES = 32 * 1024;
39
40
  const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
@@ -87,6 +88,7 @@ const defaultServices = {
87
88
  resolveLocalInstallRoot,
88
89
  restartLocalCodex,
89
90
  startCodexDeviceLogin,
91
+ createLocalChatTestService,
90
92
  };
91
93
 
92
94
  function setSecurityHeaders(response) {
@@ -113,6 +115,64 @@ function sendJson(response, statusCode, body) {
113
115
  response.end(contents);
114
116
  }
115
117
 
118
+ function requestAcceptsEventStream(request) {
119
+ const value = request.headers.accept;
120
+ return (
121
+ typeof value === "string" &&
122
+ value
123
+ .split(",")
124
+ .some((entry) => entry.trim().split(";", 1)[0] === "text/event-stream")
125
+ );
126
+ }
127
+
128
+ function startLocalChatTestStream(response) {
129
+ let ended = false;
130
+ response.writeHead(200, {
131
+ "Content-Encoding": "none",
132
+ "Content-Type": "text/event-stream; charset=utf-8",
133
+ "X-Accel-Buffering": "no",
134
+ "X-Relmio-Stream": "v1",
135
+ });
136
+ const send = (event, data) => {
137
+ if (ended || response.writableEnded || response.destroyed) return;
138
+ response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
139
+ };
140
+ const keepalive = setInterval(() => {
141
+ if (!ended && !response.writableEnded && !response.destroyed) {
142
+ response.write(": keepalive\n\n");
143
+ }
144
+ }, 15_000);
145
+ keepalive.unref?.();
146
+ send("start", { requestId: randomUUID() });
147
+ return {
148
+ send(event, data) {
149
+ if (event === "progress" || event === "delta") send(event, data);
150
+ },
151
+ complete(result) {
152
+ if (ended) return;
153
+ send("terminal", {
154
+ outcome: "completed",
155
+ conversationId: result.conversationId,
156
+ });
157
+ ended = true;
158
+ clearInterval(keepalive);
159
+ response.end();
160
+ },
161
+ fail(code = "upstream_failed") {
162
+ if (ended) return;
163
+ send("error", { code, retryable: true });
164
+ send("terminal", { outcome: "failed" });
165
+ ended = true;
166
+ clearInterval(keepalive);
167
+ response.end();
168
+ },
169
+ dispose() {
170
+ ended = true;
171
+ clearInterval(keepalive);
172
+ },
173
+ };
174
+ }
175
+
116
176
  function tokenMatches(actual, expected) {
117
177
  if (typeof actual !== "string") {
118
178
  return false;
@@ -373,6 +433,67 @@ function createSafeProjectMeta(result) {
373
433
  };
374
434
  }
375
435
 
436
+ function createSafeLocalChatTestKey(result) {
437
+ if (
438
+ !result ||
439
+ typeof result !== "object" ||
440
+ Array.isArray(result) ||
441
+ typeof result.keyId !== "string" ||
442
+ result.keyId.length === 0 ||
443
+ result.keyId.length > 128 ||
444
+ !result.publicKeyJwk ||
445
+ typeof result.publicKeyJwk !== "object" ||
446
+ Array.isArray(result.publicKeyJwk) ||
447
+ result.publicKeyJwk.kty !== "RSA" ||
448
+ typeof result.publicKeyJwk.n !== "string" ||
449
+ typeof result.publicKeyJwk.e !== "string" ||
450
+ result.publicKeyJwk.n.length === 0 ||
451
+ result.publicKeyJwk.n.length > 1_024 ||
452
+ result.publicKeyJwk.e.length === 0 ||
453
+ result.publicKeyJwk.e.length > 32 ||
454
+ result.algorithm !== "RSA-OAEP-256" ||
455
+ typeof result.expiresAt !== "string" ||
456
+ Number.isNaN(Date.parse(result.expiresAt))
457
+ ) {
458
+ throw Object.assign(new Error("The local tester could not start safely."), {
459
+ statusCode: 502,
460
+ });
461
+ }
462
+ return {
463
+ keyId: result.keyId,
464
+ publicKeyJwk: {
465
+ kty: "RSA",
466
+ n: result.publicKeyJwk.n,
467
+ e: result.publicKeyJwk.e,
468
+ },
469
+ algorithm: result.algorithm,
470
+ expiresAt: result.expiresAt,
471
+ };
472
+ }
473
+
474
+ function createSafeLocalChatTestResponse(result) {
475
+ if (
476
+ !result ||
477
+ typeof result !== "object" ||
478
+ Array.isArray(result) ||
479
+ typeof result.conversationId !== "string" ||
480
+ result.conversationId.length === 0 ||
481
+ result.conversationId.length > 160 ||
482
+ typeof result.output !== "string" ||
483
+ result.output.length === 0 ||
484
+ result.output.length > 12 * 1_024
485
+ ) {
486
+ throw Object.assign(
487
+ new Error("The local adapter returned an unexpected response."),
488
+ { statusCode: 502 },
489
+ );
490
+ }
491
+ return {
492
+ conversationId: result.conversationId,
493
+ output: result.output,
494
+ };
495
+ }
496
+
376
497
  function getPendingLocalCredentialRotation(state) {
377
498
  if (
378
499
  state.localCredentialRotationPending &&
@@ -409,6 +530,15 @@ function requireLiveLocalAction(state, action) {
409
530
  }
410
531
  }
411
532
 
533
+ function requireReadyLocalChatTester(state) {
534
+ if (state.localInstalledTarget !== "codex-chat") {
535
+ throw Object.assign(
536
+ new Error("Install the Codex Chat Adapter before starting its local tester."),
537
+ { statusCode: 409 },
538
+ );
539
+ }
540
+ }
541
+
412
542
  async function handleApi(request, response, path, state) {
413
543
  requireApiToken(request, state);
414
544
  requireSameOrigin(request, state);
@@ -603,6 +733,68 @@ async function handleApi(request, response, path, state) {
603
733
 
604
734
  const body = await readJsonBody(request);
605
735
 
736
+ if (path === "/api/local/chat-test/key") {
737
+ requireLiveLocalAction(state, "Local chat testing");
738
+ requireReadyLocalChatTester(state);
739
+ enforceRateLimit(state, path);
740
+ sendJson(
741
+ response,
742
+ 200,
743
+ createSafeLocalChatTestKey(await state.localChatTest.issueKey()),
744
+ );
745
+ return;
746
+ }
747
+
748
+ if (path === "/api/local/chat-test/message") {
749
+ requireLiveLocalAction(state, "Local chat testing");
750
+ requireReadyLocalChatTester(state);
751
+ enforceRateLimit(state, path);
752
+ const wantsStream = requestAcceptsEventStream(request);
753
+ const stream = wantsStream ? startLocalChatTestStream(response) : null;
754
+ const requestController = wantsStream ? new AbortController() : null;
755
+ const abortOnClose = () => {
756
+ if (!response.writableEnded) requestController?.abort();
757
+ };
758
+ response.once("close", abortOnClose);
759
+ try {
760
+ const result = createSafeLocalChatTestResponse(
761
+ await state.localChatTest.message(body, {
762
+ ...(stream
763
+ ? {
764
+ onEvent: (event, data) => stream.send(event, data),
765
+ signal: requestController.signal,
766
+ }
767
+ : {}),
768
+ }),
769
+ );
770
+ if (stream) stream.complete(result);
771
+ else sendJson(response, 200, result);
772
+ } catch (error) {
773
+ if (stream) {
774
+ stream.fail(error?.statusCode === 504 ? "timeout" : "upstream_failed");
775
+ } else {
776
+ throw error;
777
+ }
778
+ } finally {
779
+ response.off("close", abortOnClose);
780
+ stream?.dispose();
781
+ if (body && typeof body === "object") {
782
+ body.encryptedCredential = undefined;
783
+ body.input = undefined;
784
+ }
785
+ }
786
+ return;
787
+ }
788
+
789
+ if (path === "/api/local/chat-test/reset") {
790
+ requireLiveLocalAction(state, "Local chat testing");
791
+ requireReadyLocalChatTester(state);
792
+ enforceRateLimit(state, path);
793
+ await state.localChatTest.reset(body);
794
+ sendJson(response, 200, { forgotten: true });
795
+ return;
796
+ }
797
+
606
798
  if (path === "/api/oauth/cancel") {
607
799
  requireLiveLocalAction(state, "Live ChatGPT sign-in");
608
800
  const login = state.oauthLogin;
@@ -670,12 +862,14 @@ async function handleApi(request, response, path, state) {
670
862
  state.localInstallInFlight = true;
671
863
  acquiredInstallLock = true;
672
864
  state.localPlan = null;
865
+ state.localInstalledTarget = null;
673
866
  const result = await state.services.installLocalEndpoint({
674
867
  plan: pending.plan,
675
868
  apiKey: body.apiKey,
676
869
  confirmed: body.confirmed,
677
870
  });
678
871
 
872
+ state.localInstalledTarget = pending.plan.target;
679
873
  sendJson(response, 200, createSafeLocalInstallResult(result));
680
874
  } finally {
681
875
  if (acquiredInstallLock) {
@@ -707,6 +901,7 @@ async function handleApi(request, response, path, state) {
707
901
 
708
902
  state.localCredentialRotationInFlight = true;
709
903
  try {
904
+ state.localChatTest.resetAll?.();
710
905
  const result = await state.services.prepareLocalClientCredentialRotation({
711
906
  target: validateLocalTarget(body.target),
712
907
  });
@@ -749,6 +944,7 @@ async function handleApi(request, response, path, state) {
749
944
  state.localCredentialRotationPending = null;
750
945
  state.localCredentialRotationInFlight = true;
751
946
  try {
947
+ state.localChatTest.resetAll?.();
752
948
  const result = await state.services.activateLocalClientCredentialRotation({
753
949
  target: pending.target,
754
950
  clientCredential: body.clientCredential,
@@ -1079,9 +1275,13 @@ export async function startWizardServer({
1079
1275
  throw new TypeError("A strong wizard session token is required.");
1080
1276
  }
1081
1277
 
1278
+ const resolvedServices = { ...defaultServices, ...services };
1082
1279
  const state = {
1083
1280
  sessionToken,
1084
- services: { ...defaultServices, ...services },
1281
+ services: resolvedServices,
1282
+ localChatTest:
1283
+ resolvedServices.localChatTest ??
1284
+ resolvedServices.createLocalChatTestService(),
1085
1285
  uiFiles: uiFiles ?? (await loadDefaultUiFiles()),
1086
1286
  origin: "http://127.0.0.1",
1087
1287
  connection: null,
@@ -1094,6 +1294,7 @@ export async function startWizardServer({
1094
1294
  oauthLoginStartInFlight: false,
1095
1295
  oauthLoginStartPromise: null,
1096
1296
  localPlan: null,
1297
+ localInstalledTarget: null,
1097
1298
  localInstallInFlight: false,
1098
1299
  localCredentialRotationInFlight: false,
1099
1300
  localCredentialRotationPending: null,
@@ -1122,6 +1323,7 @@ export async function startWizardServer({
1122
1323
  origin: state.origin,
1123
1324
  async close() {
1124
1325
  state.closing = true;
1326
+ state.localChatTest.dispose?.();
1125
1327
  await waitForBoundedResult(
1126
1328
  state.oauthLoginStartPromise,
1127
1329
  state.oauthShutdownWaitMs,