clay-server 4.7.1-beta.5 → 4.8.0-beta.1
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/bin/login-browser-open.js +7 -0
- package/lib/project-vendor-login.js +52 -18
- package/lib/project.js +3 -0
- package/lib/public/app.js +1 -0
- package/lib/public/css/vendor-login-browser.css +63 -0
- package/lib/public/modules/app-messages.js +5 -0
- package/lib/public/modules/app-notifications.js +1 -1
- package/lib/public/modules/vendor-login-browser.js +243 -0
- package/lib/public/modules/vendor-login.js +23 -4
- package/lib/public/style.css +1 -0
- package/lib/terminal.js +4 -0
- package/lib/vendor-login-browser-flow.js +46 -0
- package/lib/vendor-login-browser-passkeys.js +27 -0
- package/lib/vendor-login-browser-worker.js +149 -0
- package/lib/vendor-login-browser.js +162 -0
- package/lib/ws-schema.js +4 -0
- package/package.json +2 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Capture the CLI's exact OAuth URL without opening a browser on the host desktop.
|
|
3
|
+
var fs = require("fs");
|
|
4
|
+
var file = process.env.CLAY_LOGIN_URL_FILE;
|
|
5
|
+
var url = process.argv[2];
|
|
6
|
+
if (!file || !url || url.length > 16384) process.exit(1);
|
|
7
|
+
fs.writeFileSync(file, url, { mode: 0o600 });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Vendor login flow coordinator.
|
|
2
2
|
//
|
|
3
3
|
// Owns the "the vendor CLI is not logged in" recovery path end to end so the
|
|
4
|
-
// client never has to guess. One login terminal per vendor per
|
|
4
|
+
// client never has to guess. One login terminal per vendor per requester is the
|
|
5
5
|
// single source of truth: repeated auth_required events (other sessions, split
|
|
6
6
|
// panes, a burst of 401 retries) re-use that record instead of spawning more
|
|
7
7
|
// terminals.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// command, reply with vendor_login_ready
|
|
12
12
|
// PTY output/exit -> detect a successful login, restart the vendor's
|
|
13
13
|
// YOKE adapter so it re-reads the credential file,
|
|
14
|
-
//
|
|
14
|
+
// send auth_refreshed, close the terminal and browser
|
|
15
15
|
// vendor_login_cancel -> user dismissed the flow; kill the terminal
|
|
16
16
|
//
|
|
17
17
|
// The adapter restart is the actual bug fix: a Codex app-server is a long-lived
|
|
@@ -84,10 +84,20 @@ function attachVendorLogin(ctx) {
|
|
|
84
84
|
var getLinuxUserForSession = ctx.getLinuxUserForSession;
|
|
85
85
|
var refreshVendorAuthEverywhere = ctx.refreshVendorAuthEverywhere;
|
|
86
86
|
|
|
87
|
-
// vendor -> flow
|
|
87
|
+
// Requester + vendor -> flow. Credentials and sign-in views stay user-bound.
|
|
88
88
|
var _flows = Object.create(null);
|
|
89
|
+
function flowKey(ws, vendor) {
|
|
90
|
+
return JSON.stringify([ws && ws._clayUser ? String(ws._clayUser.id) : null, vendor]);
|
|
91
|
+
}
|
|
92
|
+
var browserLogin = require("./vendor-login-browser-flow").attachBrowserLogin({
|
|
93
|
+
osUsers: osUsers, sendTo: sendTo, hasTerminalPermission: hasTerminalPermission,
|
|
94
|
+
createLoginBrowser: ctx.createLoginBrowser,
|
|
95
|
+
flowKey: flowKey,
|
|
96
|
+
cancel: function (vendor) { closeFlowTerminal(discardFlow(vendor)); broadcastState(); },
|
|
97
|
+
});
|
|
89
98
|
|
|
90
99
|
function loginCommandFor(vendor) {
|
|
100
|
+
if (vendor === "claude") return "claude auth login";
|
|
91
101
|
var info = vendorRegistry.getVendorInfo(vendor);
|
|
92
102
|
return (info && info.loginCommand) || "claude login";
|
|
93
103
|
}
|
|
@@ -97,30 +107,34 @@ function attachVendorLogin(ctx) {
|
|
|
97
107
|
return (info && info.displayName) || vendor || "Vendor";
|
|
98
108
|
}
|
|
99
109
|
|
|
100
|
-
function listFlows() {
|
|
110
|
+
function listFlows(ws) {
|
|
101
111
|
var result = [];
|
|
102
112
|
var vendors = Object.keys(_flows);
|
|
103
113
|
for (var i = 0; i < vendors.length; i++) {
|
|
104
114
|
var flow = _flows[vendors[i]];
|
|
115
|
+
if (ws && flowKey(ws, flow.vendor) !== vendors[i]) continue;
|
|
105
116
|
result.push({
|
|
106
117
|
vendor: flow.vendor,
|
|
107
118
|
terminalId: flow.terminalId,
|
|
108
119
|
startedAt: flow.startedAt,
|
|
109
120
|
completed: !!flow.completed,
|
|
121
|
+
browserId: flow.browser ? flow.browser.id : null,
|
|
110
122
|
});
|
|
111
123
|
}
|
|
112
124
|
return result;
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
function broadcastState() {
|
|
128
|
+
if (ctx.clients) { ctx.clients.forEach(sendStateTo); return; }
|
|
116
129
|
send({ type: "vendor_login_state", slug: slug, flows: listFlows() });
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
function sendStateTo(ws) {
|
|
120
|
-
sendTo(ws, { type: "vendor_login_state", slug: slug, flows: listFlows() });
|
|
133
|
+
sendTo(ws, { type: "vendor_login_state", slug: slug, flows: hasTerminalPermission(ws) ? listFlows(ws) : [] });
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
function hasTerminalPermission(ws) {
|
|
137
|
+
if (ctx.requestAccess) return ctx.requestAccess.canAccessProject(ws, slug) && ctx.requestAccess.hasPermission(ws, "terminal");
|
|
124
138
|
if (!ws || !ws._clayUser) return true;
|
|
125
139
|
if (!usersModule || typeof usersModule.getEffectivePermissions !== "function") return true;
|
|
126
140
|
var perms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
|
|
@@ -164,6 +178,8 @@ function attachVendorLogin(ctx) {
|
|
|
164
178
|
var flow = _flows[vendor];
|
|
165
179
|
if (!flow) return null;
|
|
166
180
|
delete _flows[vendor];
|
|
181
|
+
if (flow.browser) flow.browser.close();
|
|
182
|
+
if (flow.expiryTimer) clearTimeout(flow.expiryTimer);
|
|
167
183
|
if (flow.closeTimer) {
|
|
168
184
|
clearTimeout(flow.closeTimer);
|
|
169
185
|
flow.closeTimer = null;
|
|
@@ -223,14 +239,17 @@ function attachVendorLogin(ctx) {
|
|
|
223
239
|
var refresh = Promise.resolve(false);
|
|
224
240
|
if (succeeded) {
|
|
225
241
|
refresh = typeof refreshVendorAuthEverywhere === "function"
|
|
226
|
-
? refreshVendorAuthEverywhere(vendor, flow.linuxUser || null)
|
|
227
|
-
: refreshVendorAuth(vendor, flow.linuxUser || null);
|
|
242
|
+
? refreshVendorAuthEverywhere(flow.vendor, flow.linuxUser || null)
|
|
243
|
+
: refreshVendorAuth(flow.vendor, flow.linuxUser || null);
|
|
228
244
|
}
|
|
229
245
|
refresh.then(function (restarted) {
|
|
246
|
+
if (_flows[vendor] !== flow) return;
|
|
230
247
|
discardFlow(vendor);
|
|
231
248
|
closeFlowTerminal(flow);
|
|
232
249
|
if (succeeded) {
|
|
233
|
-
|
|
250
|
+
var message = { type: "auth_refreshed", slug: slug, vendor: flow.vendor, adapterRestarted: !!restarted };
|
|
251
|
+
if (ctx.clients) ctx.clients.forEach(function (ws) { if (flowKey(ws, flow.vendor) === vendor && hasTerminalPermission(ws)) sendTo(ws, message); });
|
|
252
|
+
else send(message);
|
|
234
253
|
}
|
|
235
254
|
broadcastState();
|
|
236
255
|
});
|
|
@@ -240,10 +259,11 @@ function attachVendorLogin(ctx) {
|
|
|
240
259
|
var flow = _flows[vendor];
|
|
241
260
|
if (!flow || flow.finishing || flow.succeeded) return;
|
|
242
261
|
flow.outputTail = (flow.outputTail + stripAnsi(chunk)).slice(-OUTPUT_TAIL_MAX);
|
|
262
|
+
if (flow.browser) flow.browser.output(stripAnsi(chunk));
|
|
243
263
|
if (!looksLikeLoginSuccess(flow.outputTail)) return;
|
|
244
264
|
|
|
245
265
|
flow.succeeded = true;
|
|
246
|
-
console.log("[vendor-login] Detected successful " + vendor + " login in " + slug);
|
|
266
|
+
console.log("[vendor-login] Detected successful " + flow.vendor + " login in " + slug);
|
|
247
267
|
// Let the success line land on screen before the terminal disappears.
|
|
248
268
|
flow.closeTimer = setTimeout(function () {
|
|
249
269
|
finishFlow(vendor, true);
|
|
@@ -261,10 +281,15 @@ function attachVendorLogin(ctx) {
|
|
|
261
281
|
}
|
|
262
282
|
|
|
263
283
|
function startFlow(ws, vendor, msg) {
|
|
284
|
+
var key = flowKey(ws, vendor);
|
|
264
285
|
var identity = resolveLoginIdentity(ws, msg);
|
|
265
286
|
var command = loginCommandFor(vendor);
|
|
287
|
+
var browserMode = msg.mode === "browser" && (vendor === "claude" || vendor === "codex");
|
|
288
|
+
if (browserMode) command = vendor === "codex" ? "codex login" : "claude auth login";
|
|
266
289
|
var flow = {
|
|
267
290
|
vendor: vendor,
|
|
291
|
+
key: key,
|
|
292
|
+
ownerId: ws && ws._clayUser ? String(ws._clayUser.id) : null,
|
|
268
293
|
terminalId: null,
|
|
269
294
|
startedAt: Date.now(),
|
|
270
295
|
linuxUser: identity.linuxUser,
|
|
@@ -277,16 +302,18 @@ function attachVendorLogin(ctx) {
|
|
|
277
302
|
};
|
|
278
303
|
// Registered before create() so the PTY's first output chunk (hooks fire
|
|
279
304
|
// synchronously from spawn) already finds the record.
|
|
280
|
-
_flows[
|
|
305
|
+
_flows[key] = flow;
|
|
281
306
|
|
|
282
307
|
var terminal = null;
|
|
283
308
|
try {
|
|
309
|
+
if (browserMode) browserLogin.prepare(ws, flow, identity);
|
|
284
310
|
terminal = tm.create(100, 30, identity.osUserInfo, ws, {
|
|
285
311
|
initialInput: command + "\n",
|
|
286
312
|
title: vendorDisplayName(vendor) + " login",
|
|
287
313
|
kind: "vendor-login",
|
|
288
|
-
|
|
289
|
-
|
|
314
|
+
loginBrowserEnv: flow.browser ? flow.browser.env : null,
|
|
315
|
+
onData: function (data) { if (_flows[key] === flow) handleFlowOutput(key, data); },
|
|
316
|
+
onExit: function () { if (_flows[key] === flow) handleFlowExit(key); },
|
|
290
317
|
});
|
|
291
318
|
} catch (e) {
|
|
292
319
|
console.error("[vendor-login] Failed to spawn " + vendor + " login terminal in " + slug + ":",
|
|
@@ -294,17 +321,18 @@ function attachVendorLogin(ctx) {
|
|
|
294
321
|
}
|
|
295
322
|
|
|
296
323
|
if (!terminal) {
|
|
297
|
-
|
|
324
|
+
discardFlow(key);
|
|
298
325
|
return null;
|
|
299
326
|
}
|
|
300
327
|
|
|
301
328
|
flow.terminalId = terminal.id;
|
|
302
|
-
tm.attach(terminal.id, ws);
|
|
329
|
+
if (!flow.browser) tm.attach(terminal.id, ws);
|
|
303
330
|
send({ type: "term_list", terminals: tm.list() });
|
|
304
331
|
return flow;
|
|
305
332
|
}
|
|
306
333
|
|
|
307
334
|
function handleVendorLoginMessage(ws, msg) {
|
|
335
|
+
if (browserLogin.handle(ws, msg, _flows)) return true;
|
|
308
336
|
if (msg.type === "vendor_login_state_request") {
|
|
309
337
|
sendStateTo(ws);
|
|
310
338
|
return true;
|
|
@@ -312,7 +340,7 @@ function attachVendorLogin(ctx) {
|
|
|
312
340
|
|
|
313
341
|
if (msg.type === "vendor_login_cancel") {
|
|
314
342
|
var cancelVendor = String(msg.vendor || "");
|
|
315
|
-
var cancelled = discardFlow(cancelVendor);
|
|
343
|
+
var cancelled = discardFlow(flowKey(ws, cancelVendor));
|
|
316
344
|
if (cancelled) closeFlowTerminal(cancelled);
|
|
317
345
|
broadcastState();
|
|
318
346
|
return true;
|
|
@@ -329,10 +357,11 @@ function attachVendorLogin(ctx) {
|
|
|
329
357
|
return true;
|
|
330
358
|
}
|
|
331
359
|
|
|
332
|
-
var
|
|
360
|
+
var key = flowKey(ws, vendor);
|
|
361
|
+
var existing = _flows[key];
|
|
333
362
|
if (existing && !isFlowTerminalAlive(existing)) {
|
|
334
363
|
// Terminal died without its exit hook clearing the record.
|
|
335
|
-
discardFlow(
|
|
364
|
+
discardFlow(key);
|
|
336
365
|
existing = null;
|
|
337
366
|
}
|
|
338
367
|
|
|
@@ -341,13 +370,14 @@ function attachVendorLogin(ctx) {
|
|
|
341
370
|
// session or split pane) must not pop a second prompt; it only learns
|
|
342
371
|
// that a flow is already running. A deliberate request re-attaches.
|
|
343
372
|
if (!msg.auto) {
|
|
344
|
-
tm.attach(existing.terminalId, ws);
|
|
373
|
+
if (!existing.browser) tm.attach(existing.terminalId, ws);
|
|
345
374
|
sendTo(ws, {
|
|
346
375
|
type: "vendor_login_ready",
|
|
347
376
|
slug: slug,
|
|
348
377
|
vendor: vendor,
|
|
349
378
|
terminalId: existing.terminalId,
|
|
350
379
|
reused: true,
|
|
380
|
+
browserId: existing.browser ? existing.browser.id : null,
|
|
351
381
|
});
|
|
352
382
|
}
|
|
353
383
|
sendStateTo(ws);
|
|
@@ -371,6 +401,7 @@ function attachVendorLogin(ctx) {
|
|
|
371
401
|
vendor: vendor,
|
|
372
402
|
terminalId: started.terminalId,
|
|
373
403
|
reused: false,
|
|
404
|
+
browserId: started.browser ? started.browser.id : null,
|
|
374
405
|
});
|
|
375
406
|
broadcastState();
|
|
376
407
|
return true;
|
|
@@ -408,6 +439,9 @@ function attachVendorLogin(ctx) {
|
|
|
408
439
|
forgetTerminal: forgetTerminal,
|
|
409
440
|
isLoginTerminal: isLoginTerminal,
|
|
410
441
|
refreshVendorAuth: refreshVendorAuth,
|
|
442
|
+
destroy: function () {
|
|
443
|
+
Object.keys(_flows).forEach(function (vendor) { closeFlowTerminal(discardFlow(vendor)); });
|
|
444
|
+
},
|
|
411
445
|
};
|
|
412
446
|
}
|
|
413
447
|
|
package/lib/project.js
CHANGED
|
@@ -2204,6 +2204,8 @@ function createProjectContext(opts) {
|
|
|
2204
2204
|
|
|
2205
2205
|
// --- Vendor login flow (delegated to project-vendor-login.js) ---
|
|
2206
2206
|
var _vendorLogin = attachVendorLogin({
|
|
2207
|
+
requestAccess: _requestAccess,
|
|
2208
|
+
clients: clients,
|
|
2207
2209
|
slug: slug,
|
|
2208
2210
|
osUsers: osUsers,
|
|
2209
2211
|
sm: sm,
|
|
@@ -2600,6 +2602,7 @@ function createProjectContext(opts) {
|
|
|
2600
2602
|
|
|
2601
2603
|
// --- Destroy ---
|
|
2602
2604
|
function destroy() {
|
|
2605
|
+
_vendorLogin.destroy();
|
|
2603
2606
|
if (_scheduledMessages) _scheduledMessages.shutdown();
|
|
2604
2607
|
if (_autonomousRun) _autonomousRun.shutdown();
|
|
2605
2608
|
_splitGroups.shutdown();
|
package/lib/public/app.js
CHANGED
|
@@ -268,6 +268,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
|
|
|
268
268
|
// --- Initialize store (single source of truth for client state) ---
|
|
269
269
|
createStore({
|
|
270
270
|
networkSettings: { loaded: false, busy: false, confirming: false, draft: "", saved: "", status: "", errorKind: "", requestId: 0 },
|
|
271
|
+
vendorLoginBrowser: null,
|
|
271
272
|
// session / routing
|
|
272
273
|
basePath: basePath,
|
|
273
274
|
wsPath: wsPath,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
.login-browser {
|
|
2
|
+
width: min(820px, calc(100vw - 48px));
|
|
3
|
+
height: min(780px, calc(100dvh - 48px));
|
|
4
|
+
max-width: none;
|
|
5
|
+
max-height: none;
|
|
6
|
+
padding: 0;
|
|
7
|
+
margin: auto;
|
|
8
|
+
overflow: hidden;
|
|
9
|
+
border: 1px solid var(--border);
|
|
10
|
+
border-radius: 16px;
|
|
11
|
+
background: var(--bg);
|
|
12
|
+
color: var(--text);
|
|
13
|
+
box-shadow: 0 32px 100px #0005, 0 4px 16px #0003;
|
|
14
|
+
}
|
|
15
|
+
.login-browser[open] { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; }
|
|
16
|
+
.login-browser::backdrop { background: #0007; backdrop-filter: blur(6px); }
|
|
17
|
+
.login-browser-header { display: flex; align-items: center; gap: 12px; padding: 17px 20px; border-bottom: 1px solid var(--border); }
|
|
18
|
+
.login-browser-vendor { width: 34px; height: 34px; border-radius: 9px; object-fit: cover; }
|
|
19
|
+
.login-browser-heading { flex: 1; min-width: 0; }
|
|
20
|
+
.login-browser h2 { font-size: 15px; line-height: 1.4; font-weight: 600; letter-spacing: -.015em; margin: 0; }
|
|
21
|
+
.login-browser-origin { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; font-size: 11px; line-height: 1.5; color: var(--text-dimmer); }
|
|
22
|
+
.login-browser-actions { display: flex; align-items: center; gap: 3px; }
|
|
23
|
+
.login-browser button, .login-browser-more summary { font: inherit; cursor: pointer; color: var(--text-muted, var(--text)); }
|
|
24
|
+
.login-browser button { border: 0; background: transparent; }
|
|
25
|
+
.login-browser svg { width: 16px; height: 16px; flex-shrink: 0; stroke-width: 1.6; }
|
|
26
|
+
.login-browser .login-browser-icon, .login-browser-more summary { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; border-radius: 7px; }
|
|
27
|
+
.login-browser button:hover, .login-browser-more summary:hover { background: var(--bg-alt); color: var(--text); }
|
|
28
|
+
.login-browser button:focus-visible, .login-browser-more summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
29
|
+
.login-browser .login-browser-keyboard-button { display: none; }
|
|
30
|
+
.login-browser-more { position: relative; }
|
|
31
|
+
.login-browser-more summary { list-style: none; }
|
|
32
|
+
.login-browser-more summary::-webkit-details-marker { display: none; }
|
|
33
|
+
.login-browser-menu { position: absolute; right: 0; top: 39px; z-index: 3; width: 180px; padding: 5px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg); box-shadow: 0 8px 28px #0003; }
|
|
34
|
+
.login-browser-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 10px; border-radius: 6px; font-size: 12px; text-align: left; }
|
|
35
|
+
.login-browser-viewport { position: relative; min-height: 0; overflow: hidden; background: #fff; line-height: 0; }
|
|
36
|
+
.login-browser canvas { display: block; width: 100%; height: 100%; object-fit: contain; touch-action: none; }
|
|
37
|
+
.login-browser-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; background: var(--bg-alt); color: var(--text-dimmer); font-size: 12px; line-height: 1.5; }
|
|
38
|
+
.login-browser-loading strong { font-size: 14px; font-weight: 500; color: var(--text); }
|
|
39
|
+
.login-browser-loading[hidden] { display: none; }
|
|
40
|
+
.login-browser-spinner { width: 22px; height: 22px; margin-bottom: 8px; border: 2px solid var(--border); border-top-color: var(--text-dimmer); border-radius: 50%; animation: login-browser-spin .9s linear infinite; }
|
|
41
|
+
.login-browser-keyboard { position: absolute; width: 1px; height: 1px; padding: 0; border: 0; bottom: 0; left: 50%; opacity: 0; font-size: 16px; }
|
|
42
|
+
.login-browser-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--border); }
|
|
43
|
+
.login-browser-info { min-width: 0; }
|
|
44
|
+
.login-browser-status { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
45
|
+
.login-browser-status-dot { width: 5px; height: 5px; flex-shrink: 0; border-radius: 50%; background: var(--text-dimmer); }
|
|
46
|
+
.login-browser[data-phase="error"] .login-browser-status-dot { background: var(--warning, #d69c51); }
|
|
47
|
+
.login-browser-footer p { margin: 0; font-size: 11px; color: var(--text-dimmer); line-height: 1.5; }
|
|
48
|
+
.login-browser-footer .login-browser-privacy { margin-top: 5px; color: var(--text-muted, var(--text)); }
|
|
49
|
+
.login-browser .login-browser-terminal { display: inline-flex; align-items: center; justify-content: center; gap: 7px; flex-shrink: 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; font-size: 11px; font-weight: 500; color: var(--text); }
|
|
50
|
+
.login-browser-terminal svg:last-child { width: 12px; height: 12px; color: var(--text-dimmer); }
|
|
51
|
+
@keyframes login-browser-spin { to { transform: rotate(360deg); } }
|
|
52
|
+
@media (max-width: 600px) {
|
|
53
|
+
.login-browser { width: 100vw; height: 100dvh; margin: 0; border: 0; border-radius: 0; }
|
|
54
|
+
.login-browser-header { padding: max(12px, env(safe-area-inset-top)) 12px 12px; gap: 9px; }
|
|
55
|
+
.login-browser-vendor { width: 30px; height: 30px; border-radius: 8px; }
|
|
56
|
+
.login-browser h2 { font-size: 14px; }
|
|
57
|
+
.login-browser .login-browser-keyboard-button { display: flex; }
|
|
58
|
+
.login-browser-footer { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 12px max(12px, env(safe-area-inset-bottom)); }
|
|
59
|
+
.login-browser-status { justify-content: center; }
|
|
60
|
+
.login-browser-privacy { text-align: center; }
|
|
61
|
+
.login-browser .login-browser-terminal { min-height: 40px; font-size: 12px; }
|
|
62
|
+
}
|
|
63
|
+
@media (prefers-reduced-motion: reduce) { .login-browser-spinner { animation: none; } }
|
|
@@ -92,6 +92,7 @@ import { handleSkillInstallWs } from './app-skills-install.js';
|
|
|
92
92
|
import { handleNotificationsState, handleNotificationCreated, handleNotificationDismissed, handleNotificationDismissedAll, showUpdateBanner } from './app-notifications.js';
|
|
93
93
|
import { handleUpdateSnoozed } from './update-snooze.js';
|
|
94
94
|
import { autoStartLoginIfNeeded, handleVendorLoginReady, handleVendorLoginState, handleVendorLoginError, handleAuthRefreshed } from './vendor-login.js';
|
|
95
|
+
import { handleLoginBrowserMessage } from './vendor-login-browser.js';
|
|
95
96
|
import { handleDebatePreparing, handleDebateBriefReady, renderDebateBriefReady, handleDebateStarted, renderDebateStarted, handleDebateTurn, handleDebateActivity, handleDebateStream, handleDebateTurnDone, handleDebateCommentQueued, handleDebateCommentInjected, renderDebateCommentInjected, handleDebateResumed, handleDebateEnded, renderDebateEnded, handleDebateError, isDebateActive, renderMcpDebateProposal, renderDebateUserResume } from './debate.js';
|
|
96
97
|
import { handleMentionStart, handleMentionActivity, handleMentionStream, handleMentionDone, handleMentionError, renderMentionUser, renderMentionResponse, renderUserMention } from './mention.js';
|
|
97
98
|
|
|
@@ -103,6 +104,10 @@ var connectOverlay = document.getElementById("connect-overlay");
|
|
|
103
104
|
initMessageDeliveryUi();
|
|
104
105
|
|
|
105
106
|
export function processMessage(msg) {
|
|
107
|
+
if (msg.type === "vendor_login_browser_frame" || msg.type === "vendor_login_browser_status") {
|
|
108
|
+
handleLoginBrowserMessage(msg);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
106
111
|
if (handleIssuesMessage(msg)) return;
|
|
107
112
|
if (handleAutonomousRunMessage(msg)) return;
|
|
108
113
|
if (handleLoopInterviewMessage(msg)) return;
|
|
@@ -170,7 +170,7 @@ function showBanner(notif, autoDismissMs) {
|
|
|
170
170
|
} else if (isAuthRequired) {
|
|
171
171
|
actionsHtml =
|
|
172
172
|
'<div class="notif-banner-actions">' +
|
|
173
|
-
'<button class="notif-banner-auth-login notif-banner-update-now">
|
|
173
|
+
'<button class="notif-banner-auth-login notif-banner-update-now">Sign in</button>' +
|
|
174
174
|
'</div>';
|
|
175
175
|
} else if (isTuiAttention) {
|
|
176
176
|
actionsHtml =
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
import { getWs } from './ws-ref.js';
|
|
3
|
+
import { iconHtml, refreshIcons } from './icons.js';
|
|
4
|
+
import { cancelVendorLogin, requestVendorLogin } from './vendor-login.js';
|
|
5
|
+
|
|
6
|
+
function current() { return store.get('vendorLoginBrowser'); }
|
|
7
|
+
|
|
8
|
+
function send(type, extra) {
|
|
9
|
+
var view = current();
|
|
10
|
+
var ws = getWs();
|
|
11
|
+
if (!view || view.slug !== store.get('currentSlug') || !ws || ws.readyState !== 1) return;
|
|
12
|
+
ws.send(JSON.stringify(Object.assign({ type: type, vendor: view.vendor, browserId: view.browserId }, extra || {})));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function input(event) { send("vendor_login_browser_input", { event: event }); }
|
|
16
|
+
|
|
17
|
+
export function closeLoginBrowser() {
|
|
18
|
+
var view = current();
|
|
19
|
+
if (view && view.resizeObserver) view.resizeObserver.disconnect();
|
|
20
|
+
if (view && view.moveTimer) clearTimeout(view.moveTimer);
|
|
21
|
+
store.set({ vendorLoginBrowser: null });
|
|
22
|
+
var dialog = document.getElementById("vendor-login-browser");
|
|
23
|
+
if (dialog) { dialog.close(); dialog.remove(); }
|
|
24
|
+
if (view && view.previousFocus && view.previousFocus.isConnected) view.previousFocus.focus();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function openLoginBrowser(msg) {
|
|
28
|
+
var existing = current();
|
|
29
|
+
if (existing && existing.browserId === msg.browserId) {
|
|
30
|
+
send("vendor_login_browser_attach");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
closeLoginBrowser();
|
|
34
|
+
var title = msg.vendor === "codex" ? "Sign in to Codex" : "Sign in to Claude";
|
|
35
|
+
store.set({ vendorLoginBrowser: {
|
|
36
|
+
browserId: msg.browserId, vendor: msg.vendor, slug: msg.slug || store.get('currentSlug'),
|
|
37
|
+
previousFocus: document.activeElement, decoding: false, pointerY: null, pointerId: null, mouseDown: false, moveTimer: null, pendingMove: null, resizeObserver: null, requestedSize: null, phase: "loading",
|
|
38
|
+
} });
|
|
39
|
+
var dialog = document.createElement("dialog");
|
|
40
|
+
dialog.id = "vendor-login-browser";
|
|
41
|
+
dialog.className = "login-browser";
|
|
42
|
+
dialog.setAttribute("aria-labelledby", "login-browser-title");
|
|
43
|
+
dialog.innerHTML = '<header class="login-browser-header">' +
|
|
44
|
+
'<img class="login-browser-vendor" src="/' + (msg.vendor === "codex" ? 'codex-avatar.png' : 'claude-code-avatar.png') + '" alt="">' +
|
|
45
|
+
'<div class="login-browser-heading"><h2 id="login-browser-title"></h2>' +
|
|
46
|
+
'<span class="login-browser-origin">Opening sign-in page…</span></div>' +
|
|
47
|
+
'<div class="login-browser-actions">' +
|
|
48
|
+
'<button type="button" data-action="keyboard" class="login-browser-icon login-browser-keyboard-button" aria-label="Show keyboard" title="Show keyboard">' + iconHtml("keyboard") + '</button>' +
|
|
49
|
+
'<button type="button" data-action="reload" class="login-browser-icon" aria-label="Reload page" title="Reload page">' + iconHtml("rotate-cw") + '</button>' +
|
|
50
|
+
'<details class="login-browser-more"><summary aria-label="More sign-in options" title="More sign-in options">' + iconHtml("ellipsis") + '</summary>' +
|
|
51
|
+
'<div class="login-browser-menu"><button type="button" data-action="retry">' + iconHtml("refresh-cw") + '<span>Restart sign-in</span></button></div></details>' +
|
|
52
|
+
'<button type="button" data-action="cancel" class="login-browser-icon" aria-label="Cancel sign-in" title="Cancel sign-in">' + iconHtml("x") + '</button></div></header>' +
|
|
53
|
+
'<div class="login-browser-viewport"><canvas width="1000" height="720" aria-label="Remote sign-in page"></canvas>' +
|
|
54
|
+
'<div class="login-browser-loading"><span class="login-browser-spinner" aria-hidden="true"></span><strong>Opening your sign-in page</strong><span>This may take a moment.</span></div>' +
|
|
55
|
+
'<textarea class="login-browser-keyboard" aria-label="Type into the remote sign-in page" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea></div>' +
|
|
56
|
+
'<footer class="login-browser-footer"><div class="login-browser-info"><div class="login-browser-status"><span class="login-browser-status-dot" aria-hidden="true"></span><p role="status">Preparing sign-in…</p></div>' +
|
|
57
|
+
'<p class="login-browser-privacy">Browser cookies are discarded when this session ends. You stay signed in to ' + (msg.vendor === "codex" ? 'Codex' : 'Claude') + ' after this browser closes.</p></div>' +
|
|
58
|
+
'<button type="button" data-action="terminal" class="login-browser-terminal">' + iconHtml("terminal") + '<span>Use terminal sign-in</span>' + iconHtml("arrow-up-right") + '</button></footer>';
|
|
59
|
+
dialog.querySelector("h2").textContent = title;
|
|
60
|
+
document.body.appendChild(dialog);
|
|
61
|
+
var keyboard = dialog.querySelector("textarea");
|
|
62
|
+
var canvas = dialog.querySelector("canvas");
|
|
63
|
+
function cancel() { cancelVendorLogin(msg.vendor); }
|
|
64
|
+
function restart(mode) {
|
|
65
|
+
var ws = getWs();
|
|
66
|
+
if (!ws || ws.readyState !== 1) {
|
|
67
|
+
dialog.querySelector('[role="status"]').textContent = "Reconnect to Clay before switching sign-in methods.";
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
cancel();
|
|
71
|
+
requestVendorLogin(msg.vendor, { mode: mode });
|
|
72
|
+
}
|
|
73
|
+
dialog.querySelector('[data-action="cancel"]').onclick = cancel;
|
|
74
|
+
dialog.querySelector('[data-action="retry"]').onclick = function () { restart("browser"); };
|
|
75
|
+
dialog.querySelector('[data-action="terminal"]').onclick = function () { restart("terminal"); };
|
|
76
|
+
dialog.querySelector('[data-action="reload"]').onclick = function () { input({ kind: "reload" }); };
|
|
77
|
+
dialog.querySelector('[data-action="keyboard"]').onclick = function () { keyboard.focus(); };
|
|
78
|
+
dialog.addEventListener("cancel", function (event) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
var menu = dialog.querySelector("details");
|
|
81
|
+
if (menu.open) { menu.open = false; menu.querySelector("summary").focus(); }
|
|
82
|
+
else cancel();
|
|
83
|
+
});
|
|
84
|
+
dialog.addEventListener("click", function (event) {
|
|
85
|
+
var menu = dialog.querySelector("details");
|
|
86
|
+
if (!menu.contains(event.target)) menu.open = false;
|
|
87
|
+
});
|
|
88
|
+
function pointerPoint(event) {
|
|
89
|
+
var rect = canvas.getBoundingClientRect();
|
|
90
|
+
return { x: (event.clientX - rect.left) * canvas.width / rect.width,
|
|
91
|
+
y: (event.clientY - rect.top) * canvas.height / rect.height };
|
|
92
|
+
}
|
|
93
|
+
function flushMove() {
|
|
94
|
+
var view = current();
|
|
95
|
+
if (!view || view.browserId !== msg.browserId) return;
|
|
96
|
+
clearTimeout(view.moveTimer);
|
|
97
|
+
var point = view.pendingMove;
|
|
98
|
+
store.set({ vendorLoginBrowser: Object.assign({}, view, { moveTimer: null, pendingMove: null }) });
|
|
99
|
+
if (point) input(Object.assign({ kind: "move" }, point));
|
|
100
|
+
}
|
|
101
|
+
canvas.addEventListener("pointermove", function (event) {
|
|
102
|
+
var view = current();
|
|
103
|
+
var ws = getWs();
|
|
104
|
+
if (!view || event.pointerType === "touch" || !ws || ws.bufferedAmount > 65536) return;
|
|
105
|
+
store.set({ vendorLoginBrowser: Object.assign({}, view, {
|
|
106
|
+
pendingMove: pointerPoint(event), moveTimer: view.moveTimer || setTimeout(flushMove, 40),
|
|
107
|
+
}) });
|
|
108
|
+
});
|
|
109
|
+
canvas.addEventListener("pointerdown", function (event) {
|
|
110
|
+
if (event.button !== 0 || !event.isPrimary) return;
|
|
111
|
+
flushMove();
|
|
112
|
+
var view = current();
|
|
113
|
+
if (!view || view.pointerId !== null) return;
|
|
114
|
+
canvas.setPointerCapture(event.pointerId);
|
|
115
|
+
var touch = event.pointerType === "touch";
|
|
116
|
+
store.set({ vendorLoginBrowser: Object.assign({}, view, {
|
|
117
|
+
pointerY: event.clientY, pointerId: event.pointerId, mouseDown: !touch,
|
|
118
|
+
}) });
|
|
119
|
+
if (!touch) input(Object.assign({ kind: "down" }, pointerPoint(event)));
|
|
120
|
+
});
|
|
121
|
+
function releasePointer(event, cancelled) {
|
|
122
|
+
var view = current();
|
|
123
|
+
if (!view || view.pointerId !== event.pointerId) return;
|
|
124
|
+
flushMove();
|
|
125
|
+
view = current();
|
|
126
|
+
var distance = view.pointerY === null ? 0 : view.pointerY - event.clientY;
|
|
127
|
+
if (view.mouseDown) input(Object.assign({ kind: "up" }, pointerPoint(event)));
|
|
128
|
+
else if (!cancelled) {
|
|
129
|
+
if (Math.abs(distance) > 12) input({ kind: "wheel", deltaY: distance * 2 });
|
|
130
|
+
else input(Object.assign({ kind: "click" }, pointerPoint(event)));
|
|
131
|
+
}
|
|
132
|
+
store.set({ vendorLoginBrowser: Object.assign({}, view, { pointerY: null, pointerId: null, mouseDown: false }) });
|
|
133
|
+
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
|
|
134
|
+
if (!cancelled && Math.abs(distance) <= 12) keyboard.focus({ preventScroll: true });
|
|
135
|
+
}
|
|
136
|
+
canvas.addEventListener("pointerup", function (event) { releasePointer(event, false); });
|
|
137
|
+
canvas.addEventListener("pointercancel", function (event) { releasePointer(event, true); });
|
|
138
|
+
canvas.addEventListener("lostpointercapture", function (event) { releasePointer(event, true); });
|
|
139
|
+
canvas.addEventListener("wheel", function (event) {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
input({ kind: "wheel", deltaY: event.deltaY });
|
|
142
|
+
}, { passive: false });
|
|
143
|
+
keyboard.addEventListener("keydown", function (event) {
|
|
144
|
+
var key = event.key;
|
|
145
|
+
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === "a") key = "ControlOrMeta+A";
|
|
146
|
+
else if (event.shiftKey && key === "Tab") key = "Shift+Tab";
|
|
147
|
+
if (/^(Tab|Shift\+Tab|Enter|Backspace|Delete|Escape|ArrowLeft|ArrowRight|ArrowUp|ArrowDown|Home|End|ControlOrMeta\+A)$/.test(key)) {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
event.stopPropagation();
|
|
150
|
+
input({ kind: "key", key: key });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
function insert() {
|
|
154
|
+
if (keyboard.value) input({ kind: "text", text: keyboard.value.slice(0, 4096) });
|
|
155
|
+
keyboard.value = "";
|
|
156
|
+
}
|
|
157
|
+
keyboard.addEventListener("input", function (event) { if (!event.isComposing) insert(); });
|
|
158
|
+
keyboard.addEventListener("compositionend", insert);
|
|
159
|
+
keyboard.addEventListener("paste", function (event) {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
input({ kind: "text", text: event.clipboardData.getData("text/plain").slice(0, 4096) });
|
|
162
|
+
});
|
|
163
|
+
dialog.showModal();
|
|
164
|
+
refreshIcons();
|
|
165
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
166
|
+
var observer = new ResizeObserver(function () { resizeLoginBrowser(); });
|
|
167
|
+
store.set({ vendorLoginBrowser: Object.assign({}, current(), { resizeObserver: observer }) });
|
|
168
|
+
observer.observe(dialog.querySelector(".login-browser-viewport"));
|
|
169
|
+
}
|
|
170
|
+
send("vendor_login_browser_attach");
|
|
171
|
+
resizeLoginBrowser();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resizeLoginBrowser(force) {
|
|
175
|
+
var view = current();
|
|
176
|
+
var element = document.querySelector("#vendor-login-browser .login-browser-viewport");
|
|
177
|
+
if (!view || !element) return null;
|
|
178
|
+
var size = { width: Math.max(320, Math.min(1000, Math.round(element.clientWidth))),
|
|
179
|
+
height: Math.max(360, Math.min(720, Math.round(element.clientHeight))) };
|
|
180
|
+
if (force || !view.requestedSize || view.requestedSize.width !== size.width || view.requestedSize.height !== size.height) {
|
|
181
|
+
store.set({ vendorLoginBrowser: Object.assign({}, view, { requestedSize: size }) });
|
|
182
|
+
input(Object.assign({ kind: "resize" }, size));
|
|
183
|
+
}
|
|
184
|
+
return size;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function handleLoginBrowserMessage(msg) {
|
|
188
|
+
var view = current();
|
|
189
|
+
if (!view || msg.browserId !== view.browserId) return;
|
|
190
|
+
var dialog = document.getElementById("vendor-login-browser");
|
|
191
|
+
if (!dialog) return;
|
|
192
|
+
if (msg.type === "vendor_login_browser_status") {
|
|
193
|
+
dialog.querySelector('[role="status"]').textContent = msg.text;
|
|
194
|
+
dialog.dataset.phase = msg.phase || "loading";
|
|
195
|
+
store.set({ vendorLoginBrowser: Object.assign({}, view, { phase: msg.phase || "loading" }) });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (view.decoding || typeof msg.data !== "string" || msg.origin === "null") return;
|
|
199
|
+
store.set({ vendorLoginBrowser: Object.assign({}, view, { decoding: true }) });
|
|
200
|
+
var image = new Image();
|
|
201
|
+
function done() {
|
|
202
|
+
var active = current();
|
|
203
|
+
if (active && active.browserId === msg.browserId) store.set({ vendorLoginBrowser: Object.assign({}, active, { decoding: false }) });
|
|
204
|
+
}
|
|
205
|
+
image.onload = function () {
|
|
206
|
+
var active = current();
|
|
207
|
+
if (active && active.browserId === msg.browserId) {
|
|
208
|
+
var size = resizeLoginBrowser();
|
|
209
|
+
if (image.naturalWidth !== size.width || image.naturalHeight !== size.height) {
|
|
210
|
+
resizeLoginBrowser(true);
|
|
211
|
+
done();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
var canvas = dialog.querySelector("canvas");
|
|
215
|
+
canvas.width = image.naturalWidth;
|
|
216
|
+
canvas.height = image.naturalHeight;
|
|
217
|
+
canvas.getContext("2d").drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
218
|
+
dialog.querySelector(".login-browser-loading").hidden = true;
|
|
219
|
+
var origin = dialog.querySelector(".login-browser-origin");
|
|
220
|
+
try { origin.textContent = new URL(msg.origin).host; } catch (e) { origin.textContent = "Sign-in page"; }
|
|
221
|
+
origin.title = msg.origin || "";
|
|
222
|
+
}
|
|
223
|
+
done();
|
|
224
|
+
};
|
|
225
|
+
image.onerror = done;
|
|
226
|
+
image.src = "data:image/jpeg;base64," + msg.data;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
store.subscribe(function (state, previous) {
|
|
230
|
+
var view = state.vendorLoginBrowser;
|
|
231
|
+
if (!view) return;
|
|
232
|
+
if (state.currentSlug !== previous.currentSlug && state.currentSlug !== view.slug) { closeLoginBrowser(); return; }
|
|
233
|
+
if (state.connected !== previous.connected) {
|
|
234
|
+
if (state.connected) { send("vendor_login_browser_attach"); resizeLoginBrowser(true); }
|
|
235
|
+
else {
|
|
236
|
+
var dialog = document.getElementById("vendor-login-browser");
|
|
237
|
+
if (dialog) {
|
|
238
|
+
dialog.dataset.phase = "error";
|
|
239
|
+
dialog.querySelector('[role="status"]').textContent = "Connection lost. Reconnecting…";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
@@ -15,6 +15,7 @@ import { getWs } from './ws-ref.js';
|
|
|
15
15
|
import { refreshIcons, iconHtml } from './icons.js';
|
|
16
16
|
import { openTuiModal, closeTuiModal, getTuiModalTerminalId } from './tui-attention.js';
|
|
17
17
|
import { forwardPaneAuthRequired } from './pane-bridge.js';
|
|
18
|
+
import { openLoginBrowser, closeLoginBrowser } from './vendor-login-browser.js';
|
|
18
19
|
|
|
19
20
|
// vendor -> { terminalId, startedAt } as last broadcast by the server.
|
|
20
21
|
var activeFlows = Object.create(null);
|
|
@@ -106,7 +107,7 @@ function showLoginStartedBanner(vendor) {
|
|
|
106
107
|
showLoginBanner({
|
|
107
108
|
icon: "check-circle",
|
|
108
109
|
title: vendorDisplayName(vendor) + " login started",
|
|
109
|
-
text: "Finish
|
|
110
|
+
text: "Finish sign-in in the window below. Clay reconnects your sessions automatically.",
|
|
110
111
|
onDismiss: function () { cancelVendorLogin(vendor); },
|
|
111
112
|
});
|
|
112
113
|
}
|
|
@@ -169,6 +170,7 @@ export function requestVendorLogin(vendor, opts) {
|
|
|
169
170
|
vendor: target,
|
|
170
171
|
auto: !!options.auto,
|
|
171
172
|
sessionId: options.sessionId || store.get('activeSessionId') || null,
|
|
173
|
+
mode: options.mode || ((target === "claude" || target === "codex") ? "browser" : "terminal"),
|
|
172
174
|
}));
|
|
173
175
|
showLoginStartedBanner(target);
|
|
174
176
|
return true;
|
|
@@ -177,13 +179,17 @@ export function requestVendorLogin(vendor, opts) {
|
|
|
177
179
|
export function cancelVendorLogin(vendor) {
|
|
178
180
|
var target = vendor || modalVendor;
|
|
179
181
|
if (!target) return;
|
|
182
|
+
var flow = activeFlows[target];
|
|
183
|
+
var browser = store.get('vendorLoginBrowser');
|
|
184
|
+
var browserId = flow && flow.browserId || (browser && browser.vendor === target ? browser.browserId : null);
|
|
185
|
+
if (browser && browser.vendor === target) closeLoginBrowser();
|
|
180
186
|
delete pendingStarts[target];
|
|
181
187
|
delete activeFlows[target];
|
|
182
188
|
clearLoginBanners();
|
|
183
189
|
if (modalVendor === target) closeModalInternally();
|
|
184
190
|
var ws = getWs();
|
|
185
191
|
if (ws && ws.readyState === 1) {
|
|
186
|
-
ws.send(JSON.stringify({ type: "vendor_login_cancel", vendor: target }));
|
|
192
|
+
ws.send(JSON.stringify({ type: "vendor_login_cancel", vendor: target, browserId: browserId }));
|
|
187
193
|
}
|
|
188
194
|
}
|
|
189
195
|
|
|
@@ -215,7 +221,11 @@ export function handleVendorLoginReady(msg) {
|
|
|
215
221
|
if (!msg || typeof msg.terminalId !== "number") return;
|
|
216
222
|
var vendor = msg.vendor || "claude";
|
|
217
223
|
delete pendingStarts[vendor];
|
|
218
|
-
activeFlows[vendor] = { terminalId: msg.terminalId, startedAt: Date.now() };
|
|
224
|
+
activeFlows[vendor] = { terminalId: msg.terminalId, startedAt: Date.now(), browserId: msg.browserId || null };
|
|
225
|
+
if (msg.browserId) {
|
|
226
|
+
openLoginBrowser(msg);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
219
229
|
|
|
220
230
|
var slug = msg.slug || currentProjectSlug();
|
|
221
231
|
if (!slug) return;
|
|
@@ -239,7 +249,7 @@ export function handleVendorLoginState(msg) {
|
|
|
239
249
|
var next = Object.create(null);
|
|
240
250
|
var flows = msg.flows || [];
|
|
241
251
|
for (var i = 0; i < flows.length; i++) {
|
|
242
|
-
next[flows[i].vendor] = { terminalId: flows[i].terminalId, startedAt: flows[i].startedAt };
|
|
252
|
+
next[flows[i].vendor] = { terminalId: flows[i].terminalId, startedAt: flows[i].startedAt, browserId: flows[i].browserId || null };
|
|
243
253
|
delete pendingStarts[flows[i].vendor];
|
|
244
254
|
}
|
|
245
255
|
|
|
@@ -253,11 +263,18 @@ export function handleVendorLoginState(msg) {
|
|
|
253
263
|
}
|
|
254
264
|
}
|
|
255
265
|
activeFlows = next;
|
|
266
|
+
var browser = store.get('vendorLoginBrowser');
|
|
267
|
+
if (browser && (!next[browser.vendor] || next[browser.vendor].browserId !== browser.browserId)) {
|
|
268
|
+
closeLoginBrowser();
|
|
269
|
+
clearLoginBanners();
|
|
270
|
+
}
|
|
256
271
|
}
|
|
257
272
|
|
|
258
273
|
export function handleAuthRefreshed(msg) {
|
|
259
274
|
if (!msg) return;
|
|
260
275
|
var vendor = msg.vendor || "claude";
|
|
276
|
+
var browser = store.get('vendorLoginBrowser');
|
|
277
|
+
if (browser && browser.vendor === vendor) closeLoginBrowser();
|
|
261
278
|
var flow = activeFlows[vendor];
|
|
262
279
|
delete pendingStarts[vendor];
|
|
263
280
|
delete activeFlows[vendor];
|
|
@@ -269,6 +286,8 @@ export function handleAuthRefreshed(msg) {
|
|
|
269
286
|
export function handleVendorLoginError(msg) {
|
|
270
287
|
if (!msg) return;
|
|
271
288
|
var vendor = msg.vendor || "claude";
|
|
289
|
+
var browser = store.get('vendorLoginBrowser');
|
|
290
|
+
if (browser && browser.vendor === vendor) closeLoginBrowser();
|
|
272
291
|
delete pendingStarts[vendor];
|
|
273
292
|
delete activeFlows[vendor];
|
|
274
293
|
if (modalVendor === vendor) closeModalInternally();
|
package/lib/public/style.css
CHANGED
package/lib/terminal.js
CHANGED
|
@@ -25,6 +25,10 @@ function createTerminal(cwd, cols, rows, osUserInfo, opts) {
|
|
|
25
25
|
|
|
26
26
|
// Build a minimal, isolated environment (no daemon env leakage)
|
|
27
27
|
var termEnv = buildUserEnv(osUserInfo);
|
|
28
|
+
if (opts && opts.loginBrowserEnv) {
|
|
29
|
+
termEnv.BROWSER = opts.loginBrowserEnv.BROWSER;
|
|
30
|
+
termEnv.CLAY_LOGIN_URL_FILE = opts.loginBrowserEnv.CLAY_LOGIN_URL_FILE;
|
|
31
|
+
}
|
|
28
32
|
var spawnOpts = {
|
|
29
33
|
name: "xterm-256color",
|
|
30
34
|
cols: cols || 80,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
var createLoginBrowser = require("./vendor-login-browser").createLoginBrowser;
|
|
2
|
+
|
|
3
|
+
function attachBrowserLogin(ctx) {
|
|
4
|
+
function allowed(ws, flow) {
|
|
5
|
+
var userId = ws && ws._clayUser ? String(ws._clayUser.id) : null;
|
|
6
|
+
return userId === flow.ownerId && ctx.hasTerminalPermission(ws);
|
|
7
|
+
}
|
|
8
|
+
function prepare(ws, flow, identity) {
|
|
9
|
+
if (ctx.osUsers && !identity.osUserInfo) throw new Error("OS user identity is unavailable");
|
|
10
|
+
flow.ownerId = ws && ws._clayUser ? String(ws._clayUser.id) : null;
|
|
11
|
+
flow.browser = (ctx.createLoginBrowser || createLoginBrowser)({
|
|
12
|
+
ws: ws, vendor: flow.vendor, osUserInfo: identity.osUserInfo,
|
|
13
|
+
sendTo: ctx.sendTo,
|
|
14
|
+
canAccess: function (peer) { return allowed(peer, flow); },
|
|
15
|
+
});
|
|
16
|
+
flow.expiryTimer = setTimeout(function () { ctx.cancel(flow.key); }, 15 * 60 * 1000);
|
|
17
|
+
flow.expiryTimer.unref();
|
|
18
|
+
}
|
|
19
|
+
function handle(ws, msg, flows) {
|
|
20
|
+
var flow = flows[ctx.flowKey(ws, String(msg.vendor || "claude"))];
|
|
21
|
+
if (msg.type === "vendor_login_cancel" && msg.browserId && (!flow || !flow.browser)) return true;
|
|
22
|
+
if (typeof msg.type === "string" && msg.type.indexOf("term_") === 0) {
|
|
23
|
+
var vendors = Object.keys(flows);
|
|
24
|
+
for (var i = 0; i < vendors.length; i++) {
|
|
25
|
+
var candidate = flows[vendors[i]];
|
|
26
|
+
if (candidate.terminalId === msg.id && !allowed(ws, candidate)) return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (msg.type === "vendor_login_browser_attach" || msg.type === "vendor_login_browser_input") {
|
|
30
|
+
if (!flow || !flow.browser || flow.browser.id !== msg.browserId || !allowed(ws, flow)) return true;
|
|
31
|
+
if (msg.type === "vendor_login_browser_attach") flow.browser.attach(ws);
|
|
32
|
+
else flow.browser.input(ws, msg.event);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (flow && flow.browser && (msg.type === "vendor_login_start" || msg.type === "vendor_login_cancel")) {
|
|
36
|
+
if (!allowed(ws, flow) || (msg.type === "vendor_login_cancel" && msg.browserId !== flow.browser.id)) {
|
|
37
|
+
ctx.sendTo(ws, { type: "vendor_login_error", vendor: msg.vendor, error: "This sign-in belongs to another request. Reopen sign-in to continue." });
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return { prepare: prepare, handle: handle };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { attachBrowserLogin: attachBrowserLogin };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Runs before page scripts in each remote sign-in page, frame, and popup.
|
|
2
|
+
function disableLoginPasskeys() {
|
|
3
|
+
var credentials = window.CredentialsContainer && window.CredentialsContainer.prototype;
|
|
4
|
+
if (credentials) {
|
|
5
|
+
["get", "create"].forEach(function (method) {
|
|
6
|
+
var original = credentials[method];
|
|
7
|
+
if (typeof original !== "function") return;
|
|
8
|
+
Object.defineProperty(credentials, method, {
|
|
9
|
+
configurable: true,
|
|
10
|
+
writable: true,
|
|
11
|
+
value: function (options) {
|
|
12
|
+
if (options && "publicKey" in Object(options)) {
|
|
13
|
+
return Promise.reject(new DOMException(
|
|
14
|
+
"Passkeys are unavailable in this remote sign-in browser. Choose another sign-in method.",
|
|
15
|
+
"NotSupportedError"
|
|
16
|
+
));
|
|
17
|
+
}
|
|
18
|
+
return original.apply(this, arguments);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// Feature detection should choose password/email instead of host-native authenticator UI.
|
|
24
|
+
Object.defineProperty(window, "PublicKeyCredential", { configurable: true, writable: true, value: undefined });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { disableLoginPasskeys: disableLoginPasskeys };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Runs as the same OS identity as the login CLI. Only the human supplies input.
|
|
2
|
+
var readline = require("readline");
|
|
3
|
+
var spawn = require("child_process").spawn;
|
|
4
|
+
var chromium = require("playwright").chromium;
|
|
5
|
+
var browser = null;
|
|
6
|
+
var page = null;
|
|
7
|
+
var installing = null;
|
|
8
|
+
var viewing = true;
|
|
9
|
+
var closing = false;
|
|
10
|
+
var captureTimer = null;
|
|
11
|
+
var inputQueue = Promise.resolve();
|
|
12
|
+
var queued = 0;
|
|
13
|
+
var viewport = { width: 1000, height: 720 };
|
|
14
|
+
|
|
15
|
+
function emit(message) {
|
|
16
|
+
if (!closing && process.stdout.writableLength < 1024 * 1024) process.stdout.write(JSON.stringify(message) + "\n");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function status(text, phase) { emit({ type: "status", text: text, phase: phase || "loading" }); }
|
|
20
|
+
|
|
21
|
+
async function launch() {
|
|
22
|
+
var channels = ["chrome", "msedge", null];
|
|
23
|
+
for (var i = 0; i < channels.length; i++) {
|
|
24
|
+
try {
|
|
25
|
+
return await chromium.launch({ channel: channels[i] || undefined, headless: true, chromiumSandbox: true, timeout: 20000 });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (closing) throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
status("Preparing the sign-in browser. The first download may take a few minutes…");
|
|
31
|
+
await new Promise(function (resolve, reject) {
|
|
32
|
+
installing = spawn(process.execPath, [require("path").join(require.resolve("playwright/package.json"), "../cli.js"), "install", "chromium"], { stdio: "ignore", env: process.env });
|
|
33
|
+
var timer = setTimeout(function () { installing.kill(); reject(new Error("Install timed out")); }, 180000);
|
|
34
|
+
installing.once("error", function (err) { clearTimeout(timer); reject(err); });
|
|
35
|
+
installing.once("exit", function (code) {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
installing = null;
|
|
38
|
+
if (code === 0) resolve(); else reject(new Error("Browser installation failed"));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
if (closing) throw new Error("Closed");
|
|
42
|
+
return chromium.launch({ headless: true, chromiumSandbox: true, timeout: 20000 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function capture() {
|
|
46
|
+
if (closing) return;
|
|
47
|
+
try {
|
|
48
|
+
if (page && !page.isClosed() && viewing && process.stdout.writableLength < 262144) {
|
|
49
|
+
var current = page;
|
|
50
|
+
var data = await current.screenshot({ type: "jpeg", quality: 65, caret: "initial", timeout: 3000 });
|
|
51
|
+
if (current === page) emit({ type: "frame", data: data.toString("base64"), origin: new URL(current.url()).origin,
|
|
52
|
+
width: current.viewportSize().width, height: current.viewportSize().height });
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {}
|
|
55
|
+
if (!closing) captureTimer = setTimeout(capture, 250);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function start(url) {
|
|
59
|
+
browser = await launch();
|
|
60
|
+
if (closing) { await browser.close(); return; }
|
|
61
|
+
var session = await browser.newBrowserCDPSession();
|
|
62
|
+
var version;
|
|
63
|
+
try { version = await session.send("Browser.getVersion"); }
|
|
64
|
+
finally { await session.detach(); }
|
|
65
|
+
// Preserve the native version/platform and replace only the headless product token.
|
|
66
|
+
var userAgent = version.userAgent.replace("HeadlessChrome/", "Chrome/");
|
|
67
|
+
var context = await browser.newContext({ viewport: viewport, acceptDownloads: false, userAgent: userAgent });
|
|
68
|
+
await context.addInitScript(require("./vendor-login-browser-passkeys").disableLoginPasskeys);
|
|
69
|
+
var callback = new URL(new URL(url).searchParams.get("redirect_uri"));
|
|
70
|
+
await context.route("**/*", function (route) {
|
|
71
|
+
var target = new URL(route.request().url());
|
|
72
|
+
if (target.protocol === "https:" || (target.origin === callback.origin && target.pathname === callback.pathname)) return route.continue();
|
|
73
|
+
return route.abort();
|
|
74
|
+
});
|
|
75
|
+
context.on("page", function (opened) {
|
|
76
|
+
page = opened;
|
|
77
|
+
opened.setViewportSize(viewport).catch(function () {});
|
|
78
|
+
opened.on("download", function (download) { download.cancel().catch(function () {}); });
|
|
79
|
+
opened.on("close", function () {
|
|
80
|
+
var pages = context.pages();
|
|
81
|
+
page = pages.length ? pages[pages.length - 1] : null;
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
page = await context.newPage();
|
|
85
|
+
capture();
|
|
86
|
+
status("Opening sign-in page…");
|
|
87
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
88
|
+
status("Sign in to connect your account.", "ready");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function input(event) {
|
|
92
|
+
if (!page || page.isClosed() || closing) return;
|
|
93
|
+
if (["move", "down", "up"].indexOf(event.kind) >= 0 && Number.isFinite(event.x) && Number.isFinite(event.y)) {
|
|
94
|
+
var pointerSize = page.viewportSize();
|
|
95
|
+
var x = Math.max(0, Math.min(pointerSize.width - 1, event.x));
|
|
96
|
+
var y = Math.max(0, Math.min(pointerSize.height - 1, event.y));
|
|
97
|
+
await page.mouse.move(x, y);
|
|
98
|
+
if (event.kind === "down") await page.mouse.down();
|
|
99
|
+
if (event.kind === "up") await page.mouse.up();
|
|
100
|
+
} else if (event.kind === "click" && Number.isFinite(event.x) && Number.isFinite(event.y)) {
|
|
101
|
+
var size = page.viewportSize();
|
|
102
|
+
await page.mouse.click(Math.max(0, Math.min(size.width - 1, event.x)), Math.max(0, Math.min(size.height - 1, event.y)));
|
|
103
|
+
} else if (event.kind === "wheel" && Number.isFinite(event.deltaY)) {
|
|
104
|
+
await page.mouse.wheel(0, Math.max(-1500, Math.min(1500, event.deltaY)));
|
|
105
|
+
} else if (event.kind === "text" && typeof event.text === "string" && event.text.length <= 4096) {
|
|
106
|
+
await page.keyboard.insertText(event.text);
|
|
107
|
+
} else if (event.kind === "key" && /^(Tab|Shift\+Tab|Enter|Backspace|Delete|Escape|ArrowLeft|ArrowRight|ArrowUp|ArrowDown|Home|End|ControlOrMeta\+A)$/.test(event.key)) {
|
|
108
|
+
await page.keyboard.press(event.key);
|
|
109
|
+
} else if (event.kind === "reload") {
|
|
110
|
+
await page.reload({ waitUntil: "domcontentloaded", timeout: 30000 });
|
|
111
|
+
} else if (event.kind === "resize" && Number.isFinite(event.width) && Number.isFinite(event.height)) {
|
|
112
|
+
viewport = { width: Math.round(Math.max(320, Math.min(1000, event.width))), height: Math.round(Math.max(360, Math.min(720, event.height))) };
|
|
113
|
+
await page.setViewportSize(viewport);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function close() {
|
|
118
|
+
if (closing) return;
|
|
119
|
+
closing = true;
|
|
120
|
+
clearTimeout(captureTimer);
|
|
121
|
+
if (installing) installing.kill();
|
|
122
|
+
if (browser) await browser.close().catch(function () {});
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var started = false;
|
|
127
|
+
var lines = readline.createInterface({ input: process.stdin });
|
|
128
|
+
lines.on("line", function (line) {
|
|
129
|
+
if (line.length > 20000 || closing) return;
|
|
130
|
+
var message;
|
|
131
|
+
try { message = JSON.parse(line); } catch (e) { return; }
|
|
132
|
+
if (message.type === "start" && !started) {
|
|
133
|
+
started = true;
|
|
134
|
+
start(message.url).catch(function () {
|
|
135
|
+
status("Unable to open the sign-in browser. The host may need browser libraries or sandbox support. Use terminal sign-in to continue.", "error");
|
|
136
|
+
});
|
|
137
|
+
} else if (message.type === "viewing") {
|
|
138
|
+
viewing = !!message.value;
|
|
139
|
+
} else if (message.type === "input" && message.event && queued < 100) {
|
|
140
|
+
if (message.event.kind === "move" && queued >= 10) return;
|
|
141
|
+
queued++;
|
|
142
|
+
inputQueue = inputQueue.then(function () { return input(message.event); }).catch(function () {
|
|
143
|
+
status("The page did not respond. Try reloading.", "error");
|
|
144
|
+
}).finally(function () { queued--; });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
lines.on("close", close);
|
|
148
|
+
process.on("SIGTERM", close);
|
|
149
|
+
process.on("SIGINT", close);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Private, human-controlled browser transport for a single vendor login.
|
|
2
|
+
var fs = require("fs");
|
|
3
|
+
var os = require("os");
|
|
4
|
+
var path = require("path");
|
|
5
|
+
var crypto = require("crypto");
|
|
6
|
+
var spawn = require("child_process").spawn;
|
|
7
|
+
var buildUserEnv = require("./build-user-env").buildUserEnv;
|
|
8
|
+
var wrapSpawnAsUser = require("./os-users").wrapSpawnAsUser;
|
|
9
|
+
|
|
10
|
+
function loginUrl(value, vendor) {
|
|
11
|
+
try {
|
|
12
|
+
var url = new URL(value);
|
|
13
|
+
var hosts = vendor === "codex" ? ["auth.openai.com"] : ["claude.ai", "claude.com", "console.anthropic.com", "platform.claude.com"];
|
|
14
|
+
if (url.protocol !== "https:" || url.username || url.password || hosts.indexOf(url.hostname) < 0) return null;
|
|
15
|
+
if (!url.searchParams.get("state") || !url.searchParams.get("code_challenge")) return null;
|
|
16
|
+
var callback = new URL(url.searchParams.get("redirect_uri"));
|
|
17
|
+
if (callback.protocol !== "http:" || ["localhost", "127.0.0.1", "[::1]"].indexOf(callback.hostname) < 0) return null;
|
|
18
|
+
return url.href;
|
|
19
|
+
} catch (e) { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createLoginBrowser(options) {
|
|
23
|
+
var id = crypto.randomUUID();
|
|
24
|
+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "clay-login-"));
|
|
25
|
+
fs.chmodSync(dir, 0o700);
|
|
26
|
+
var info = options.osUserInfo;
|
|
27
|
+
if (info) fs.chownSync(dir, info.uid, info.gid);
|
|
28
|
+
var urlFile = path.join(dir, "url");
|
|
29
|
+
var opener = path.resolve(__dirname, "../bin/login-browser-open.js");
|
|
30
|
+
var wrapper = path.join(dir, process.platform === "win32" ? "open.cmd" : "open");
|
|
31
|
+
var quote = function (value) { return "'" + value.replace(/'/g, "'\\''") + "'"; };
|
|
32
|
+
var script = process.platform === "win32"
|
|
33
|
+
? '@"' + process.execPath + '" "' + opener + '" %*\r\n'
|
|
34
|
+
: "#!/bin/sh\nexec " + quote(process.execPath) + " " + quote(opener) + ' "$@"\n';
|
|
35
|
+
fs.writeFileSync(wrapper, script, { mode: 0o700 });
|
|
36
|
+
if (info) fs.chownSync(wrapper, info.uid, info.gid);
|
|
37
|
+
var child = null;
|
|
38
|
+
var peer = options.ws;
|
|
39
|
+
var closed = false;
|
|
40
|
+
var started = false;
|
|
41
|
+
var status = "Waiting for the sign-in page…";
|
|
42
|
+
var statusPhase = "loading";
|
|
43
|
+
var tail = "";
|
|
44
|
+
var pollBusy = false;
|
|
45
|
+
var waitTimer = setTimeout(function () {
|
|
46
|
+
if (!started && !closed) {
|
|
47
|
+
status = "Still waiting for the CLI sign-in page. Use terminal sign-in if it needs attention.";
|
|
48
|
+
send({ type: "vendor_login_browser_status", text: status, phase: statusPhase });
|
|
49
|
+
}
|
|
50
|
+
}, 30000);
|
|
51
|
+
waitTimer.unref();
|
|
52
|
+
|
|
53
|
+
function send(message) {
|
|
54
|
+
if (closed || !peer || peer.readyState !== 1 || !options.canAccess(peer)) return;
|
|
55
|
+
if (message.type === "vendor_login_browser_frame" && peer.bufferedAmount > 1024 * 1024) return;
|
|
56
|
+
options.sendTo(peer, Object.assign({ vendor: options.vendor, browserId: id }, message));
|
|
57
|
+
}
|
|
58
|
+
function write(message) {
|
|
59
|
+
if (child && child.stdin.writable && child.stdin.writableLength < 65536) child.stdin.write(JSON.stringify(message) + "\n");
|
|
60
|
+
}
|
|
61
|
+
function start(url) {
|
|
62
|
+
if (closed || started) return;
|
|
63
|
+
started = true;
|
|
64
|
+
clearTimeout(waitTimer);
|
|
65
|
+
status = "Opening a private sign-in browser…";
|
|
66
|
+
send({ type: "vendor_login_browser_status", text: status, phase: statusPhase });
|
|
67
|
+
var env = buildUserEnv(info);
|
|
68
|
+
if (process.platform === "win32" && !info) {
|
|
69
|
+
["SystemRoot", "WINDIR", "TEMP", "TMP", "LOCALAPPDATA", "APPDATA", "USERPROFILE", "COMSPEC", "PATHEXT"].forEach(function (key) {
|
|
70
|
+
if (process.env[key]) env[key] = process.env[key];
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
var opts = { cwd: info ? info.home : os.homedir(), env: env, stdio: ["pipe", "pipe", "ignore"] };
|
|
74
|
+
if (info) { opts.uid = info.uid; opts.gid = info.gid; }
|
|
75
|
+
var spec = wrapSpawnAsUser(process.execPath, [path.join(__dirname, "vendor-login-browser-worker.js")], opts);
|
|
76
|
+
child = spawn(spec.command, spec.args, spec.options);
|
|
77
|
+
var buffer = "";
|
|
78
|
+
child.stdout.setEncoding("utf8");
|
|
79
|
+
child.stdout.on("data", function (chunk) {
|
|
80
|
+
buffer += chunk;
|
|
81
|
+
if (buffer.length > 4 * 1024 * 1024) { child.kill(); return; }
|
|
82
|
+
var at;
|
|
83
|
+
while ((at = buffer.indexOf("\n")) >= 0) {
|
|
84
|
+
var line = buffer.slice(0, at);
|
|
85
|
+
buffer = buffer.slice(at + 1);
|
|
86
|
+
try {
|
|
87
|
+
var message = JSON.parse(line);
|
|
88
|
+
if (message.type === "status") {
|
|
89
|
+
status = message.text;
|
|
90
|
+
statusPhase = message.phase || "loading";
|
|
91
|
+
send({ type: "vendor_login_browser_status", text: status, phase: statusPhase });
|
|
92
|
+
} else if (message.type === "frame") {
|
|
93
|
+
send({ type: "vendor_login_browser_frame", data: message.data, origin: message.origin, width: message.width, height: message.height });
|
|
94
|
+
write({ type: "viewing", value: !!(peer && peer.readyState === 1 && options.canAccess(peer)) });
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
child.on("error", function () {
|
|
100
|
+
statusPhase = "error";
|
|
101
|
+
status = "The sign-in browser could not start. Use terminal sign-in to continue.";
|
|
102
|
+
send({ type: "vendor_login_browser_status", text: status, phase: statusPhase });
|
|
103
|
+
});
|
|
104
|
+
child.on("exit", function () {
|
|
105
|
+
if (closed) return;
|
|
106
|
+
statusPhase = "error";
|
|
107
|
+
status = "The sign-in browser closed. You can retry or use terminal sign-in.";
|
|
108
|
+
send({ type: "vendor_login_browser_status", text: status, phase: statusPhase });
|
|
109
|
+
});
|
|
110
|
+
child.stdin.on("error", function () {});
|
|
111
|
+
write({ type: "start", url: url });
|
|
112
|
+
}
|
|
113
|
+
function acceptUrl(value) {
|
|
114
|
+
var url = loginUrl(value, options.vendor);
|
|
115
|
+
if (url) start(url);
|
|
116
|
+
}
|
|
117
|
+
var timer = setInterval(function () {
|
|
118
|
+
if (closed || started || pollBusy) return;
|
|
119
|
+
pollBusy = true;
|
|
120
|
+
fs.readFile(urlFile, "utf8", function (err, value) {
|
|
121
|
+
pollBusy = false;
|
|
122
|
+
if (!err && value.length <= 16384) acceptUrl(value);
|
|
123
|
+
});
|
|
124
|
+
}, 250);
|
|
125
|
+
timer.unref();
|
|
126
|
+
return {
|
|
127
|
+
id: id,
|
|
128
|
+
env: { BROWSER: wrapper, CLAY_LOGIN_URL_FILE: urlFile },
|
|
129
|
+
output: function (chunk) {
|
|
130
|
+
// Codex also prints the complete URL when the platform opener ignores BROWSER.
|
|
131
|
+
tail = (tail + chunk).slice(-32768);
|
|
132
|
+
var matches = tail.match(/https:\/\/[^\s\x1b<>"']+(?=[\s\x1b<>"'])/g) || [];
|
|
133
|
+
for (var i = 0; i < matches.length; i++) acceptUrl(matches[i]);
|
|
134
|
+
},
|
|
135
|
+
attach: function (ws) {
|
|
136
|
+
peer = ws;
|
|
137
|
+
send({ type: "vendor_login_browser_status", text: status, phase: statusPhase });
|
|
138
|
+
write({ type: "viewing", value: true });
|
|
139
|
+
},
|
|
140
|
+
input: function (ws, event) {
|
|
141
|
+
if (ws !== peer || !options.canAccess(ws) || !event || typeof event !== "object") return;
|
|
142
|
+
write({ type: "input", event: event });
|
|
143
|
+
},
|
|
144
|
+
close: function () {
|
|
145
|
+
if (closed) return;
|
|
146
|
+
closed = true;
|
|
147
|
+
clearInterval(timer);
|
|
148
|
+
clearTimeout(waitTimer);
|
|
149
|
+
if (child) {
|
|
150
|
+
child.stdin.end();
|
|
151
|
+
var killTimer = setTimeout(function () { child.kill("SIGKILL"); }, 5000);
|
|
152
|
+
killTimer.unref();
|
|
153
|
+
child.once("exit", function () { clearTimeout(killTimer); });
|
|
154
|
+
}
|
|
155
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
tail = "";
|
|
157
|
+
peer = null;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { createLoginBrowser: createLoginBrowser, loginUrl: loginUrl };
|
package/lib/ws-schema.js
CHANGED
|
@@ -220,6 +220,10 @@ var schema = {
|
|
|
220
220
|
// -----------------------------------------------------------------------
|
|
221
221
|
"auth_required": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Authentication required (e.g. API key needed)" },
|
|
222
222
|
"vendor_login_start": { direction: "c2s", handler: "lib/project-vendor-login.js", description: "Start (or re-attach to) the per-vendor login terminal" },
|
|
223
|
+
"vendor_login_browser_attach": { direction: "c2s", handler: "lib/vendor-login-browser-flow.js", description: "Attach the sign-in owner to their private browser" },
|
|
224
|
+
"vendor_login_browser_input": { direction: "c2s", handler: "lib/vendor-login-browser-flow.js", description: "Send human input to the private sign-in browser" },
|
|
225
|
+
"vendor_login_browser_frame": { direction: "s2c", handler: "lib/public/modules/vendor-login-browser.js", description: "Private sign-in viewport; never recorded in conversation history" },
|
|
226
|
+
"vendor_login_browser_status": { direction: "s2c", handler: "lib/public/modules/vendor-login-browser.js", description: "Private sign-in browser status" },
|
|
223
227
|
"vendor_login_cancel": { direction: "c2s", handler: "lib/project-vendor-login.js", description: "Dismiss the login flow and kill its terminal" },
|
|
224
228
|
"vendor_login_state_request": { direction: "c2s", handler: "lib/project-vendor-login.js", description: "Ask for the project's active login flows" },
|
|
225
229
|
"vendor_login_ready": { direction: "s2c", handler: "lib/public/modules/vendor-login.js", description: "Login terminal is available; open/re-attach the modal to it" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clay-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.0-beta.1",
|
|
4
4
|
"description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"clay-server": "./bin/cli.js"
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"@seald-io/nedb": "^4.1.2",
|
|
54
54
|
"imapflow": "^1.7.8",
|
|
55
55
|
"nodemailer": "^9.1.1",
|
|
56
|
+
"playwright": "1.63.0",
|
|
56
57
|
"qrcode-terminal": "^0.12.0",
|
|
57
58
|
"web-push": "^3.6.7",
|
|
58
59
|
"ws": "^8.18.0"
|