@wipcomputer/wip-ldm-os 0.4.82-alpha.1 → 0.4.83
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/README.md +2 -0
- package/SKILL.md +1 -1
- package/bin/ldm.js +140 -0
- package/docs/skills/README.md +2 -0
- package/lib/bin-manifest.mjs +257 -0
- package/package.json +35 -2
- package/scripts/test-bin-manifest.mjs +282 -0
- package/scripts/test-doctor-cron-target.mjs +172 -0
- package/scripts/test-ldm-install-preserves-foreign-bin.mjs +112 -0
- package/scripts/validate-bin-manifest.mjs +41 -0
- package/src/hosted-mcp/app/codex-remote-control/index.html +254 -0
- package/src/hosted-mcp/app/login.html +176 -0
- package/src/hosted-mcp/app/pair.html +118 -0
- package/src/hosted-mcp/package-lock.json +22 -0
- package/src/hosted-mcp/package.json +1 -0
- package/src/hosted-mcp/server.mjs +418 -10
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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>
|
|
@@ -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",
|