relmio 0.3.0 → 0.4.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.
@@ -0,0 +1,550 @@
1
+ const token = new URLSearchParams(window.location.search).get("session");
2
+ window.history.replaceState(null, "", window.location.pathname);
3
+
4
+ const element = (id) => document.getElementById(id);
5
+
6
+ const state = {
7
+ step: 1,
8
+ target: "openai-api",
9
+ dockerAvailable: false,
10
+ planId: null,
11
+ plan: null,
12
+ };
13
+
14
+ const messageBox = element("global-message");
15
+ const messageText = element("global-message-text");
16
+ const errorBox = element("global-error");
17
+ const errorText = element("global-error-text");
18
+
19
+ function createWizardUrl(path) {
20
+ return token ? `${path}?session=${encodeURIComponent(token)}` : path;
21
+ }
22
+
23
+ element("back-to-vps").href = createWizardUrl("/");
24
+ element("return-to-vps").href = createWizardUrl("/");
25
+
26
+ function setMessage(text) {
27
+ messageText.textContent = text;
28
+ messageBox.hidden = false;
29
+ }
30
+
31
+ function clearError() {
32
+ errorText.textContent = "";
33
+ errorBox.hidden = true;
34
+ }
35
+
36
+ function showError(error) {
37
+ errorText.textContent = error?.message ?? "Something went wrong.";
38
+ errorBox.hidden = false;
39
+ errorBox.focus();
40
+ }
41
+
42
+ function setBusy(button, busy, busyText) {
43
+ if (!button.dataset.label) {
44
+ button.dataset.label = button.textContent.trim();
45
+ }
46
+ button.disabled = busy;
47
+ button.setAttribute("aria-busy", String(busy));
48
+ button.textContent = busy ? busyText : button.dataset.label;
49
+ }
50
+
51
+ function setButtonLabel(button, label) {
52
+ button.textContent = label;
53
+ button.dataset.label = label;
54
+ }
55
+
56
+ function showStep(step) {
57
+ state.step = step;
58
+ document.body.dataset.currentStep = String(step);
59
+
60
+ for (const panel of document.querySelectorAll("[data-step]")) {
61
+ const active = Number(panel.dataset.step) === step;
62
+ panel.hidden = !active;
63
+ if (active) {
64
+ panel.querySelector("h2")?.focus({ preventScroll: true });
65
+ }
66
+ }
67
+
68
+ for (const marker of document.querySelectorAll("[data-step-marker]")) {
69
+ const markerStep = Number(marker.dataset.stepMarker);
70
+ marker.classList.toggle("complete", markerStep < step);
71
+ if (markerStep === step) {
72
+ marker.setAttribute("aria-current", "step");
73
+ } else {
74
+ marker.removeAttribute("aria-current");
75
+ }
76
+ }
77
+
78
+ window.scrollTo({ top: 0, behavior: "smooth" });
79
+ }
80
+
81
+ async function api(path, { method = "GET", body } = {}) {
82
+ if (!token) {
83
+ throw new Error(
84
+ "This wizard link is incomplete. Close this tab and open the full URL printed by the active Relmio terminal.",
85
+ );
86
+ }
87
+
88
+ let response;
89
+ try {
90
+ response = await fetch(path, {
91
+ method,
92
+ headers: {
93
+ "Content-Type": "application/json",
94
+ "X-Setup-Token": token,
95
+ },
96
+ body: body === undefined ? undefined : JSON.stringify(body),
97
+ });
98
+ } catch {
99
+ throw new Error(
100
+ "The local Relmio wizard is not reachable. Keep its terminal open and try again.",
101
+ );
102
+ }
103
+
104
+ let result;
105
+ try {
106
+ result = await response.json();
107
+ } catch {
108
+ throw new Error("The local wizard returned an unreadable response.");
109
+ }
110
+
111
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
112
+ throw new Error("The local wizard returned an unexpected response.");
113
+ }
114
+ if (!response.ok) {
115
+ throw new Error(result.error ?? "The local request failed.");
116
+ }
117
+ return result;
118
+ }
119
+
120
+ function selectedTarget() {
121
+ return document.querySelector('input[name="target"]:checked')?.value;
122
+ }
123
+
124
+ function readAllowedOrigins() {
125
+ return element("allowed-origins")
126
+ .value.split(/\r?\n/u)
127
+ .map((value) => value.trim())
128
+ .filter((value) => value !== "");
129
+ }
130
+
131
+ function invalidatePlan() {
132
+ state.planId = null;
133
+ state.plan = null;
134
+ element("install-confirm").checked = false;
135
+ element("install-settings-button").disabled = true;
136
+ }
137
+
138
+ function renderTarget() {
139
+ state.target = selectedTarget();
140
+ invalidatePlan();
141
+
142
+ const isOpenAiApi = state.target === "openai-api";
143
+ element("local-port").value = isOpenAiApi ? "12435" : "14500";
144
+ element("origins-field").hidden = !isOpenAiApi;
145
+ element("target-guidance-title").textContent = isOpenAiApi
146
+ ? "Uses an OpenAI Platform API key"
147
+ : "Uses the official Codex ChatGPT sign-in";
148
+ element("target-guidance-detail").textContent = isOpenAiApi
149
+ ? "Your Platform key is seeded over stdin into a private Docker volume and is never returned to the browser. A separate local client credential is generated for your apps."
150
+ : "This runs Codex App Server as its own experimental protocol. It does not translate the ChatGPT session into a generic OpenAI /v1 API and it does not accept direct browser connections.";
151
+ }
152
+
153
+ function appendPolicyNotice(container, heading, detail) {
154
+ const strong = document.createElement("strong");
155
+ const paragraph = document.createElement("p");
156
+ strong.textContent = heading;
157
+ paragraph.textContent = detail;
158
+ container.replaceChildren(strong, paragraph);
159
+ }
160
+
161
+ function renderPlan(plan) {
162
+ const isOpenAiApi = plan.target === "openai-api";
163
+ element("review-endpoint").textContent = plan.endpoint;
164
+ element("review-protocol").textContent = isOpenAiApi
165
+ ? "OpenAI-compatible HTTP /v1"
166
+ : "Codex App Server JSON-RPC over WebSocket";
167
+ element("review-auth").textContent = isOpenAiApi
168
+ ? "OpenAI Platform API key"
169
+ : "ChatGPT sign-in through official Codex";
170
+ element("review-browser").textContent = isOpenAiApi
171
+ ? plan.allowedOrigins.length > 0
172
+ ? `Only ${plan.allowedOrigins.length} exact allowed origin(s)`
173
+ : "Native clients only until exact origins are added"
174
+ : "No — trusted native local clients only";
175
+ element("review-origins-row").hidden = !isOpenAiApi;
176
+ element("review-origins").textContent = isOpenAiApi
177
+ ? plan.allowedOrigins.length > 0
178
+ ? plan.allowedOrigins.join(", ")
179
+ : "None"
180
+ : "";
181
+ element("review-path").textContent = plan.managedPath;
182
+
183
+ if (isOpenAiApi) {
184
+ appendPolicyNotice(
185
+ element("review-policy"),
186
+ "OpenAI Platform terms and billing apply",
187
+ "This option sends requests to the OpenAI API with your developer Platform key. ChatGPT subscriptions and open-source program benefits do not turn a ChatGPT credential into an API key.",
188
+ );
189
+ } else {
190
+ appendPolicyNotice(
191
+ element("review-policy"),
192
+ "Experimental, high-trust Codex integration",
193
+ "This option exposes the official Codex App Server only on loopback. Its client capability controls Codex inside the isolated container, may act through the ChatGPT session you sign in with, and may recover that container's ChatGPT session credential. Treat it like your ChatGPT password. It is not a browser or OpenAI /v1 API.",
194
+ );
195
+ }
196
+ }
197
+
198
+ function prepareInstallPanel() {
199
+ const isOpenAiApi = state.plan.target === "openai-api";
200
+ const apiKey = element("platform-api-key");
201
+ element("api-key-field").hidden = !isOpenAiApi;
202
+ element("codex-install-warning").hidden = isOpenAiApi;
203
+ apiKey.required = isOpenAiApi;
204
+ apiKey.value = "";
205
+ element("install-intro").textContent = isOpenAiApi
206
+ ? "Enter the OpenAI Platform API key this endpoint will use upstream. It is sent only to this local Relmio process."
207
+ : "Relmio will install the official Codex App Server first. You will complete ChatGPT device sign-in after the container is ready.";
208
+ setButtonLabel(
209
+ element("install-button"),
210
+ isOpenAiApi ? "Install OpenAI API endpoint" : "Install Codex App Server",
211
+ );
212
+ }
213
+
214
+ function renderInstallResult(result) {
215
+ if (
216
+ typeof result.endpoint !== "string" ||
217
+ typeof result.clientCredential !== "string" ||
218
+ !["openai-api", "codex-chatgpt"].includes(result.target)
219
+ ) {
220
+ throw new Error("The local installer returned an unexpected response.");
221
+ }
222
+
223
+ const isOpenAiApi = result.target === "openai-api";
224
+ element("result-endpoint").textContent = result.endpoint;
225
+ element("result-credential").textContent = result.clientCredential;
226
+ element("codex-production-warning").hidden = isOpenAiApi;
227
+ element("codex-login").hidden = isOpenAiApi;
228
+ element("done-title").textContent = isOpenAiApi
229
+ ? "OpenAI API endpoint is ready"
230
+ : "Codex App Server is installed";
231
+ element("done-detail").textContent = isOpenAiApi
232
+ ? "Copy the endpoint and generated bearer credential into your local app."
233
+ : "Copy the endpoint and capability, then sign the isolated Codex container in to ChatGPT.";
234
+ appendPolicyNotice(
235
+ element("client-warning"),
236
+ isOpenAiApi ? "For local OpenAI-compatible clients" : "For trusted native Codex clients only",
237
+ isOpenAiApi
238
+ ? "Use the generated client credential as the bearer API key. Your upstream Platform key remains private in the managed Docker volume."
239
+ : "This capability is not an OpenAI API key. Treat it like your ChatGPT password: the client is trusted to control the isolated container and may recover its ChatGPT session credential. It must speak official Codex App Server JSON-RPC over WebSocket.",
240
+ );
241
+ }
242
+
243
+ function validateVerificationUrl(value) {
244
+ if (typeof value !== "string" || value.length > 2048) {
245
+ throw new Error("Relmio refused an unexpected sign-in destination.");
246
+ }
247
+
248
+ let url;
249
+ try {
250
+ url = new URL(value);
251
+ } catch {
252
+ throw new Error("Relmio refused an unexpected sign-in destination.");
253
+ }
254
+
255
+ if (
256
+ url.origin !== "https://auth.openai.com" ||
257
+ url.username !== "" ||
258
+ url.password !== "" ||
259
+ url.hash !== ""
260
+ ) {
261
+ throw new Error("Relmio refused an unexpected sign-in destination.");
262
+ }
263
+ return url.toString();
264
+ }
265
+
266
+ function validateDeviceCode(value) {
267
+ if (
268
+ typeof value !== "string" ||
269
+ value.length < 4 ||
270
+ value.length > 32 ||
271
+ !/^[A-Z0-9]+(?:-[A-Z0-9]+)*$/u.test(value)
272
+ ) {
273
+ throw new Error("Codex returned an unexpected device code.");
274
+ }
275
+ return value;
276
+ }
277
+
278
+ const delay = (milliseconds) =>
279
+ new Promise((resolve) => window.setTimeout(resolve, milliseconds));
280
+
281
+ async function waitForCodexLogin() {
282
+ for (let attempt = 0; attempt < 300; attempt += 1) {
283
+ const result = await api("/api/local/codex/login/status");
284
+ if (result.status === "success") {
285
+ return;
286
+ }
287
+ if (result.status === "error") {
288
+ throw new Error(result.error ?? "ChatGPT sign-in did not finish.");
289
+ }
290
+ await delay(1_000);
291
+ }
292
+ throw new Error("ChatGPT device sign-in expired. Start it again.");
293
+ }
294
+
295
+ async function copyText(value) {
296
+ const previouslyFocused = document.activeElement;
297
+ const textarea = document.createElement("textarea");
298
+ textarea.value = value;
299
+ textarea.readOnly = true;
300
+ textarea.setAttribute("aria-hidden", "true");
301
+ textarea.style.position = "fixed";
302
+ textarea.style.opacity = "0";
303
+
304
+ let copied = false;
305
+ try {
306
+ document.body.append(textarea);
307
+ textarea.focus();
308
+ textarea.select();
309
+ textarea.setSelectionRange?.(0, textarea.value.length);
310
+ copied = document.execCommand("copy");
311
+ } catch {
312
+ copied = false;
313
+ } finally {
314
+ textarea.remove();
315
+ previouslyFocused?.focus?.();
316
+ }
317
+
318
+ if (copied) {
319
+ return;
320
+ }
321
+ if (navigator.clipboard?.writeText) {
322
+ try {
323
+ await navigator.clipboard.writeText(value);
324
+ return;
325
+ } catch {
326
+ // A generic message below avoids repeating sensitive copied values.
327
+ }
328
+ }
329
+ throw new Error("The browser refused clipboard access.");
330
+ }
331
+
332
+ function flashCopied(button) {
333
+ const originalLabel = button.textContent;
334
+ button.textContent = "Copied";
335
+ button.classList.add("copied");
336
+ window.setTimeout(() => {
337
+ button.textContent = originalLabel;
338
+ button.classList.remove("copied");
339
+ }, 1_800);
340
+ }
341
+
342
+ element("target-form").addEventListener("submit", async (event) => {
343
+ event.preventDefault();
344
+ const button = element("review-button");
345
+ clearError();
346
+ invalidatePlan();
347
+ setBusy(button, true, "Preparing plan…");
348
+ try {
349
+ const result = await api("/api/local/plan", {
350
+ method: "POST",
351
+ body: {
352
+ target: state.target,
353
+ port: element("local-port").value,
354
+ allowedOrigins:
355
+ state.target === "openai-api" ? readAllowedOrigins() : [],
356
+ },
357
+ });
358
+ if (
359
+ typeof result.planId !== "string" ||
360
+ !result.plan ||
361
+ typeof result.plan !== "object" ||
362
+ Array.isArray(result.plan)
363
+ ) {
364
+ throw new Error("The local wizard returned an unexpected plan.");
365
+ }
366
+ state.planId = result.planId;
367
+ state.plan = result.plan;
368
+ renderPlan(result.plan);
369
+ showStep(2);
370
+ setMessage("Review the exact loopback plan. Nothing has been written yet.");
371
+ } catch (error) {
372
+ showError(error);
373
+ } finally {
374
+ setBusy(button, false);
375
+ }
376
+ });
377
+
378
+ for (const input of document.querySelectorAll('input[name="target"]')) {
379
+ input.addEventListener("change", renderTarget);
380
+ }
381
+
382
+ element("local-port").addEventListener("input", invalidatePlan);
383
+ element("allowed-origins").addEventListener("input", invalidatePlan);
384
+
385
+ element("install-confirm").addEventListener("change", (event) => {
386
+ element("install-settings-button").disabled = !event.currentTarget.checked;
387
+ });
388
+
389
+ element("install-settings-button").addEventListener("click", () => {
390
+ clearError();
391
+ if (!state.planId || !state.plan || !element("install-confirm").checked) {
392
+ showError(new Error("Review and confirm the local plan first."));
393
+ return;
394
+ }
395
+ prepareInstallPanel();
396
+ showStep(3);
397
+ setMessage("The plan is confirmed. Installation has not started yet.");
398
+ });
399
+
400
+ element("install-button").addEventListener("click", async (event) => {
401
+ const button = event.currentTarget;
402
+ const apiKeyInput = element("platform-api-key");
403
+ clearError();
404
+ if (!state.planId || !state.plan || !element("install-confirm").checked) {
405
+ showStep(1);
406
+ showError(new Error("Review and confirm a fresh local plan first."));
407
+ return;
408
+ }
409
+ if (state.plan.target === "openai-api" && !apiKeyInput.reportValidity()) {
410
+ return;
411
+ }
412
+ const requestBody = {
413
+ planId: state.planId,
414
+ confirmed: element("install-confirm").checked,
415
+ ...(state.plan.target === "openai-api"
416
+ ? { apiKey: apiKeyInput.value }
417
+ : {}),
418
+ };
419
+ apiKeyInput.value = "";
420
+ setBusy(button, true, "Installing locally…");
421
+ setMessage("Building and verifying the loopback-only Docker container…");
422
+ try {
423
+ const result = await api("/api/local/install", {
424
+ method: "POST",
425
+ body: requestBody,
426
+ });
427
+ renderInstallResult(result);
428
+ state.planId = null;
429
+ showStep(4);
430
+ setMessage(
431
+ result.target === "openai-api"
432
+ ? "Local OpenAI API endpoint verified. Copy its one-time client credential now."
433
+ : "Codex App Server verified. Copy its one-time capability and complete ChatGPT sign-in.",
434
+ );
435
+ } catch (error) {
436
+ invalidatePlan();
437
+ showStep(1);
438
+ setMessage("Installation stopped. Prepare and confirm a fresh plan before retrying.");
439
+ showError(error);
440
+ } finally {
441
+ requestBody.apiKey = undefined;
442
+ apiKeyInput.value = "";
443
+ setBusy(button, false);
444
+ }
445
+ });
446
+
447
+ element("codex-login-button").addEventListener("click", async (event) => {
448
+ const button = event.currentTarget;
449
+ const resultBox = element("device-code-result");
450
+ const status = element("device-code-status");
451
+ clearError();
452
+ resultBox.hidden = true;
453
+ setBusy(button, true, "Waiting for ChatGPT…");
454
+ try {
455
+ const result = await api("/api/local/codex/login", {
456
+ method: "POST",
457
+ body: {},
458
+ });
459
+ const verificationUrl = validateVerificationUrl(result.verificationUrl);
460
+ const userCode = validateDeviceCode(result.userCode);
461
+ element("device-code").textContent = userCode;
462
+ element("device-code-link").href = verificationUrl;
463
+ status.textContent = "Waiting for sign-in in the isolated Codex container…";
464
+ resultBox.hidden = false;
465
+ setMessage("Open the official OpenAI page and enter the displayed device code.");
466
+ await waitForCodexLogin();
467
+ status.textContent = "ChatGPT sign-in completed. Codex is ready for your trusted native client.";
468
+ setMessage("Codex ChatGPT sign-in completed successfully.");
469
+ } catch (error) {
470
+ status.textContent = "ChatGPT sign-in did not complete.";
471
+ showError(error);
472
+ } finally {
473
+ setBusy(button, false);
474
+ }
475
+ });
476
+
477
+ for (const button of document.querySelectorAll("[data-copy-target]")) {
478
+ button.addEventListener("click", async (event) => {
479
+ const copyButton = event.currentTarget;
480
+ const value = element(copyButton.dataset.copyTarget).textContent;
481
+ clearError();
482
+ try {
483
+ if (!value) {
484
+ throw new Error("No displayed value is available to copy.");
485
+ }
486
+ await copyText(value);
487
+ flashCopied(copyButton);
488
+ setMessage(`${copyButton.dataset.copyLabel} copied.`);
489
+ } catch {
490
+ showError(
491
+ new Error(
492
+ `Copy failed. Select the ${copyButton.dataset.copyLabel} manually.`,
493
+ ),
494
+ );
495
+ }
496
+ });
497
+ }
498
+
499
+ for (const button of document.querySelectorAll(".back-button")) {
500
+ button.addEventListener("click", () => {
501
+ clearError();
502
+ showStep(Number(button.dataset.back));
503
+ setMessage("No new local installation has started.");
504
+ });
505
+ }
506
+
507
+ async function refreshDockerStatus() {
508
+ clearError();
509
+ const result = await api("/api/local/docker/status");
510
+ state.dockerAvailable = result.dockerAvailable === true;
511
+ const indicator = element("docker-indicator");
512
+ const reviewButton = element("review-button");
513
+ if (result.previewMode === true) {
514
+ indicator.classList.remove("ready");
515
+ element("docker-status-title").textContent = "Sanitized preview mode";
516
+ element("docker-status-detail").textContent =
517
+ "Local Docker discovery and installation are disabled in this preview.";
518
+ reviewButton.disabled = true;
519
+ setMessage("Preview mode shows the flow without accessing local Docker or credentials.");
520
+ return;
521
+ }
522
+ if (result.unsupportedPlatform === true) {
523
+ indicator.classList.remove("ready");
524
+ element("docker-status-title").textContent =
525
+ "Native Windows is not supported";
526
+ element("docker-status-detail").textContent =
527
+ "Run Relmio on macOS, Linux, or under WSL2 so credentials retain POSIX owner-only file permissions.";
528
+ reviewButton.disabled = true;
529
+ setMessage("This local Docker feature requires a supported POSIX environment.");
530
+ return;
531
+ }
532
+ if (state.dockerAvailable) {
533
+ indicator.classList.add("ready");
534
+ element("docker-status-title").textContent = "Docker is ready";
535
+ element("docker-status-detail").textContent =
536
+ `Engine ${result.dockerVersion}; Compose ${result.composeVersion}`;
537
+ reviewButton.disabled = false;
538
+ setMessage("Docker is ready. Choose the credential path for your client.");
539
+ } else {
540
+ indicator.classList.remove("ready");
541
+ element("docker-status-title").textContent = "Docker is not available";
542
+ element("docker-status-detail").textContent =
543
+ "Start Docker Desktop or install Docker Engine with Compose, then reopen this wizard.";
544
+ reviewButton.disabled = true;
545
+ setMessage("Docker is required before Relmio can create a local endpoint.");
546
+ }
547
+ }
548
+
549
+ renderTarget();
550
+ refreshDockerStatus().catch(showError);