@wipcomputer/wip-ldm-os 0.4.81 → 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 +152 -14
- package/docs/skills/README.md +2 -0
- package/lib/bin-manifest.mjs +257 -0
- package/package.json +36 -2
- package/scripts/test-bin-manifest.mjs +282 -0
- package/scripts/test-doctor-cron-target.mjs +172 -0
- package/scripts/test-installer-update-tracks.mjs +39 -0
- package/scripts/test-ldm-install-preserves-foreign-bin.mjs +112 -0
- package/scripts/validate-bin-manifest.mjs +41 -0
- package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +3 -1
- package/shared/docs/how-install-works.md.tmpl +2 -0
- package/shared/docs/how-releases-work.md.tmpl +9 -3
- package/shared/rules/release-pipeline.md +4 -2
- 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
|
@@ -29,11 +29,13 @@ cd /path/to/repo && git checkout main && git pull
|
|
|
29
29
|
# 4. Alpha release
|
|
30
30
|
wip-release alpha --notes="what changed"
|
|
31
31
|
|
|
32
|
-
#
|
|
32
|
+
# 5. Agent prerelease validation
|
|
33
33
|
ldm install --alpha
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Done. No deploy-public. No public repo sync. The installer pulls from npm @alpha (or finds the local private repo if offline).
|
|
36
|
+
Done. No deploy-public. No public repo sync. The installer pulls from npm @alpha (or finds the local private repo if offline). Agents are expected to run alpha installs when validating prereleases.
|
|
37
|
+
|
|
38
|
+
Beta follows the same rule with `wip-release beta` and `ldm install --beta`. Agents validate alpha and beta tracks. Parker dogfoods stable/latest releases.
|
|
37
39
|
|
|
38
40
|
**Sub-tool versions:** If you changed a sub-tool's code, bump its `package.json` version in the PR. Same version = same code. `wip-release` warns if files changed without a version bump.
|
|
39
41
|
|
|
@@ -53,13 +55,15 @@ wip-release patch # auto-detects release notes
|
|
|
53
55
|
deploy-public /path/to/private-repo org/public-repo
|
|
54
56
|
|
|
55
57
|
# 7. Dogfood
|
|
56
|
-
|
|
58
|
+
Read https://wip.computer/install/wip-ldm-os.txt
|
|
57
59
|
```
|
|
58
60
|
|
|
59
61
|
`wip-release` handles: version bump, CHANGELOG.md, SKILL.md sync, npm publish, GitHub release.
|
|
60
62
|
|
|
61
63
|
`deploy-public` syncs everything except `ai/` to the public repo. Creates a matching release.
|
|
62
64
|
|
|
65
|
+
For stable/latest releases, agents stop after publish and public sync. Do not run `ldm install` or `npm install -g` unless Parker explicitly asks. Parker dogfoods the release through the install prompt.
|
|
66
|
+
|
|
63
67
|
## Quality Gates
|
|
64
68
|
|
|
65
69
|
`wip-release` enforces before publishing (stable only):
|
|
@@ -91,3 +95,5 @@ Same version = same code. Always. If code changed, the version must change. The
|
|
|
91
95
|
| Install | `ldm install [--alpha/--beta]` | Extensions updated on your machine. |
|
|
92
96
|
|
|
93
97
|
For stable releases, add a fourth step: `deploy-public` to sync the public GitHub repo.
|
|
98
|
+
|
|
99
|
+
Ownership rule: agents install alpha and beta for validation. Parker installs stable/latest releases for dogfooding unless he explicitly asks an agent to do it.
|
|
@@ -21,7 +21,9 @@ Then: repo change, PR, merge, release, `ldm install`. That's the only path.
|
|
|
21
21
|
| **Deploy** | wip-release + deploy-public.sh | Published to npm + GitHub. Not on your machine yet. |
|
|
22
22
|
| **Install** | Run the install prompt | Extensions updated on your machine. Only when Parker says "install." |
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
For alpha and beta tracks, agents install prereleases for validation: `ldm install --alpha` or `ldm install --beta`. That is test work, not owner dogfooding.
|
|
25
|
+
|
|
26
|
+
For stable/latest releases, after Deploy, STOP. Do not copy files. Do not npm install -g. Do not npm link. Do not run `ldm install` unless Parker explicitly asks. Parker dogfoods stable releases through the install prompt.
|
|
25
27
|
|
|
26
28
|
## The workflow
|
|
27
29
|
|
|
@@ -31,7 +33,7 @@ After Deploy, STOP. Do not copy files. Do not npm install -g. Do not npm link. D
|
|
|
31
33
|
4. `git checkout main && git pull`
|
|
32
34
|
5. `wip-release patch` (auto-detects release notes)
|
|
33
35
|
6. `deploy-public.sh` to sync public repo
|
|
34
|
-
7.
|
|
36
|
+
7. Stop. Parker dogfoods: `Read https://wip.computer/install/wip-ldm-os.txt`
|
|
35
37
|
|
|
36
38
|
## Never run tools from repo clones
|
|
37
39
|
|
|
@@ -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",
|