@wipcomputer/wip-ldm-os 0.4.82-alpha.1 → 0.4.84

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,254 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Codex remote control</title>
7
+ <style>
8
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
9
+ :root {
10
+ --bg: #FFFDF5;
11
+ --bg-event: #F5F3ED;
12
+ --bg-tool: #F0EDE6;
13
+ --text: #1a1a1a;
14
+ --text-muted: #8a8580;
15
+ --accent: #0033FF;
16
+ --danger: #b00020;
17
+ --border: #E0DDD6;
18
+ --user-bubble: #E8F0FE;
19
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
20
+ --mono: ui-monospace, "SF Mono", Menlo, monospace;
21
+ }
22
+ html, body { height: 100%; font-family: var(--font); background: var(--bg); color: var(--text); }
23
+ body { display: flex; flex-direction: column; }
24
+ header { padding: 12px 16px; padding-top: calc(12px + env(safe-area-inset-top, 0px)); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
25
+ header .id { flex: 1; font-size: 13px; color: var(--text-muted); font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
26
+ header .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
27
+ header .dot.online { background: #2ea44f; }
28
+ header .dot.offline { background: var(--danger); }
29
+ main { flex: 1; overflow-y: auto; padding: 16px; padding-bottom: 0; -webkit-overflow-scrolling: touch; }
30
+ .event { margin-bottom: 12px; padding: 12px 14px; border-radius: 10px; background: var(--bg-event); font-size: 14px; line-height: 1.45; }
31
+ .event .meta { font-size: 11px; color: var(--text-muted); font-family: var(--mono); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
32
+ .event.user { background: var(--user-bubble); }
33
+ .event.agent_message { background: var(--bg); border: 1px solid var(--border); }
34
+ .event.command_execution { background: var(--bg-tool); font-family: var(--mono); white-space: pre-wrap; word-break: break-all; }
35
+ .event.command_execution.failed { border-left: 3px solid var(--danger); }
36
+ .event.error { background: #fff0f0; border: 1px solid #f0c0c0; color: var(--danger); }
37
+ .event.system { background: transparent; color: var(--text-muted); font-size: 12px; padding: 6px 0; text-align: center; }
38
+ .event pre { font-family: var(--mono); white-space: pre-wrap; word-break: break-word; font-size: 13px; }
39
+ footer { padding: 12px; padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--border); background: var(--bg); }
40
+ .composer { display: flex; gap: 8px; align-items: flex-end; }
41
+ textarea {
42
+ flex: 1; min-height: 44px; max-height: 120px; padding: 12px;
43
+ border: 1px solid var(--border); border-radius: 10px;
44
+ background: var(--bg); color: var(--text); font-family: var(--font); font-size: 16px;
45
+ resize: none;
46
+ }
47
+ textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
48
+ button { padding: 12px 16px; border: none; border-radius: 10px; font-family: var(--font); font-size: 14px; font-weight: 600; cursor: pointer; -webkit-tap-highlight-color: transparent; }
49
+ button:active { transform: scale(0.97); }
50
+ button:disabled { opacity: 0.4; cursor: not-allowed; }
51
+ .btn-send { background: var(--accent); color: white; }
52
+ .btn-stop { background: var(--danger); color: white; }
53
+ </style>
54
+ </head>
55
+ <body>
56
+ <header>
57
+ <div class="dot" id="presence" title="connecting"></div>
58
+ <div class="id" id="threadId">...</div>
59
+ <button id="stopBtn" class="btn-stop" type="button" disabled>Stop</button>
60
+ </header>
61
+ <main id="log"></main>
62
+ <footer>
63
+ <form class="composer" id="composer">
64
+ <textarea id="prompt" rows="1" placeholder="Tell Codex what to do..." autocomplete="off"></textarea>
65
+ <button type="submit" class="btn-send" id="sendBtn">Send</button>
66
+ </form>
67
+ </footer>
68
+ <script>
69
+ function getApiKey() { return sessionStorage.getItem("wip_api_key"); }
70
+ function getHandle() { return sessionStorage.getItem("wip_handle") || ""; }
71
+
72
+ function ensureSignedIn() {
73
+ if (!getApiKey()) {
74
+ location.href = "/app/login.html?next=" + encodeURIComponent(location.pathname);
75
+ return false;
76
+ }
77
+ return true;
78
+ }
79
+
80
+ function parsePath() {
81
+ // /:handle/codex-remote-control/:threadId
82
+ const m = location.pathname.match(/^\/([^/]+)\/codex-remote-control\/([^/]+)\/?$/);
83
+ if (!m) return null;
84
+ return { handle: decodeURIComponent(m[1]), threadId: decodeURIComponent(m[2]) };
85
+ }
86
+
87
+ function setPresence(state) {
88
+ const dot = document.getElementById("presence");
89
+ dot.classList.remove("online", "offline");
90
+ if (state === "online") dot.classList.add("online");
91
+ if (state === "offline") dot.classList.add("offline");
92
+ dot.title = state;
93
+ }
94
+
95
+ function appendEvent(html, kind) {
96
+ const log = document.getElementById("log");
97
+ const div = document.createElement("div");
98
+ div.className = "event " + (kind || "");
99
+ div.innerHTML = html;
100
+ log.appendChild(div);
101
+ log.scrollTop = log.scrollHeight;
102
+ return div;
103
+ }
104
+
105
+ function escapeHtml(s) {
106
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
107
+ }
108
+
109
+ function renderItem(item) {
110
+ if (item.type === "agent_message") {
111
+ return appendEvent('<div class="meta">codex</div>' + escapeHtml(item.text || "").replace(/\n/g, "<br>"), "agent_message");
112
+ }
113
+ if (item.type === "command_execution") {
114
+ const status = (item.status || "").toString();
115
+ const out = item.aggregated_output ? '\n\n' + item.aggregated_output : "";
116
+ return appendEvent(
117
+ '<div class="meta">$ ' + escapeHtml(status) + (item.exit_code != null ? " (exit " + item.exit_code + ")" : "") + '</div>' +
118
+ '<pre>' + escapeHtml(item.command || "") + escapeHtml(out) + '</pre>',
119
+ "command_execution" + (status === "failed" ? " failed" : ""),
120
+ );
121
+ }
122
+ if (item.type === "reasoning") {
123
+ return appendEvent('<div class="meta">reasoning</div>' + escapeHtml(item.text || ""), "reasoning");
124
+ }
125
+ return appendEvent('<div class="meta">' + escapeHtml(item.type || "item") + '</div><pre>' + escapeHtml(JSON.stringify(item, null, 2)) + '</pre>', "item");
126
+ }
127
+
128
+ let ws = null;
129
+ let pendingId = 1;
130
+
131
+ function send(req) {
132
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
133
+ ws.send(JSON.stringify(req));
134
+ }
135
+
136
+ function connect(threadId) {
137
+ const apiKey = getApiKey();
138
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
139
+ const url = proto + "//" + location.host + "/api/codex-relay/web/" + encodeURIComponent(threadId) + "?token=" + encodeURIComponent(apiKey);
140
+ ws = new WebSocket(url);
141
+
142
+ ws.addEventListener("open", () => {
143
+ setPresence("online");
144
+ appendEvent("connected. open this thread in Codex on your Mac if it's not already.", "system");
145
+ });
146
+
147
+ ws.addEventListener("close", (ev) => {
148
+ setPresence("offline");
149
+ appendEvent("disconnected (code " + ev.code + ")", "system");
150
+ });
151
+
152
+ ws.addEventListener("error", () => {
153
+ setPresence("offline");
154
+ });
155
+
156
+ ws.addEventListener("message", (ev) => {
157
+ let msg;
158
+ try { msg = JSON.parse(ev.data); } catch { return; }
159
+ if (msg.type === "session.started") {
160
+ // Daemon assigned a temp id; the real thread id will arrive in thread.started.
161
+ return;
162
+ }
163
+ if (msg.type === "session.event") {
164
+ const evt = msg.event || {};
165
+ if (evt.type === "thread.started") {
166
+ // ok
167
+ return;
168
+ }
169
+ if (evt.type === "turn.started") {
170
+ document.getElementById("stopBtn").disabled = false;
171
+ return;
172
+ }
173
+ if (evt.type === "item.completed" && evt.item) {
174
+ renderItem(evt.item);
175
+ return;
176
+ }
177
+ if (evt.type === "item.started") {
178
+ return; // skip; we render on completed for now
179
+ }
180
+ if (evt.type === "turn.completed") {
181
+ document.getElementById("stopBtn").disabled = true;
182
+ const u = evt.usage;
183
+ if (u) appendEvent("turn complete (" + (u.input_tokens || 0) + " in / " + (u.output_tokens || 0) + " out)", "system");
184
+ else appendEvent("turn complete", "system");
185
+ return;
186
+ }
187
+ if (evt.type === "turn.failed") {
188
+ document.getElementById("stopBtn").disabled = true;
189
+ appendEvent("turn failed: " + (evt.error && evt.error.message ? evt.error.message : "unknown"), "error");
190
+ return;
191
+ }
192
+ return;
193
+ }
194
+ if (msg.type === "ack") return;
195
+ if (msg.type === "error") {
196
+ appendEvent("error: " + (msg.message || ""), "error");
197
+ return;
198
+ }
199
+ });
200
+ }
201
+
202
+ function init() {
203
+ if (!ensureSignedIn()) return;
204
+ const parsed = parsePath();
205
+ if (!parsed) {
206
+ appendEvent("Invalid URL. Expected /<handle>/codex-remote-control/<thread-id>.", "error");
207
+ return;
208
+ }
209
+ document.getElementById("threadId").textContent = parsed.threadId;
210
+ connect(parsed.threadId);
211
+
212
+ // Open or attach to the session on the daemon. session.start returns a temp
213
+ // sessionId; the actual thread.id arrives via thread.started in the stream.
214
+ setTimeout(() => {
215
+ send({ type: "session.start", id: "open-" + (pendingId += 1) });
216
+ }, 250);
217
+
218
+ document.getElementById("composer").addEventListener("submit", (ev) => {
219
+ ev.preventDefault();
220
+ const input = document.getElementById("prompt");
221
+ const text = input.value.trim();
222
+ if (!text) return;
223
+ input.value = "";
224
+ appendEvent('<div class="meta">you</div>' + escapeHtml(text), "user");
225
+ send({
226
+ type: "session.send",
227
+ id: "send-" + (pendingId += 1),
228
+ sessionId: parsed.threadId,
229
+ prompt: text,
230
+ });
231
+ });
232
+
233
+ document.getElementById("stopBtn").addEventListener("click", () => {
234
+ send({ type: "session.interrupt", id: "stop-" + (pendingId += 1), sessionId: parsed.threadId });
235
+ });
236
+
237
+ // Submit on Cmd+Enter / Ctrl+Enter; auto-resize.
238
+ const ta = document.getElementById("prompt");
239
+ ta.addEventListener("input", () => {
240
+ ta.style.height = "auto";
241
+ ta.style.height = Math.min(120, ta.scrollHeight) + "px";
242
+ });
243
+ ta.addEventListener("keydown", (ev) => {
244
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
245
+ ev.preventDefault();
246
+ document.getElementById("composer").requestSubmit();
247
+ }
248
+ });
249
+ }
250
+
251
+ init();
252
+ </script>
253
+ </body>
254
+ </html>
@@ -0,0 +1,176 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Sign in ... wip.computer</title>
7
+ <style>
8
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
9
+ :root {
10
+ --bg: #FFFDF5;
11
+ --text: #1a1a1a;
12
+ --text-muted: #8a8580;
13
+ --accent: #0033FF;
14
+ --input-border: #E0DDD6;
15
+ --card-bg: #FFFFFF;
16
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
17
+ }
18
+ html, body { height: 100%; font-family: var(--font); background: var(--bg); color: var(--text); }
19
+ .page { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
20
+ .card { max-width: 380px; width: 100%; text-align: center; }
21
+ h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; }
22
+ .sub { font-size: 16px; color: var(--text-muted); margin-bottom: 24px; }
23
+ .btn {
24
+ display: block; width: 100%; padding: 18px;
25
+ border: none; border-radius: 12px;
26
+ font-size: 18px; font-weight: 600; font-family: var(--font);
27
+ cursor: pointer; transition: background 0.15s, transform 0.1s;
28
+ -webkit-tap-highlight-color: transparent;
29
+ }
30
+ .btn:active { transform: scale(0.98); }
31
+ .btn-primary { background: var(--accent); color: white; }
32
+ .btn-secondary { background: var(--card-bg); color: var(--text); border: 1px solid var(--input-border); }
33
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
34
+ .status { margin-top: 16px; font-size: 14px; min-height: 1.4em; color: var(--text-muted); }
35
+ .status.error { color: #b00020; }
36
+ .status.success { color: #007e33; }
37
+ .footer { margin-top: 24px; font-size: 13px; color: var(--text-muted); }
38
+
39
+ .qr-wrap {
40
+ margin: 12px auto;
41
+ padding: 16px; background: var(--card-bg);
42
+ border: 1px solid var(--input-border); border-radius: 16px;
43
+ width: 280px; height: 280px;
44
+ display: flex; align-items: center; justify-content: center;
45
+ }
46
+ .qr-wrap img { width: 100%; height: 100%; image-rendering: pixelated; }
47
+
48
+ /* Mobile: hide QR, passkey is the only path. */
49
+ @media (max-width: 700px) {
50
+ #qr-section { display: none; }
51
+ }
52
+ /* Desktop: passkey is the secondary action. */
53
+ @media (min-width: 701px) {
54
+ #passkey-section { margin-top: 20px; }
55
+ #passkey-section .or { font-size: 13px; color: var(--text-muted); margin-bottom: 10px; }
56
+ }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="page">
61
+ <div class="card">
62
+ <h1 id="title">Open on your phone</h1>
63
+ <div class="sub" id="subtitle">Scan with your phone to drive this session from your phone.</div>
64
+
65
+ <div id="qr-section">
66
+ <div class="qr-wrap"><img id="qrImg" alt="QR code"></div>
67
+ </div>
68
+
69
+ <div id="passkey-section">
70
+ <div class="or">or sign in on this device</div>
71
+ <button id="passkeyBtn" class="btn btn-secondary" type="button">Continue with passkey</button>
72
+ <div id="status" class="status"></div>
73
+ </div>
74
+
75
+ <div class="footer">No account? <a href="/signup">Create one</a></div>
76
+ </div>
77
+ </div>
78
+ <script>
79
+ function b64urlToBytes(b64url) {
80
+ const pad = "=".repeat((4 - (b64url.length % 4)) % 4);
81
+ const b64 = (b64url + pad).replace(/-/g, "+").replace(/_/g, "/");
82
+ const bin = atob(b64);
83
+ const out = new Uint8Array(bin.length);
84
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
85
+ return out.buffer;
86
+ }
87
+ function bytesToB64url(buf) {
88
+ const bytes = new Uint8Array(buf);
89
+ let s = "";
90
+ for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
91
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
92
+ }
93
+
94
+ const params = new URLSearchParams(location.search);
95
+ const NEXT = (() => {
96
+ const n = params.get("next");
97
+ return n && n.startsWith("/") ? n : "/pair";
98
+ })();
99
+
100
+ function setStatus(msg, kind) {
101
+ const el = document.getElementById("status");
102
+ el.textContent = msg;
103
+ el.className = "status" + (kind ? " " + kind : "");
104
+ }
105
+
106
+ // Already signed in? Skip the page entirely.
107
+ if (sessionStorage.getItem("wip_api_key")) {
108
+ location.replace(NEXT);
109
+ }
110
+
111
+ // QR encodes the absolute next URL. Phone scans, phone visits next URL.
112
+ // If phone has no api_key yet, the next page redirects back here on the
113
+ // phone, where the user signs in with passkey on the phone.
114
+ (function renderQr() {
115
+ const absoluteNext = location.origin + NEXT;
116
+ document.getElementById("qrImg").src = "/api/qr?url=" + encodeURIComponent(absoluteNext);
117
+ })();
118
+
119
+ async function signIn() {
120
+ const btn = document.getElementById("passkeyBtn");
121
+ btn.disabled = true;
122
+ setStatus("Waiting for biometric...");
123
+ try {
124
+ const optRes = await fetch("/webauthn/auth-options", {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: "{}",
128
+ });
129
+ const { challengeId, options } = await optRes.json();
130
+ if (!options) throw new Error("Server returned no auth options");
131
+ options.challenge = b64urlToBytes(options.challenge);
132
+ if (options.allowCredentials) {
133
+ options.allowCredentials = options.allowCredentials.map(c => ({ ...c, id: b64urlToBytes(c.id) }));
134
+ }
135
+ const credential = await navigator.credentials.get({ publicKey: options });
136
+ setStatus("Verifying...");
137
+ const verifyRes = await fetch("/webauthn/auth-verify", {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify({
141
+ challengeId,
142
+ credential: {
143
+ id: credential.id,
144
+ rawId: bytesToB64url(credential.rawId),
145
+ type: credential.type,
146
+ response: {
147
+ clientDataJSON: bytesToB64url(credential.response.clientDataJSON),
148
+ authenticatorData: bytesToB64url(credential.response.authenticatorData),
149
+ signature: bytesToB64url(credential.response.signature),
150
+ userHandle: credential.response.userHandle ? bytesToB64url(credential.response.userHandle) : null,
151
+ },
152
+ },
153
+ }),
154
+ });
155
+ const data = await verifyRes.json();
156
+ if (!verifyRes.ok || !data.success) throw new Error(data.error || "Sign-in failed");
157
+ sessionStorage.setItem("wip_api_key", data.apiKey);
158
+ sessionStorage.setItem("wip_handle", data.agentId);
159
+ setStatus("Signed in.", "success");
160
+ location.replace(NEXT);
161
+ } catch (err) {
162
+ setStatus(err.message || String(err), "error");
163
+ btn.disabled = false;
164
+ }
165
+ }
166
+
167
+ document.getElementById("passkeyBtn").addEventListener("click", signIn);
168
+
169
+ // Tighten copy on phone-sized viewports.
170
+ if (window.matchMedia("(max-width: 700px)").matches) {
171
+ document.getElementById("title").textContent = "Sign in";
172
+ document.getElementById("subtitle").textContent = "Use the passkey on this device.";
173
+ }
174
+ </script>
175
+ </body>
176
+ </html>
@@ -0,0 +1,118 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Pair codex-daemon ... wip.computer</title>
7
+ <style>
8
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
9
+ :root {
10
+ --bg: #FFFDF5;
11
+ --text: #1a1a1a;
12
+ --text-muted: #8a8580;
13
+ --accent: #0033FF;
14
+ --input-bg: #F5F3ED;
15
+ --input-border: #E0DDD6;
16
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
17
+ }
18
+ html, body { height: 100%; font-family: var(--font); background: var(--bg); color: var(--text); }
19
+ .page { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
20
+ .card { max-width: 420px; width: 100%; text-align: center; }
21
+ h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; }
22
+ .sub { font-size: 16px; color: var(--text-muted); margin-bottom: 32px; }
23
+ input[type="text"] {
24
+ width: 100%; padding: 18px; font-size: 22px; font-weight: 600;
25
+ text-align: center; letter-spacing: 0.4em;
26
+ border: 2px solid var(--input-border); border-radius: 12px;
27
+ background: var(--input-bg); color: var(--text);
28
+ text-transform: uppercase; font-family: ui-monospace, "SF Mono", monospace;
29
+ }
30
+ input[type="text"]:focus { border-color: var(--accent); outline: none; }
31
+ .btn { display: block; width: 100%; padding: 18px; border: none; border-radius: 12px; font-size: 18px; font-weight: 600; font-family: var(--font); cursor: pointer; margin-top: 16px; }
32
+ .btn:active { transform: scale(0.98); }
33
+ .btn-primary { background: var(--accent); color: white; }
34
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
35
+ .status { margin-top: 18px; font-size: 14px; min-height: 1.4em; color: var(--text-muted); }
36
+ .status.error { color: #b00020; }
37
+ .status.success { color: #007e33; }
38
+ .footer { margin-top: 32px; font-size: 13px; color: var(--text-muted); }
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <div class="page">
43
+ <div class="card">
44
+ <h1>Pair your Mac</h1>
45
+ <div class="sub">Type the code printed by <code>codex-daemon link</code></div>
46
+ <input id="codeInput" type="text" maxlength="6" autocomplete="off" autocapitalize="characters" inputmode="text" placeholder="ABC123">
47
+ <button id="pairBtn" class="btn btn-primary" type="button">Pair</button>
48
+ <div id="status" class="status"></div>
49
+ <div class="footer">Signed in as <span id="handle">...</span> ... <a href="#" id="signout">sign out</a></div>
50
+ </div>
51
+ </div>
52
+ <script>
53
+ function getApiKey() {
54
+ return sessionStorage.getItem("wip_api_key");
55
+ }
56
+ function getHandle() {
57
+ return sessionStorage.getItem("wip_handle") || "(unknown)";
58
+ }
59
+ function setStatus(msg, kind) {
60
+ const el = document.getElementById("status");
61
+ el.textContent = msg;
62
+ el.className = "status" + (kind ? " " + kind : "");
63
+ }
64
+ function ensureSignedIn() {
65
+ if (!getApiKey()) {
66
+ location.href = "/app/login.html?next=" + encodeURIComponent("/pair");
67
+ return false;
68
+ }
69
+ document.getElementById("handle").textContent = getHandle();
70
+ return true;
71
+ }
72
+
73
+ document.getElementById("signout").addEventListener("click", (ev) => {
74
+ ev.preventDefault();
75
+ sessionStorage.removeItem("wip_api_key");
76
+ sessionStorage.removeItem("wip_handle");
77
+ location.href = "/app/login.html?next=" + encodeURIComponent("/pair");
78
+ });
79
+
80
+ async function pair() {
81
+ const input = document.getElementById("codeInput");
82
+ const code = input.value.trim().toUpperCase();
83
+ if (code.length !== 6) {
84
+ setStatus("Enter the 6-character code shown on your Mac.", "error");
85
+ return;
86
+ }
87
+ const btn = document.getElementById("pairBtn");
88
+ btn.disabled = true;
89
+ setStatus("Pairing...");
90
+ try {
91
+ const res = await fetch("/api/codex-relay/pair-complete", {
92
+ method: "POST",
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ "Authorization": "Bearer " + getApiKey(),
96
+ },
97
+ body: JSON.stringify({ code }),
98
+ });
99
+ const data = await res.json();
100
+ if (!res.ok) throw new Error(data.error || "Pairing failed");
101
+ setStatus("Paired. Your Mac will pick this up in a few seconds.", "success");
102
+ btn.textContent = "Done";
103
+ } catch (err) {
104
+ setStatus(err.message || String(err), "error");
105
+ btn.disabled = false;
106
+ }
107
+ }
108
+
109
+ if (ensureSignedIn()) {
110
+ document.getElementById("pairBtn").addEventListener("click", pair);
111
+ document.getElementById("codeInput").addEventListener("keydown", (ev) => {
112
+ if (ev.key === "Enter") pair();
113
+ });
114
+ document.getElementById("codeInput").focus();
115
+ }
116
+ </script>
117
+ </body>
118
+ </html>
@@ -16,6 +16,94 @@ server {
16
16
  add_header X-Content-Type-Options "nosniff" always;
17
17
  add_header X-XSS-Protection "1; mode=block" always;
18
18
 
19
+ # ── Codex Remote Control relay ──
20
+ # The Node app at 127.0.0.1:18800 (wip-mcp/server.mjs) owns these.
21
+ # Without these blocks, nginx falls back to /index.html and the
22
+ # phone-side bootstrap + ws-ticket calls receive HTML, which breaks
23
+ # E2EE handshake and relay attach. See:
24
+ # wip-ldm-os-private/ai/product/plans-prds/codex-remote-control/
25
+
26
+ # HTTP routes: bootstrap (GET), ws-ticket (POST), state (GET),
27
+ # pair-init (POST), pair-status (GET), pair-complete (POST).
28
+ location /api/codex-relay/bootstrap/ {
29
+ proxy_pass http://127.0.0.1:18800;
30
+ proxy_http_version 1.1;
31
+ proxy_set_header Host $host;
32
+ proxy_set_header X-Real-IP $remote_addr;
33
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
34
+ proxy_set_header X-Forwarded-Proto $scheme;
35
+ }
36
+ location /api/codex-relay/ws-ticket {
37
+ proxy_pass http://127.0.0.1:18800;
38
+ proxy_http_version 1.1;
39
+ proxy_set_header Host $host;
40
+ proxy_set_header X-Real-IP $remote_addr;
41
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
42
+ proxy_set_header X-Forwarded-Proto $scheme;
43
+ }
44
+ location /api/codex-relay/state {
45
+ proxy_pass http://127.0.0.1:18800;
46
+ proxy_http_version 1.1;
47
+ proxy_set_header Host $host;
48
+ proxy_set_header X-Real-IP $remote_addr;
49
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
50
+ proxy_set_header X-Forwarded-Proto $scheme;
51
+ }
52
+ location /api/codex-relay/pair-init {
53
+ proxy_pass http://127.0.0.1:18800;
54
+ proxy_http_version 1.1;
55
+ proxy_set_header Host $host;
56
+ proxy_set_header X-Real-IP $remote_addr;
57
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
58
+ proxy_set_header X-Forwarded-Proto $scheme;
59
+ }
60
+ location /api/codex-relay/pair-status/ {
61
+ proxy_pass http://127.0.0.1:18800;
62
+ proxy_http_version 1.1;
63
+ proxy_set_header Host $host;
64
+ proxy_set_header X-Real-IP $remote_addr;
65
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
66
+ proxy_set_header X-Forwarded-Proto $scheme;
67
+ }
68
+ location /api/codex-relay/pair-complete {
69
+ proxy_pass http://127.0.0.1:18800;
70
+ proxy_http_version 1.1;
71
+ proxy_set_header Host $host;
72
+ proxy_set_header X-Real-IP $remote_addr;
73
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
74
+ proxy_set_header X-Forwarded-Proto $scheme;
75
+ }
76
+
77
+ # WebSocket routes: phone (web) and daemon long-lived sockets.
78
+ # Upgrade headers are the standard WebSocket-via-nginx pattern.
79
+ # Long read timeout because daemon sockets stay open indefinitely.
80
+ location /api/codex-relay/web/ {
81
+ proxy_pass http://127.0.0.1:18800;
82
+ proxy_http_version 1.1;
83
+ proxy_set_header Upgrade $http_upgrade;
84
+ proxy_set_header Connection "upgrade";
85
+ proxy_set_header Host $host;
86
+ proxy_set_header X-Real-IP $remote_addr;
87
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
88
+ proxy_set_header X-Forwarded-Proto $scheme;
89
+ proxy_read_timeout 86400;
90
+ proxy_send_timeout 86400;
91
+ proxy_buffering off;
92
+ }
93
+ location /api/codex-relay/daemon {
94
+ proxy_pass http://127.0.0.1:18800;
95
+ proxy_http_version 1.1;
96
+ proxy_set_header Upgrade $http_upgrade;
97
+ proxy_set_header Connection "upgrade";
98
+ proxy_set_header Host $host;
99
+ proxy_set_header X-Real-IP $remote_addr;
100
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
101
+ proxy_set_header X-Forwarded-Proto $scheme;
102
+ proxy_read_timeout 86400;
103
+ proxy_send_timeout 86400;
104
+ proxy_buffering off;
105
+ }
106
+
19
107
  location ~ /\. {
20
108
  deny all;
21
109
  }
@@ -13,6 +13,7 @@
13
13
  "@simplewebauthn/server": "^13.3.0",
14
14
  "prisma": "^6.19.3",
15
15
  "qrcode": "^1.5.4",
16
+ "ws": "^8.18.0",
16
17
  "zod": "^3.25.0"
17
18
  },
18
19
  "engines": {
@@ -2029,6 +2030,27 @@
2029
2030
  "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
2030
2031
  "license": "ISC"
2031
2032
  },
2033
+ "node_modules/ws": {
2034
+ "version": "8.20.0",
2035
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
2036
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
2037
+ "license": "MIT",
2038
+ "engines": {
2039
+ "node": ">=10.0.0"
2040
+ },
2041
+ "peerDependencies": {
2042
+ "bufferutil": "^4.0.1",
2043
+ "utf-8-validate": ">=5.0.2"
2044
+ },
2045
+ "peerDependenciesMeta": {
2046
+ "bufferutil": {
2047
+ "optional": true
2048
+ },
2049
+ "utf-8-validate": {
2050
+ "optional": true
2051
+ }
2052
+ }
2053
+ },
2032
2054
  "node_modules/y18n": {
2033
2055
  "version": "4.0.3",
2034
2056
  "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",