clay-server 4.0.0-beta.1 → 4.0.0-beta.2
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/lib/project-connection.js +4 -0
- package/lib/project-vendor-login.js +397 -0
- package/lib/project.js +20 -0
- package/lib/public/modules/app-messages.js +24 -17
- package/lib/public/modules/app-notifications.js +4 -117
- package/lib/public/modules/pane-bridge.js +9 -0
- package/lib/public/modules/split-view.js +7 -0
- package/lib/public/modules/tui-attention.js +10 -0
- package/lib/public/modules/vendor-login.js +287 -0
- package/lib/tools-registry.js +11 -1
- package/lib/ws-schema.js +7 -0
- package/lib/yoke/adapters/codex.js +3 -4
- package/lib/yoke/codex-app-server.js +20 -2
- package/package.json +2 -2
|
@@ -78,6 +78,7 @@ function attachConnection(ctx) {
|
|
|
78
78
|
var _mcp = ctx._mcp;
|
|
79
79
|
var _notifications = ctx._notifications;
|
|
80
80
|
var _splitGroups = ctx._splitGroups;
|
|
81
|
+
var _vendorLogin = ctx._vendorLogin;
|
|
81
82
|
var hydrateImageRefs = ctx.hydrateImageRefs;
|
|
82
83
|
var resolveSessionForView = ctx.resolveSessionForView;
|
|
83
84
|
var broadcastClientCount = ctx.broadcastClientCount;
|
|
@@ -191,6 +192,9 @@ function attachConnection(ctx) {
|
|
|
191
192
|
sendTo(ws, { type: "last_vendor", vendor: sm.lastVendor || "" });
|
|
192
193
|
sendTo(ws, Object.assign({ type: "codex_config" }, getCodexConfig(sm)));
|
|
193
194
|
sendTo(ws, { type: "term_list", terminals: tm.list() });
|
|
195
|
+
// Any login flow already running in this project, so a reconnecting client
|
|
196
|
+
// re-attaches to the existing login terminal instead of starting another.
|
|
197
|
+
if (_vendorLogin) _vendorLogin.sendStateTo(ws);
|
|
194
198
|
// Context sources sent after session is resolved (per-session storage)
|
|
195
199
|
// Send email accounts list for context sources picker
|
|
196
200
|
var emailUserId = (wsUser && wsUser.id) || "default";
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// Vendor login flow coordinator.
|
|
2
|
+
//
|
|
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 project is the
|
|
5
|
+
// single source of truth: repeated auth_required events (other sessions, split
|
|
6
|
+
// panes, a burst of 401 retries) re-use that record instead of spawning more
|
|
7
|
+
// terminals.
|
|
8
|
+
//
|
|
9
|
+
// Lifecycle:
|
|
10
|
+
// vendor_login_start -> create (or re-use) a PTY running the vendor login
|
|
11
|
+
// command, reply with vendor_login_ready
|
|
12
|
+
// PTY output/exit -> detect a successful login, restart the vendor's
|
|
13
|
+
// YOKE adapter so it re-reads the credential file,
|
|
14
|
+
// broadcast auth_refreshed, close the terminal
|
|
15
|
+
// vendor_login_cancel -> user dismissed the flow; kill the terminal
|
|
16
|
+
//
|
|
17
|
+
// The adapter restart is the actual bug fix: a Codex app-server is a long-lived
|
|
18
|
+
// child process that reads ~/.codex/auth.json once at spawn, so without a
|
|
19
|
+
// restart every query after a successful login keeps hitting 401 and re-arms
|
|
20
|
+
// auth_required forever.
|
|
21
|
+
|
|
22
|
+
var vendorRegistry = require("./yoke/vendor-registry");
|
|
23
|
+
|
|
24
|
+
// Login CLIs print a success line and then hand the shell back, so the PTY
|
|
25
|
+
// itself does not exit. Watch the output stream instead. Deliberately narrow:
|
|
26
|
+
// "Successfully logged out" and MCP-server logins must not match.
|
|
27
|
+
var LOGIN_SUCCESS_PATTERNS = [
|
|
28
|
+
/successfully logged in(?![ \t]+to[ \t]+mcp)/i,
|
|
29
|
+
/logged in successfully/i,
|
|
30
|
+
/login successful/i,
|
|
31
|
+
/successfully authenticated/i,
|
|
32
|
+
/authentication successful/i,
|
|
33
|
+
/signed in successfully/i,
|
|
34
|
+
/signed in with your [a-z ]+ account/i,
|
|
35
|
+
/you(?:'re| are) (?:now )?(?:logged|signed) in/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Rolling window kept per flow so a success line split across PTY chunks is
|
|
39
|
+
// still matched, without buffering the whole login transcript.
|
|
40
|
+
var OUTPUT_TAIL_MAX = 4096;
|
|
41
|
+
|
|
42
|
+
// Grace period between detecting success and killing the terminal, so the user
|
|
43
|
+
// actually sees the confirmation line before the modal closes.
|
|
44
|
+
var CLOSE_AFTER_SUCCESS_MS = 2000;
|
|
45
|
+
|
|
46
|
+
var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r/g;
|
|
47
|
+
|
|
48
|
+
function stripAnsi(text) {
|
|
49
|
+
return String(text).replace(ANSI_PATTERN, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function looksLikeLoginSuccess(text) {
|
|
53
|
+
for (var i = 0; i < LOGIN_SUCCESS_PATTERNS.length; i++) {
|
|
54
|
+
if (LOGIN_SUCCESS_PATTERNS[i].test(text)) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ctx fields:
|
|
61
|
+
* slug, osUsers,
|
|
62
|
+
* sm, tm, adapters,
|
|
63
|
+
* send, sendTo,
|
|
64
|
+
* usersModule,
|
|
65
|
+
* getOsUserInfoForWs, getOsUserInfoForLinuxUser, getLinuxUserForSession
|
|
66
|
+
*/
|
|
67
|
+
function attachVendorLogin(ctx) {
|
|
68
|
+
var slug = ctx.slug;
|
|
69
|
+
var osUsers = ctx.osUsers;
|
|
70
|
+
|
|
71
|
+
var sm = ctx.sm;
|
|
72
|
+
var tm = ctx.tm;
|
|
73
|
+
var adapters = ctx.adapters || {};
|
|
74
|
+
|
|
75
|
+
var send = ctx.send;
|
|
76
|
+
var sendTo = ctx.sendTo;
|
|
77
|
+
|
|
78
|
+
var usersModule = ctx.usersModule;
|
|
79
|
+
|
|
80
|
+
var getOsUserInfoForWs = ctx.getOsUserInfoForWs;
|
|
81
|
+
var getOsUserInfoForLinuxUser = ctx.getOsUserInfoForLinuxUser;
|
|
82
|
+
var getLinuxUserForSession = ctx.getLinuxUserForSession;
|
|
83
|
+
|
|
84
|
+
// vendor -> flow record. At most one live login terminal per vendor.
|
|
85
|
+
var _flows = Object.create(null);
|
|
86
|
+
|
|
87
|
+
function loginCommandFor(vendor) {
|
|
88
|
+
var info = vendorRegistry.getVendorInfo(vendor);
|
|
89
|
+
return (info && info.loginCommand) || "claude login";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function vendorDisplayName(vendor) {
|
|
93
|
+
var info = vendorRegistry.getVendorInfo(vendor);
|
|
94
|
+
return (info && info.displayName) || vendor || "Vendor";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function listFlows() {
|
|
98
|
+
var result = [];
|
|
99
|
+
var vendors = Object.keys(_flows);
|
|
100
|
+
for (var i = 0; i < vendors.length; i++) {
|
|
101
|
+
var flow = _flows[vendors[i]];
|
|
102
|
+
result.push({
|
|
103
|
+
vendor: flow.vendor,
|
|
104
|
+
terminalId: flow.terminalId,
|
|
105
|
+
startedAt: flow.startedAt,
|
|
106
|
+
completed: !!flow.completed,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function broadcastState() {
|
|
113
|
+
send({ type: "vendor_login_state", slug: slug, flows: listFlows() });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sendStateTo(ws) {
|
|
117
|
+
sendTo(ws, { type: "vendor_login_state", slug: slug, flows: listFlows() });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hasTerminalPermission(ws) {
|
|
121
|
+
if (!ws || !ws._clayUser) return true;
|
|
122
|
+
if (!usersModule || typeof usersModule.getEffectivePermissions !== "function") return true;
|
|
123
|
+
var perms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
|
|
124
|
+
return !!(perms && perms.terminal);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The login terminal must write credentials to the same HOME the adapter
|
|
128
|
+
// will read them from. With OS-user isolation the adapter runs as the
|
|
129
|
+
// *session owner's* Linux user, which is not necessarily the connected
|
|
130
|
+
// client, so prefer the session identity when the requester is entitled to
|
|
131
|
+
// it. Without isolation both sides run as the daemon user and this resolves
|
|
132
|
+
// to null on both paths.
|
|
133
|
+
function resolveLoginIdentity(ws, msg) {
|
|
134
|
+
if (!osUsers) return { linuxUser: null, osUserInfo: null };
|
|
135
|
+
|
|
136
|
+
var sessionLinuxUser = null;
|
|
137
|
+
var sessionId = msg && msg.sessionId;
|
|
138
|
+
if (sessionId && sm && sm.sessions && typeof getLinuxUserForSession === "function") {
|
|
139
|
+
var session = sm.sessions.get(sessionId);
|
|
140
|
+
if (session) {
|
|
141
|
+
var requesterId = ws && ws._clayUser ? ws._clayUser.id : null;
|
|
142
|
+
var isOwner = !session.ownerId || (requesterId && String(session.ownerId) === String(requesterId));
|
|
143
|
+
var isAdmin = !!(ws && ws._clayUser && ws._clayUser.role === "admin");
|
|
144
|
+
if (isOwner || isAdmin) sessionLinuxUser = getLinuxUserForSession(session);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (sessionLinuxUser && typeof getOsUserInfoForLinuxUser === "function") {
|
|
149
|
+
return { linuxUser: sessionLinuxUser, osUserInfo: getOsUserInfoForLinuxUser(sessionLinuxUser) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
var wsInfo = typeof getOsUserInfoForWs === "function" ? getOsUserInfoForWs(ws) : null;
|
|
153
|
+
return { linuxUser: wsInfo ? wsInfo.user : null, osUserInfo: wsInfo };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isFlowTerminalAlive(flow) {
|
|
157
|
+
return !!(flow && typeof flow.terminalId === "number" && tm.has(flow.terminalId));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function discardFlow(vendor) {
|
|
161
|
+
var flow = _flows[vendor];
|
|
162
|
+
if (!flow) return null;
|
|
163
|
+
delete _flows[vendor];
|
|
164
|
+
if (flow.closeTimer) {
|
|
165
|
+
clearTimeout(flow.closeTimer);
|
|
166
|
+
flow.closeTimer = null;
|
|
167
|
+
}
|
|
168
|
+
return flow;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Kill the PTY and drop it from the sidebar list. `flow.closing` keeps the
|
|
172
|
+
// terminal's own exit hook from re-entering finalization.
|
|
173
|
+
function closeFlowTerminal(flow) {
|
|
174
|
+
if (!flow || typeof flow.terminalId !== "number") return;
|
|
175
|
+
flow.closing = true;
|
|
176
|
+
try { tm.close(flow.terminalId); } catch (e) {}
|
|
177
|
+
send({ type: "term_list", terminals: tm.list() });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Restart the vendor adapter so the next query spawns a process that reads
|
|
181
|
+
// the credentials the login just wrote. Shared adapter instances (Claude) are
|
|
182
|
+
// reused by every project, so tearing one down here would kill unrelated
|
|
183
|
+
// sessions; those runtimes pick up new credentials on their own.
|
|
184
|
+
function refreshVendorAuth(vendor) {
|
|
185
|
+
var adapter = adapters[vendor];
|
|
186
|
+
if (!adapter || typeof adapter.shutdown !== "function") return Promise.resolve(false);
|
|
187
|
+
if (adapter.shared) {
|
|
188
|
+
console.log("[vendor-login] " + vendor + " adapter is shared; skipping restart for " + slug);
|
|
189
|
+
return Promise.resolve(false);
|
|
190
|
+
}
|
|
191
|
+
// adapter.shutdown() already fans out to every per-OS-user runtime it
|
|
192
|
+
// created (shutdownUserRuntimes), so one call covers isolated setups too.
|
|
193
|
+
return Promise.resolve()
|
|
194
|
+
.then(function () { return adapter.shutdown(); })
|
|
195
|
+
.then(function () {
|
|
196
|
+
console.log("[vendor-login] Restarted " + vendor + " adapter for " + slug + " after login");
|
|
197
|
+
return true;
|
|
198
|
+
})
|
|
199
|
+
.catch(function (err) {
|
|
200
|
+
console.error("[vendor-login] " + vendor + " adapter restart failed for " + slug + ":",
|
|
201
|
+
err && err.message ? err.message : err);
|
|
202
|
+
return false;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function finishFlow(vendor, succeeded) {
|
|
207
|
+
var flow = _flows[vendor];
|
|
208
|
+
if (!flow || flow.finishing) return;
|
|
209
|
+
flow.finishing = true;
|
|
210
|
+
flow.completed = !!succeeded;
|
|
211
|
+
|
|
212
|
+
var refresh = succeeded ? refreshVendorAuth(vendor) : Promise.resolve(false);
|
|
213
|
+
refresh.then(function (restarted) {
|
|
214
|
+
discardFlow(vendor);
|
|
215
|
+
closeFlowTerminal(flow);
|
|
216
|
+
if (succeeded) {
|
|
217
|
+
send({ type: "auth_refreshed", slug: slug, vendor: vendor, adapterRestarted: !!restarted });
|
|
218
|
+
}
|
|
219
|
+
broadcastState();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function handleFlowOutput(vendor, chunk) {
|
|
224
|
+
var flow = _flows[vendor];
|
|
225
|
+
if (!flow || flow.finishing || flow.succeeded) return;
|
|
226
|
+
flow.outputTail = (flow.outputTail + stripAnsi(chunk)).slice(-OUTPUT_TAIL_MAX);
|
|
227
|
+
if (!looksLikeLoginSuccess(flow.outputTail)) return;
|
|
228
|
+
|
|
229
|
+
flow.succeeded = true;
|
|
230
|
+
console.log("[vendor-login] Detected successful " + vendor + " login in " + slug);
|
|
231
|
+
// Let the success line land on screen before the terminal disappears.
|
|
232
|
+
flow.closeTimer = setTimeout(function () {
|
|
233
|
+
finishFlow(vendor, true);
|
|
234
|
+
}, CLOSE_AFTER_SUCCESS_MS);
|
|
235
|
+
if (flow.closeTimer && typeof flow.closeTimer.unref === "function") flow.closeTimer.unref();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Fires for both a PTY that exited on its own and one the user closed from
|
|
239
|
+
// the sidebar. Either way the flow is over; refresh auth if the command had
|
|
240
|
+
// already reported success.
|
|
241
|
+
function handleFlowExit(vendor) {
|
|
242
|
+
var flow = _flows[vendor];
|
|
243
|
+
if (!flow || flow.closing || flow.finishing) return;
|
|
244
|
+
finishFlow(vendor, !!flow.succeeded);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function startFlow(ws, vendor, msg) {
|
|
248
|
+
var identity = resolveLoginIdentity(ws, msg);
|
|
249
|
+
var command = loginCommandFor(vendor);
|
|
250
|
+
var flow = {
|
|
251
|
+
vendor: vendor,
|
|
252
|
+
terminalId: null,
|
|
253
|
+
startedAt: Date.now(),
|
|
254
|
+
linuxUser: identity.linuxUser,
|
|
255
|
+
outputTail: "",
|
|
256
|
+
succeeded: false,
|
|
257
|
+
finishing: false,
|
|
258
|
+
closing: false,
|
|
259
|
+
completed: false,
|
|
260
|
+
closeTimer: null,
|
|
261
|
+
};
|
|
262
|
+
// Registered before create() so the PTY's first output chunk (hooks fire
|
|
263
|
+
// synchronously from spawn) already finds the record.
|
|
264
|
+
_flows[vendor] = flow;
|
|
265
|
+
|
|
266
|
+
var terminal = null;
|
|
267
|
+
try {
|
|
268
|
+
terminal = tm.create(100, 30, identity.osUserInfo, ws, {
|
|
269
|
+
initialInput: command + "\n",
|
|
270
|
+
title: vendorDisplayName(vendor) + " login",
|
|
271
|
+
kind: "vendor-login",
|
|
272
|
+
onData: function (data) { handleFlowOutput(vendor, data); },
|
|
273
|
+
onExit: function () { handleFlowExit(vendor); },
|
|
274
|
+
});
|
|
275
|
+
} catch (e) {
|
|
276
|
+
console.error("[vendor-login] Failed to spawn " + vendor + " login terminal in " + slug + ":",
|
|
277
|
+
e && e.message ? e.message : e);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!terminal) {
|
|
281
|
+
delete _flows[vendor];
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
flow.terminalId = terminal.id;
|
|
286
|
+
tm.attach(terminal.id, ws);
|
|
287
|
+
send({ type: "term_list", terminals: tm.list() });
|
|
288
|
+
return flow;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function handleVendorLoginMessage(ws, msg) {
|
|
292
|
+
if (msg.type === "vendor_login_state_request") {
|
|
293
|
+
sendStateTo(ws);
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (msg.type === "vendor_login_cancel") {
|
|
298
|
+
var cancelVendor = String(msg.vendor || "");
|
|
299
|
+
var cancelled = discardFlow(cancelVendor);
|
|
300
|
+
if (cancelled) closeFlowTerminal(cancelled);
|
|
301
|
+
broadcastState();
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (msg.type === "vendor_login_start") {
|
|
306
|
+
var vendor = String(msg.vendor || "claude");
|
|
307
|
+
if (!vendorRegistry.getVendorInfo(vendor)) {
|
|
308
|
+
sendTo(ws, { type: "vendor_login_error", vendor: vendor, error: "Unknown vendor: " + vendor });
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
if (!hasTerminalPermission(ws)) {
|
|
312
|
+
sendTo(ws, { type: "vendor_login_error", vendor: vendor, error: "Terminal access is not permitted" });
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
var existing = _flows[vendor];
|
|
317
|
+
if (existing && !isFlowTerminalAlive(existing)) {
|
|
318
|
+
// Terminal died without its exit hook clearing the record.
|
|
319
|
+
discardFlow(vendor);
|
|
320
|
+
existing = null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (existing) {
|
|
324
|
+
// An auto-triggered request (a fresh auth_required from another
|
|
325
|
+
// session or split pane) must not pop a second prompt; it only learns
|
|
326
|
+
// that a flow is already running. A deliberate request re-attaches.
|
|
327
|
+
if (!msg.auto) {
|
|
328
|
+
tm.attach(existing.terminalId, ws);
|
|
329
|
+
sendTo(ws, {
|
|
330
|
+
type: "vendor_login_ready",
|
|
331
|
+
slug: slug,
|
|
332
|
+
vendor: vendor,
|
|
333
|
+
terminalId: existing.terminalId,
|
|
334
|
+
reused: true,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
sendStateTo(ws);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
var started = startFlow(ws, vendor, msg);
|
|
342
|
+
if (!started) {
|
|
343
|
+
sendTo(ws, {
|
|
344
|
+
type: "vendor_login_error",
|
|
345
|
+
vendor: vendor,
|
|
346
|
+
error: "Cannot create terminal (node-pty not available or limit reached)",
|
|
347
|
+
});
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log("[vendor-login] Started " + vendor + " login terminal " + started.terminalId + " in " + slug);
|
|
352
|
+
sendTo(ws, {
|
|
353
|
+
type: "vendor_login_ready",
|
|
354
|
+
slug: slug,
|
|
355
|
+
vendor: vendor,
|
|
356
|
+
terminalId: started.terminalId,
|
|
357
|
+
reused: false,
|
|
358
|
+
});
|
|
359
|
+
broadcastState();
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The sidebar "close terminal" path goes through tm.close() directly, which
|
|
367
|
+
// fires our exit hook. This is the explicit entry point for callers that
|
|
368
|
+
// remove a terminal without going through the PTY.
|
|
369
|
+
function forgetTerminal(terminalId) {
|
|
370
|
+
var vendors = Object.keys(_flows);
|
|
371
|
+
for (var i = 0; i < vendors.length; i++) {
|
|
372
|
+
if (_flows[vendors[i]].terminalId === terminalId) {
|
|
373
|
+
handleFlowExit(vendors[i]);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isLoginTerminal(terminalId) {
|
|
381
|
+
var vendors = Object.keys(_flows);
|
|
382
|
+
for (var i = 0; i < vendors.length; i++) {
|
|
383
|
+
if (_flows[vendors[i]].terminalId === terminalId) return true;
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
handleVendorLoginMessage: handleVendorLoginMessage,
|
|
390
|
+
sendStateTo: sendStateTo,
|
|
391
|
+
listFlows: listFlows,
|
|
392
|
+
forgetTerminal: forgetTerminal,
|
|
393
|
+
isLoginTerminal: isLoginTerminal,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
module.exports = { attachVendorLogin: attachVendorLogin };
|
package/lib/project.js
CHANGED
|
@@ -34,6 +34,7 @@ var { attachSessions } = require("./project-sessions");
|
|
|
34
34
|
var { attachModels } = require("./project-models");
|
|
35
35
|
var { attachUserMessage } = require("./project-user-message");
|
|
36
36
|
var { attachShellCommand } = require("./project-shell-command");
|
|
37
|
+
var { attachVendorLogin } = require("./project-vendor-login");
|
|
37
38
|
var { attachConnection } = require("./project-connection");
|
|
38
39
|
var { attachMcp } = require("./project-mcp");
|
|
39
40
|
var { createLocalMcp } = require("./mcp-local");
|
|
@@ -1286,6 +1287,9 @@ function createProjectContext(opts) {
|
|
|
1286
1287
|
// --- Shell command context ---
|
|
1287
1288
|
if (_shellCommand.handleShellCommand(ws, msg)) return;
|
|
1288
1289
|
|
|
1290
|
+
// --- Vendor login flow (delegated to project-vendor-login.js) ---
|
|
1291
|
+
if (_vendorLogin.handleVendorLoginMessage(ws, msg)) return;
|
|
1292
|
+
|
|
1289
1293
|
// --- Notes, terminals, context, user message (delegated to project-user-message.js) ---
|
|
1290
1294
|
if (_userMessage.handleUserMessage(ws, msg)) return;
|
|
1291
1295
|
}
|
|
@@ -1593,6 +1597,21 @@ function createProjectContext(opts) {
|
|
|
1593
1597
|
getOsUserInfoForWs: getOsUserInfoForWs,
|
|
1594
1598
|
});
|
|
1595
1599
|
|
|
1600
|
+
// --- Vendor login flow (delegated to project-vendor-login.js) ---
|
|
1601
|
+
var _vendorLogin = attachVendorLogin({
|
|
1602
|
+
slug: slug,
|
|
1603
|
+
osUsers: osUsers,
|
|
1604
|
+
sm: sm,
|
|
1605
|
+
tm: tm,
|
|
1606
|
+
adapters: adapters,
|
|
1607
|
+
send: send,
|
|
1608
|
+
sendTo: sendTo,
|
|
1609
|
+
usersModule: usersModule,
|
|
1610
|
+
getOsUserInfoForWs: getOsUserInfoForWs,
|
|
1611
|
+
getOsUserInfoForLinuxUser: getOsUserInfoForLinuxUser,
|
|
1612
|
+
getLinuxUserForSession: getLinuxUserForSession,
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1596
1615
|
// --- Filesystem handler (delegated to project-filesystem.js) ---
|
|
1597
1616
|
var _filesystem = attachFilesystem({
|
|
1598
1617
|
cwd: cwd,
|
|
@@ -1866,6 +1885,7 @@ function createProjectContext(opts) {
|
|
|
1866
1885
|
_mcp: _mcp,
|
|
1867
1886
|
_notifications: _notifications,
|
|
1868
1887
|
_splitGroups: _splitGroups,
|
|
1888
|
+
_vendorLogin: _vendorLogin,
|
|
1869
1889
|
resolveSessionForView: _sessions.resolveSessionForView,
|
|
1870
1890
|
hydrateImageRefs: hydrateImageRefs,
|
|
1871
1891
|
broadcastClientCount: broadcastClientCount,
|
|
@@ -37,7 +37,7 @@ import { updateSettingsStats, updateDaemonConfig, handleSetPinResult, handleKeep
|
|
|
37
37
|
import { handleTermList, handleTermCreated, sendTerminalCommand, handleTermOutput, handleTermResized, handleTermExited, handleTermClosed } from './terminal.js';
|
|
38
38
|
import { attachTuiView, detachTuiView, setTuiSuspendedView, tuiHandleTermOutput, tuiHandleTermResized, tuiHandleTermExited, tuiHandleTermClosed } from './session-tui-view.js';
|
|
39
39
|
import { handleTuiTranscriptState } from './tui-grab.js';
|
|
40
|
-
import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTermExited, tuiModalHandleTermClosed
|
|
40
|
+
import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTermExited, tuiModalHandleTermClosed } from './tui-attention.js';
|
|
41
41
|
import { updateTerminalList, handleContextSourcesState, updateEmailAccountList, updateEmailUnreadCounts, handleEmailTestResult, handleEmailAddResult, handleEmailRemoveResult } from './context-sources.js';
|
|
42
42
|
import { refreshEmailSettings } from './user-settings.js';
|
|
43
43
|
import { handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted, handleNoteWritten } from './sticky-notes.js';
|
|
@@ -72,7 +72,8 @@ import { handleRemoteCursorMove, handleRemoteCursorLeave, handleRemoteSelection,
|
|
|
72
72
|
import { showLoopBanner, updateLoopBanner, updateLoopInputVisibility, showRalphApprovalBar, updateRalphApprovalStatus, openRalphPreviewModal, showExecModal, updateExecModalStatus } from './app-loop-ui.js';
|
|
73
73
|
import { showDebateSticky, showDebateConcludeConfirm, showDebateUserFloor, exitDebateFloorMode, exitDebateConcludeMode, exitDebateEndedMode, updateDebateRound, renderDebateUserFloorDone } from './app-debate-ui.js';
|
|
74
74
|
import { handleSkillInstallWs } from './app-skills-install.js';
|
|
75
|
-
import { handleNotificationsState, handleNotificationCreated, handleNotificationDismissed, handleNotificationDismissedAll, showUpdateBanner
|
|
75
|
+
import { handleNotificationsState, handleNotificationCreated, handleNotificationDismissed, handleNotificationDismissedAll, showUpdateBanner } from './app-notifications.js';
|
|
76
|
+
import { autoStartLoginIfNeeded, handleVendorLoginReady, handleVendorLoginState, handleVendorLoginError, handleAuthRefreshed } from './vendor-login.js';
|
|
76
77
|
import { handleDebatePreparing, handleDebateBriefReady, renderDebateBriefReady, handleDebateStarted, renderDebateStarted, handleDebateTurn, handleDebateActivity, handleDebateStream, handleDebateTurnDone, handleDebateCommentQueued, handleDebateCommentInjected, renderDebateCommentInjected, handleDebateResumed, handleDebateEnded, renderDebateEnded, handleDebateError, isDebateActive, renderMcpDebateProposal, renderDebateUserResume } from './debate.js';
|
|
77
78
|
import { handleMentionStart, handleMentionActivity, handleMentionStream, handleMentionDone, handleMentionError, renderMentionUser, renderMentionResponse, renderUserMention } from './mention.js';
|
|
78
79
|
|
|
@@ -1299,11 +1300,28 @@ export function processMessage(msg) {
|
|
|
1299
1300
|
appendDelta((msg.text || "Authentication required.") + "\n");
|
|
1300
1301
|
setStatus("connected");
|
|
1301
1302
|
if (!store.get('loopActive')) enableMainInput();
|
|
1302
|
-
//
|
|
1303
|
-
//
|
|
1303
|
+
// Open the login flow for this vendor. The server keeps exactly one
|
|
1304
|
+
// login terminal per vendor, so repeat auth_required events (other
|
|
1305
|
+
// sessions, split panes, a 401 retry burst) do not stack up.
|
|
1304
1306
|
autoStartLoginIfNeeded(msg);
|
|
1305
1307
|
break;
|
|
1306
1308
|
|
|
1309
|
+
case "vendor_login_ready":
|
|
1310
|
+
handleVendorLoginReady(msg);
|
|
1311
|
+
break;
|
|
1312
|
+
|
|
1313
|
+
case "vendor_login_state":
|
|
1314
|
+
handleVendorLoginState(msg);
|
|
1315
|
+
break;
|
|
1316
|
+
|
|
1317
|
+
case "vendor_login_error":
|
|
1318
|
+
handleVendorLoginError(msg);
|
|
1319
|
+
break;
|
|
1320
|
+
|
|
1321
|
+
case "auth_refreshed":
|
|
1322
|
+
handleAuthRefreshed(msg);
|
|
1323
|
+
break;
|
|
1324
|
+
|
|
1307
1325
|
case "rate_limit":
|
|
1308
1326
|
handleRateLimitEvent(msg);
|
|
1309
1327
|
break;
|
|
@@ -1492,19 +1510,8 @@ export function processMessage(msg) {
|
|
|
1492
1510
|
break;
|
|
1493
1511
|
|
|
1494
1512
|
case "term_created":
|
|
1495
|
-
// Login
|
|
1496
|
-
//
|
|
1497
|
-
// so the login URL is visible) instead of the sidebar terminal.
|
|
1498
|
-
if (store.get('pendingLoginModal')) {
|
|
1499
|
-
var _lm = store.get('pendingLoginModal');
|
|
1500
|
-
store.set({ pendingLoginModal: null });
|
|
1501
|
-
openTuiModal(msg.id, _lm.slug, {
|
|
1502
|
-
sessionTitle: (VENDOR_NAMES[_lm.vendor] || "Claude Code") + " login",
|
|
1503
|
-
projectName: _lm.slug,
|
|
1504
|
-
compact: true,
|
|
1505
|
-
});
|
|
1506
|
-
break;
|
|
1507
|
-
}
|
|
1513
|
+
// Login terminals never come through here: they are created by the
|
|
1514
|
+
// server-owned flow and announced with vendor_login_ready instead.
|
|
1508
1515
|
handleTermCreated(msg);
|
|
1509
1516
|
if (store.get('pendingTermCommand')) {
|
|
1510
1517
|
var cmd = store.get('pendingTermCommand');
|
|
@@ -10,8 +10,8 @@ import { showHomeHub } from './app-home-hub.js';
|
|
|
10
10
|
import { openHomeChat } from './home-mate-chat.js';
|
|
11
11
|
import { getCachedProjects, switchProject, renderProjectList } from './app-projects.js';
|
|
12
12
|
import { mateAvatarUrl, userAvatarUrl } from './avatar.js';
|
|
13
|
-
import { openTerminal } from './terminal.js';
|
|
14
13
|
import { openTuiModal } from './tui-attention.js';
|
|
14
|
+
import { requestVendorLogin } from './vendor-login.js';
|
|
15
15
|
import { startUrgentBlink, stopUrgentBlink } from './app-favicon.js';
|
|
16
16
|
import { playDoneSound, isNotifSoundEnabled } from './notifications.js';
|
|
17
17
|
var notifications = [];
|
|
@@ -47,7 +47,6 @@ var pendingTuiTotal = 0;
|
|
|
47
47
|
// (next hour) acts as a fresh ping.
|
|
48
48
|
var pendingUpdateMsg = null;
|
|
49
49
|
var activeAuthRequiredMsg = null;
|
|
50
|
-
var authReminderVisible = false;
|
|
51
50
|
|
|
52
51
|
// ========================================================
|
|
53
52
|
// Init
|
|
@@ -91,7 +90,6 @@ function showAllBanners() {
|
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
if (activeAuthRequiredMsg) showAuthRequiredBanner(activeAuthRequiredMsg);
|
|
94
|
-
if (authReminderVisible) showLoginReminderBanner();
|
|
95
93
|
|
|
96
94
|
// Check if any banner actually got rendered (update/auth banners can be suppressed)
|
|
97
95
|
var hasVisibleBanner = bannerContainer.children.length > 0;
|
|
@@ -266,8 +264,9 @@ function showBanner(notif, autoDismissMs) {
|
|
|
266
264
|
removeBanner(banner);
|
|
267
265
|
dismissNotif(notif.id);
|
|
268
266
|
var authMeta = notif.meta || {};
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
// Deliberate click: re-attach to a live login terminal instead of
|
|
268
|
+
// spawning a second one (the server dedups per vendor).
|
|
269
|
+
requestVendorLogin(authMeta.vendor || "claude", { sessionId: notif.sessionId || null });
|
|
271
270
|
});
|
|
272
271
|
}
|
|
273
272
|
}
|
|
@@ -325,118 +324,6 @@ function showBanner(notif, autoDismissMs) {
|
|
|
325
324
|
}
|
|
326
325
|
}
|
|
327
326
|
|
|
328
|
-
// Open the login terminal as a modal dialog (the same modal used for TUI
|
|
329
|
-
// attention), running the vendor login command. The terminal is created with
|
|
330
|
-
// the command as its initial input, so when the modal attaches it replays the
|
|
331
|
-
// scrollback and the user sees the login URL / prompts immediately.
|
|
332
|
-
// Falls back to the sidebar terminal if the current project slug is unknown
|
|
333
|
-
// (the modal needs a slug to open its own WS to the project).
|
|
334
|
-
function currentProjectSlug() {
|
|
335
|
-
var slug = store.get('currentSlug') || "";
|
|
336
|
-
if (slug) return slug;
|
|
337
|
-
// Fallback: derive from the URL (/p/<slug>/...).
|
|
338
|
-
try {
|
|
339
|
-
var m = (window.location.pathname || "").match(/^\/p\/([a-z0-9_-]+)/);
|
|
340
|
-
if (m) return m[1];
|
|
341
|
-
} catch (e) {}
|
|
342
|
-
return "";
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function startLoginInModal(loginCommand, vendor) {
|
|
346
|
-
if (authReminderVisible) return;
|
|
347
|
-
var ws = getWs();
|
|
348
|
-
if (!ws || ws.readyState !== 1) return;
|
|
349
|
-
var cmd = loginCommand || getVendorLoginCommand(vendor);
|
|
350
|
-
var slug = currentProjectSlug();
|
|
351
|
-
if (!slug) { startLoginCommand(cmd); return; }
|
|
352
|
-
store.set({ pendingLoginModal: { slug: slug, vendor: vendor || "claude" } });
|
|
353
|
-
ws.send(JSON.stringify({
|
|
354
|
-
type: "term_create",
|
|
355
|
-
cols: 100,
|
|
356
|
-
rows: 30,
|
|
357
|
-
initialCommand: cmd + "\n",
|
|
358
|
-
}));
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Sidebar-terminal fallback (used when no slug is available for the modal).
|
|
362
|
-
function startLoginCommand(loginCommand) {
|
|
363
|
-
if (authReminderVisible) return;
|
|
364
|
-
var ws = getWs();
|
|
365
|
-
if (!ws || ws.readyState !== 1) return;
|
|
366
|
-
var termCommand = (loginCommand || "claude login") + "\n";
|
|
367
|
-
store.set({ pendingTermCommand: termCommand });
|
|
368
|
-
ws.send(JSON.stringify({ type: "term_create", cols: 80, rows: 24 }));
|
|
369
|
-
openTerminal();
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Auto-open the login modal and run the login command when the server reports
|
|
373
|
-
// the vendor isn't logged in. The popup terminal runs as the connected user's
|
|
374
|
-
// own OS identity (term_create -> getOsUserInfoForWs), so login always targets
|
|
375
|
-
// the right account — we pop it unconditionally rather than gating on
|
|
376
|
-
// canAutoLogin. Idempotent: startLoginInModal is guarded by authReminderVisible
|
|
377
|
-
// so repeat auth_required events no-op.
|
|
378
|
-
export function autoStartLoginIfNeeded(msg) {
|
|
379
|
-
if (!msg) return false;
|
|
380
|
-
if (authReminderVisible) return false;
|
|
381
|
-
var vendor = msg.vendor || "claude";
|
|
382
|
-
var cmd = msg.loginCommand
|
|
383
|
-
|| getVendorLoginCommand(vendor);
|
|
384
|
-
startLoginInModal(cmd, vendor);
|
|
385
|
-
showLoginReminderBanner();
|
|
386
|
-
return true;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function showLoginReminderBanner() {
|
|
390
|
-
if (!bannerContainer) return;
|
|
391
|
-
authReminderVisible = true;
|
|
392
|
-
var existing = bannerContainer.querySelector('[data-auth-reminder="true"]');
|
|
393
|
-
if (existing) removeBanner(existing);
|
|
394
|
-
var banner = document.createElement("div");
|
|
395
|
-
banner.className = "notif-banner notif-banner-update";
|
|
396
|
-
banner.setAttribute("data-notif-id", "_auth_reminder");
|
|
397
|
-
banner.setAttribute("data-auth-reminder", "true");
|
|
398
|
-
banner.innerHTML =
|
|
399
|
-
'<div class="notif-banner-icon">' + iconHtml("check-circle") + '</div>' +
|
|
400
|
-
'<div class="notif-banner-body">' +
|
|
401
|
-
'<div class="notif-banner-project">CLAY</div>' +
|
|
402
|
-
'<div class="notif-banner-title">Login started</div>' +
|
|
403
|
-
'<div class="notif-banner-text">After the login completes, open a new session so Clay picks up the fresh auth state.</div>' +
|
|
404
|
-
'<div class="notif-banner-actions">' +
|
|
405
|
-
'<button class="notif-banner-new-session notif-banner-update-now">Open new session</button>' +
|
|
406
|
-
'</div>' +
|
|
407
|
-
'</div>' +
|
|
408
|
-
'<button class="notif-banner-close">' + iconHtml("x") + '</button>';
|
|
409
|
-
|
|
410
|
-
bannerContainer.appendChild(banner);
|
|
411
|
-
refreshIcons();
|
|
412
|
-
|
|
413
|
-
requestAnimationFrame(function () {
|
|
414
|
-
banner.classList.add("show");
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
var newSessionBtn = banner.querySelector(".notif-banner-new-session");
|
|
418
|
-
if (newSessionBtn) {
|
|
419
|
-
newSessionBtn.addEventListener("click", function (e) {
|
|
420
|
-
e.stopPropagation();
|
|
421
|
-
authReminderVisible = false;
|
|
422
|
-
var ws = getWs();
|
|
423
|
-
if (ws && ws.readyState === 1) {
|
|
424
|
-
ws.send(JSON.stringify({ type: "new_session" }));
|
|
425
|
-
}
|
|
426
|
-
removeBanner(banner);
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
var closeBtn = banner.querySelector(".notif-banner-close");
|
|
431
|
-
if (closeBtn) {
|
|
432
|
-
closeBtn.addEventListener("click", function (e) {
|
|
433
|
-
e.stopPropagation();
|
|
434
|
-
authReminderVisible = false;
|
|
435
|
-
removeBanner(banner);
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
327
|
export function showAuthRequiredBanner(msg) {
|
|
441
328
|
if (!bannerContainer) return;
|
|
442
329
|
var vendor = (msg && (msg.vendor || (msg.meta && msg.meta.vendor))) || "claude";
|
|
@@ -23,6 +23,15 @@ export function reportPaneContext(data) {
|
|
|
23
23
|
}, window.location.origin);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// A pane has no banner surface and no login modal of its own, so both panes
|
|
27
|
+
// hitting auth_required would otherwise race to start their own login flow.
|
|
28
|
+
// Hand the event to the parent shell, which owns the single flow.
|
|
29
|
+
export function forwardPaneAuthRequired(message) {
|
|
30
|
+
if (!store.get('paneMode') || window.parent === window) return false;
|
|
31
|
+
window.parent.postMessage({ type: "clay-pane-auth-required", message: message }, window.location.origin);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
export function forwardPaneMarkdownPresentation(message) {
|
|
27
36
|
if (!store.get('paneMode') || window.parent === window) return false;
|
|
28
37
|
window.parent.postMessage({ type: "clay-pane-present-markdown", message: message }, window.location.origin);
|
|
@@ -11,6 +11,7 @@ import { groupedSessionIds, findSplitGroup } from './split-group-helpers.js';
|
|
|
11
11
|
import { showConfirm } from './app-misc.js';
|
|
12
12
|
import { syncPairChrome } from './split-pair-ui.js';
|
|
13
13
|
import { presentMarkdownEdit } from './filebrowser.js';
|
|
14
|
+
import { autoStartLoginIfNeeded } from './vendor-login.js';
|
|
14
15
|
|
|
15
16
|
var host = null;
|
|
16
17
|
var nativeApp = null;
|
|
@@ -276,6 +277,12 @@ function handlePaneMessage(event) {
|
|
|
276
277
|
if (present) presentMarkdownEdit(present);
|
|
277
278
|
return;
|
|
278
279
|
}
|
|
280
|
+
// Both panes can report auth_required for their own sessions. The shell runs
|
|
281
|
+
// one login flow for the project; repeat events are no-ops there.
|
|
282
|
+
if (msg.type === "clay-pane-auth-required") {
|
|
283
|
+
if (msg.message) autoStartLoginIfNeeded(msg.message);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
279
286
|
if (msg.type !== "clay-pane-context") return;
|
|
280
287
|
var frames = host.querySelectorAll(".split-pane-frame");
|
|
281
288
|
for (var i = 0; i < frames.length; i++) {
|
|
@@ -29,6 +29,10 @@ var modalWs = null;
|
|
|
29
29
|
var modalResizeObserver = null;
|
|
30
30
|
var modalKeyHandler = null;
|
|
31
31
|
var modalResizeDebounce = null;
|
|
32
|
+
// Optional per-open callback fired once the modal is torn down, whatever
|
|
33
|
+
// dismissed it (Esc, backdrop click, term_closed). The login flow uses it to
|
|
34
|
+
// cancel a login the user walked away from.
|
|
35
|
+
var modalCloseHook = null;
|
|
32
36
|
// Debounced fit+redraw for the modal xterm. Same rationale as
|
|
33
37
|
// session-tui-view.js: collapses rapid resize events into a single
|
|
34
38
|
// SIGWINCH so claude can redraw cleanly without mid-resize corruption.
|
|
@@ -139,6 +143,7 @@ export function openTuiModal(terminalId, sourceSlug, info) {
|
|
|
139
143
|
modalTerminalId = terminalId;
|
|
140
144
|
modalSourceSlug = sourceSlug;
|
|
141
145
|
var infoObj = info || {};
|
|
146
|
+
modalCloseHook = typeof infoObj.onClose === "function" ? infoObj.onClose : null;
|
|
142
147
|
setModalBreadcrumb({
|
|
143
148
|
projectIcon: infoObj.projectIcon || null,
|
|
144
149
|
projectName: infoObj.projectName || sourceSlug,
|
|
@@ -232,6 +237,8 @@ export function openTuiModal(terminalId, sourceSlug, info) {
|
|
|
232
237
|
|
|
233
238
|
export function closeTuiModal() {
|
|
234
239
|
if (!modalEl) return;
|
|
240
|
+
var closeHook = modalCloseHook;
|
|
241
|
+
modalCloseHook = null;
|
|
235
242
|
if (modalTerminalId != null && modalWs && modalWs.readyState === 1) {
|
|
236
243
|
try { modalWs.send(JSON.stringify({ type: "term_detach", id: modalTerminalId })); } catch (e) {}
|
|
237
244
|
}
|
|
@@ -247,6 +254,9 @@ export function closeTuiModal() {
|
|
|
247
254
|
document.removeEventListener("keydown", modalKeyHandler);
|
|
248
255
|
modalKeyHandler = null;
|
|
249
256
|
}
|
|
257
|
+
if (closeHook) {
|
|
258
|
+
try { closeHook(); } catch (e) {}
|
|
259
|
+
}
|
|
250
260
|
}
|
|
251
261
|
|
|
252
262
|
export function isTuiModalOpen() {
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// vendor-login.js - Vendor login (auth_required) recovery flow.
|
|
2
|
+
//
|
|
3
|
+
// The server owns the login terminal (see lib/project-vendor-login.js): exactly
|
|
4
|
+
// one per vendor per project, tracked server-side, killed once the login lands.
|
|
5
|
+
// This module is the client half of that contract - it asks for the flow, opens
|
|
6
|
+
// the modal on whatever terminal the server hands back, and reflects the flow's
|
|
7
|
+
// state in a banner.
|
|
8
|
+
//
|
|
9
|
+
// Nothing here spawns a terminal speculatively. Every auth_required after the
|
|
10
|
+
// first is a no-op while a flow is live, which is what stops the login-terminal
|
|
11
|
+
// pile-up and the endless auth_required -> login -> 401 loop.
|
|
12
|
+
|
|
13
|
+
import { store } from './store.js';
|
|
14
|
+
import { getWs } from './ws-ref.js';
|
|
15
|
+
import { refreshIcons, iconHtml } from './icons.js';
|
|
16
|
+
import { openTuiModal, closeTuiModal, getTuiModalTerminalId } from './tui-attention.js';
|
|
17
|
+
import { forwardPaneAuthRequired } from './pane-bridge.js';
|
|
18
|
+
|
|
19
|
+
// vendor -> { terminalId, startedAt } as last broadcast by the server.
|
|
20
|
+
var activeFlows = Object.create(null);
|
|
21
|
+
// vendor -> true between sending vendor_login_start and hearing back.
|
|
22
|
+
var pendingStarts = Object.create(null);
|
|
23
|
+
// Vendor whose login terminal the modal is currently showing.
|
|
24
|
+
var modalVendor = null;
|
|
25
|
+
// Set while this module tears the modal down itself, so the modal's close hook
|
|
26
|
+
// does not read a programmatic close as "the user gave up".
|
|
27
|
+
var closingModalInternally = false;
|
|
28
|
+
|
|
29
|
+
function vendorDisplayName(vendor) {
|
|
30
|
+
var vendors = store.get('vendorInfo') || {};
|
|
31
|
+
var info = vendors[vendor];
|
|
32
|
+
if (info && info.displayName) return info.displayName;
|
|
33
|
+
return vendor === "codex" ? "Codex" : "Claude Code";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function currentProjectSlug() {
|
|
37
|
+
var slug = store.get('currentSlug') || "";
|
|
38
|
+
if (slug) return slug;
|
|
39
|
+
// Fallback: derive from the URL (/p/<slug>/...).
|
|
40
|
+
try {
|
|
41
|
+
var m = (window.location.pathname || "").match(/^\/p\/([a-z0-9_-]+)/);
|
|
42
|
+
if (m) return m[1];
|
|
43
|
+
} catch (e) {}
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The banner surface belongs to the notification center. Query it rather than
|
|
48
|
+
// importing app-notifications so the dependency stays one-directional
|
|
49
|
+
// (app-notifications -> vendor-login).
|
|
50
|
+
function bannerContainer() {
|
|
51
|
+
return document.querySelector(".notif-banner-container");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function removeBannerEl(el) {
|
|
55
|
+
if (!el) return;
|
|
56
|
+
el.classList.remove("show");
|
|
57
|
+
setTimeout(function () {
|
|
58
|
+
if (el.parentNode) el.parentNode.removeChild(el);
|
|
59
|
+
}, 300);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clearLoginBanners() {
|
|
63
|
+
var container = bannerContainer();
|
|
64
|
+
if (!container) return;
|
|
65
|
+
var existing = container.querySelectorAll('[data-vendor-login="true"]');
|
|
66
|
+
for (var i = 0; i < existing.length; i++) removeBannerEl(existing[i]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function showLoginBanner(opts) {
|
|
70
|
+
var container = bannerContainer();
|
|
71
|
+
if (!container) return null;
|
|
72
|
+
clearLoginBanners();
|
|
73
|
+
|
|
74
|
+
var banner = document.createElement("div");
|
|
75
|
+
banner.className = "notif-banner notif-banner-update";
|
|
76
|
+
banner.setAttribute("data-notif-id", "_vendor_login");
|
|
77
|
+
banner.setAttribute("data-vendor-login", "true");
|
|
78
|
+
banner.innerHTML =
|
|
79
|
+
'<div class="notif-banner-icon">' + iconHtml(opts.icon || "check-circle") + '</div>' +
|
|
80
|
+
'<div class="notif-banner-body">' +
|
|
81
|
+
'<div class="notif-banner-project">CLAY</div>' +
|
|
82
|
+
'<div class="notif-banner-title">' + opts.title + '</div>' +
|
|
83
|
+
'<div class="notif-banner-text">' + opts.text + '</div>' +
|
|
84
|
+
'</div>' +
|
|
85
|
+
'<button class="notif-banner-close">' + iconHtml("x") + '</button>';
|
|
86
|
+
|
|
87
|
+
container.appendChild(banner);
|
|
88
|
+
refreshIcons();
|
|
89
|
+
requestAnimationFrame(function () { banner.classList.add("show"); });
|
|
90
|
+
|
|
91
|
+
var closeBtn = banner.querySelector(".notif-banner-close");
|
|
92
|
+
if (closeBtn) {
|
|
93
|
+
closeBtn.addEventListener("click", function (e) {
|
|
94
|
+
e.stopPropagation();
|
|
95
|
+
removeBannerEl(banner);
|
|
96
|
+
if (opts.onDismiss) opts.onDismiss();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (typeof opts.autoDismissMs === "number") {
|
|
100
|
+
setTimeout(function () { removeBannerEl(banner); }, opts.autoDismissMs);
|
|
101
|
+
}
|
|
102
|
+
return banner;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function showLoginStartedBanner(vendor) {
|
|
106
|
+
showLoginBanner({
|
|
107
|
+
icon: "check-circle",
|
|
108
|
+
title: vendorDisplayName(vendor) + " login started",
|
|
109
|
+
text: "Finish the sign-in in the terminal. Clay reloads the credentials on its own, so your sessions keep working - no new session needed.",
|
|
110
|
+
onDismiss: function () { cancelVendorLogin(vendor); },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function showLoginCompleteBanner(vendor) {
|
|
115
|
+
showLoginBanner({
|
|
116
|
+
icon: "check-circle",
|
|
117
|
+
title: "Signed in to " + vendorDisplayName(vendor),
|
|
118
|
+
text: "Credentials reloaded. Send your next message to continue.",
|
|
119
|
+
autoDismissMs: 6000,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function showLoginErrorBanner(vendor, error) {
|
|
124
|
+
showLoginBanner({
|
|
125
|
+
icon: "alert-triangle",
|
|
126
|
+
title: vendorDisplayName(vendor) + " login could not start",
|
|
127
|
+
text: error || "The login terminal could not be created.",
|
|
128
|
+
autoDismissMs: 8000,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function closeModalInternally() {
|
|
133
|
+
modalVendor = null;
|
|
134
|
+
closingModalInternally = true;
|
|
135
|
+
try {
|
|
136
|
+
closeTuiModal();
|
|
137
|
+
} finally {
|
|
138
|
+
closingModalInternally = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function closeModalForVendor(vendor, terminalId) {
|
|
143
|
+
if (modalVendor !== vendor) return;
|
|
144
|
+
var openId = getTuiModalTerminalId();
|
|
145
|
+
if (typeof terminalId === "number" && openId != null && openId !== terminalId) return;
|
|
146
|
+
closeModalInternally();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ========================================================
|
|
150
|
+
// Outgoing
|
|
151
|
+
// ========================================================
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Ask the server to start (or hand back) the login terminal for a vendor.
|
|
155
|
+
* opts.auto marks a request triggered by an auth_required event; those never
|
|
156
|
+
* open a second prompt while a flow is already running.
|
|
157
|
+
*/
|
|
158
|
+
export function requestVendorLogin(vendor, opts) {
|
|
159
|
+
var options = opts || {};
|
|
160
|
+
var target = vendor || "claude";
|
|
161
|
+
if (options.auto && (activeFlows[target] || pendingStarts[target])) return false;
|
|
162
|
+
|
|
163
|
+
var ws = getWs();
|
|
164
|
+
if (!ws || ws.readyState !== 1) return false;
|
|
165
|
+
|
|
166
|
+
pendingStarts[target] = true;
|
|
167
|
+
ws.send(JSON.stringify({
|
|
168
|
+
type: "vendor_login_start",
|
|
169
|
+
vendor: target,
|
|
170
|
+
auto: !!options.auto,
|
|
171
|
+
sessionId: options.sessionId || store.get('activeSessionId') || null,
|
|
172
|
+
}));
|
|
173
|
+
showLoginStartedBanner(target);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function cancelVendorLogin(vendor) {
|
|
178
|
+
var target = vendor || modalVendor;
|
|
179
|
+
if (!target) return;
|
|
180
|
+
delete pendingStarts[target];
|
|
181
|
+
delete activeFlows[target];
|
|
182
|
+
clearLoginBanners();
|
|
183
|
+
if (modalVendor === target) closeModalInternally();
|
|
184
|
+
var ws = getWs();
|
|
185
|
+
if (ws && ws.readyState === 1) {
|
|
186
|
+
ws.send(JSON.stringify({ type: "vendor_login_cancel", vendor: target }));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Auto-open the login flow when the server reports the vendor isn't logged in.
|
|
192
|
+
*
|
|
193
|
+
* Split panes have no banner surface of their own (the parent shell owns the
|
|
194
|
+
* single visible one), so a pane hands the event up instead of running two
|
|
195
|
+
* competing flows.
|
|
196
|
+
*/
|
|
197
|
+
export function autoStartLoginIfNeeded(msg) {
|
|
198
|
+
if (!msg) return false;
|
|
199
|
+
if (store.get('paneMode')) {
|
|
200
|
+
return forwardPaneAuthRequired({
|
|
201
|
+
vendor: msg.vendor || "claude",
|
|
202
|
+
sessionId: store.get('activeSessionId') || null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
var vendor = msg.vendor || "claude";
|
|
206
|
+
if (activeFlows[vendor] || pendingStarts[vendor]) return false;
|
|
207
|
+
return requestVendorLogin(vendor, { auto: true, sessionId: msg.sessionId || null });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ========================================================
|
|
211
|
+
// Incoming
|
|
212
|
+
// ========================================================
|
|
213
|
+
|
|
214
|
+
export function handleVendorLoginReady(msg) {
|
|
215
|
+
if (!msg || typeof msg.terminalId !== "number") return;
|
|
216
|
+
var vendor = msg.vendor || "claude";
|
|
217
|
+
delete pendingStarts[vendor];
|
|
218
|
+
activeFlows[vendor] = { terminalId: msg.terminalId, startedAt: Date.now() };
|
|
219
|
+
|
|
220
|
+
var slug = msg.slug || currentProjectSlug();
|
|
221
|
+
if (!slug) return;
|
|
222
|
+
modalVendor = vendor;
|
|
223
|
+
openTuiModal(msg.terminalId, slug, {
|
|
224
|
+
sessionTitle: vendorDisplayName(vendor) + " login",
|
|
225
|
+
projectName: slug,
|
|
226
|
+
compact: true,
|
|
227
|
+
// Dismissing the modal abandons the login: kill the terminal server-side
|
|
228
|
+
// so it never lingers in the sidebar terminal list.
|
|
229
|
+
onClose: function () {
|
|
230
|
+
if (closingModalInternally) return;
|
|
231
|
+
modalVendor = null;
|
|
232
|
+
cancelVendorLogin(vendor);
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function handleVendorLoginState(msg) {
|
|
238
|
+
if (!msg) return;
|
|
239
|
+
var next = Object.create(null);
|
|
240
|
+
var flows = msg.flows || [];
|
|
241
|
+
for (var i = 0; i < flows.length; i++) {
|
|
242
|
+
next[flows[i].vendor] = { terminalId: flows[i].terminalId, startedAt: flows[i].startedAt };
|
|
243
|
+
delete pendingStarts[flows[i].vendor];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A flow the server dropped without an auth_refreshed (cancelled elsewhere,
|
|
247
|
+
// terminal killed from the sidebar) must not leave a stale modal behind.
|
|
248
|
+
var previous = Object.keys(activeFlows);
|
|
249
|
+
for (var p = 0; p < previous.length; p++) {
|
|
250
|
+
if (!next[previous[p]] && modalVendor === previous[p]) {
|
|
251
|
+
closeModalInternally();
|
|
252
|
+
clearLoginBanners();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
activeFlows = next;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function handleAuthRefreshed(msg) {
|
|
259
|
+
if (!msg) return;
|
|
260
|
+
var vendor = msg.vendor || "claude";
|
|
261
|
+
var flow = activeFlows[vendor];
|
|
262
|
+
delete pendingStarts[vendor];
|
|
263
|
+
delete activeFlows[vendor];
|
|
264
|
+
closeModalForVendor(vendor, flow ? flow.terminalId : undefined);
|
|
265
|
+
clearLoginBanners();
|
|
266
|
+
showLoginCompleteBanner(vendor);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function handleVendorLoginError(msg) {
|
|
270
|
+
if (!msg) return;
|
|
271
|
+
var vendor = msg.vendor || "claude";
|
|
272
|
+
delete pendingStarts[vendor];
|
|
273
|
+
delete activeFlows[vendor];
|
|
274
|
+
if (modalVendor === vendor) closeModalInternally();
|
|
275
|
+
clearLoginBanners();
|
|
276
|
+
showLoginErrorBanner(vendor, msg.error);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function requestVendorLoginState() {
|
|
280
|
+
var ws = getWs();
|
|
281
|
+
if (!ws || ws.readyState !== 1) return;
|
|
282
|
+
ws.send(JSON.stringify({ type: "vendor_login_state_request" }));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function getActiveLoginFlow(vendor) {
|
|
286
|
+
return activeFlows[vendor] || null;
|
|
287
|
+
}
|
package/lib/tools-registry.js
CHANGED
|
@@ -137,7 +137,17 @@ function seedBuiltInCapsules(ctx) {
|
|
|
137
137
|
for (var ri = 0; ri < REMOVED_BUILTIN_IDS.length; ri++) {
|
|
138
138
|
fs.rmSync(path.join(root, REMOVED_BUILTIN_IDS[ri]), { recursive: true, force: true });
|
|
139
139
|
}
|
|
140
|
-
|
|
140
|
+
// Clay ships no built-in Capsules any more, so lib/capsules/ is absent from a
|
|
141
|
+
// fresh checkout and from the npm tarball (neither git nor npm carries an
|
|
142
|
+
// empty directory). Seeding still owns the removed-built-in migration above
|
|
143
|
+
// and the marker below, so an absent root means "nothing to copy" rather than
|
|
144
|
+
// a failure. Anything other than a missing root is still a real error.
|
|
145
|
+
var entries = [];
|
|
146
|
+
try {
|
|
147
|
+
entries = fs.readdirSync(CAPSULES_ROOT, { withFileTypes: true });
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e.code !== "ENOENT") throw e;
|
|
150
|
+
}
|
|
141
151
|
var seeded = [];
|
|
142
152
|
for (var i = 0; i < entries.length; i++) {
|
|
143
153
|
if (!entries[i].isDirectory()) continue;
|
package/lib/ws-schema.js
CHANGED
|
@@ -171,6 +171,13 @@ var schema = {
|
|
|
171
171
|
// Auth
|
|
172
172
|
// -----------------------------------------------------------------------
|
|
173
173
|
"auth_required": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Authentication required (e.g. API key needed)" },
|
|
174
|
+
"vendor_login_start": { direction: "c2s", handler: "lib/project-vendor-login.js", description: "Start (or re-attach to) the per-vendor login terminal" },
|
|
175
|
+
"vendor_login_cancel": { direction: "c2s", handler: "lib/project-vendor-login.js", description: "Dismiss the login flow and kill its terminal" },
|
|
176
|
+
"vendor_login_state_request": { direction: "c2s", handler: "lib/project-vendor-login.js", description: "Ask for the project's active login flows" },
|
|
177
|
+
"vendor_login_ready": { direction: "s2c", handler: "lib/public/modules/vendor-login.js", description: "Login terminal is available; open/re-attach the modal to it" },
|
|
178
|
+
"vendor_login_state": { direction: "s2c", handler: "lib/public/modules/vendor-login.js", description: "Active login flows for this project (one per vendor)" },
|
|
179
|
+
"vendor_login_error": { direction: "s2c", handler: "lib/public/modules/vendor-login.js", description: "The login flow could not be started" },
|
|
180
|
+
"auth_refreshed": { direction: "s2c", handler: "lib/public/modules/vendor-login.js", description: "Login completed and the vendor adapter was restarted" },
|
|
174
181
|
|
|
175
182
|
// -----------------------------------------------------------------------
|
|
176
183
|
// Rate limiting / scheduling
|
|
@@ -1312,15 +1312,14 @@ function createCodexAdapter(opts) {
|
|
|
1312
1312
|
// model listing must not depend on a successful app-server init — otherwise a
|
|
1313
1313
|
// slow/failed `initialize` leaves the picker empty and the chip shows the
|
|
1314
1314
|
// previous vendor's model.
|
|
1315
|
+
// Kept in sync with what `model/list` reports for the bundled codex build
|
|
1316
|
+
// (0.152.1). Models the CLI no longer offers are rejected at turn/start, so
|
|
1317
|
+
// listing them would only surface a runtime failure in the model picker.
|
|
1315
1318
|
var CODEX_MODELS = [
|
|
1316
1319
|
"gpt-5.6-terra",
|
|
1317
1320
|
"gpt-5.6-sol",
|
|
1318
1321
|
"gpt-5.6-luna",
|
|
1319
1322
|
"gpt-5.5",
|
|
1320
|
-
"gpt-5.4",
|
|
1321
|
-
"gpt-5.4-mini",
|
|
1322
|
-
"gpt-5.3-codex",
|
|
1323
|
-
"gpt-5.3-codex-spark",
|
|
1324
1323
|
"gpt-5.2",
|
|
1325
1324
|
];
|
|
1326
1325
|
var _cachedModels = CODEX_MODELS.slice();
|
|
@@ -13,7 +13,13 @@ var { buildUserEnv } = require("../build-user-env");
|
|
|
13
13
|
var { wrapSpawnAsUser } = require("../os-users");
|
|
14
14
|
|
|
15
15
|
// --- Find the codex binary path ---
|
|
16
|
-
// Mirrors the logic from @openai/codex-sdk findCodexPath()
|
|
16
|
+
// Mirrors the logic from @openai/codex-sdk findCodexPath().
|
|
17
|
+
//
|
|
18
|
+
// The bundled @openai/codex platform package is the default. Set the
|
|
19
|
+
// CLAY_CODEX_PATH env var to an existing executable to run a different codex
|
|
20
|
+
// build (local dev build, system install, pinned older release); the chosen
|
|
21
|
+
// binary is logged on every resolution. An unset or non-existent
|
|
22
|
+
// CLAY_CODEX_PATH silently falls back to the bundled resolution.
|
|
17
23
|
|
|
18
24
|
var PLATFORM_PACKAGE_BY_TARGET = {
|
|
19
25
|
"aarch64-apple-darwin": "@openai/codex-darwin-arm64",
|
|
@@ -39,6 +45,15 @@ function getTargetTriple() {
|
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
function findCodexPath() {
|
|
48
|
+
var override = process.env.CLAY_CODEX_PATH;
|
|
49
|
+
if (override) {
|
|
50
|
+
if (fs.existsSync(override)) {
|
|
51
|
+
console.log("[codex-app-server] Using CLAY_CODEX_PATH binary:", override);
|
|
52
|
+
return override;
|
|
53
|
+
}
|
|
54
|
+
console.warn("[codex-app-server] CLAY_CODEX_PATH does not exist, falling back to bundled codex:", override);
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
var triple = getTargetTriple();
|
|
43
58
|
if (!triple) throw new Error("Unsupported platform: " + process.platform + "/" + process.arch);
|
|
44
59
|
|
|
@@ -59,7 +74,10 @@ function findCodexPath() {
|
|
|
59
74
|
path.join(vendorRoot, triple, "codex", binaryName),
|
|
60
75
|
];
|
|
61
76
|
for (var i = 0; i < candidates.length; i++) {
|
|
62
|
-
if (fs.existsSync(candidates[i]))
|
|
77
|
+
if (fs.existsSync(candidates[i])) {
|
|
78
|
+
console.log("[codex-app-server] Using bundled codex binary:", candidates[i]);
|
|
79
|
+
return candidates[i];
|
|
80
|
+
}
|
|
63
81
|
}
|
|
64
82
|
throw new Error("codex binary not found in any known layout under " + path.join(vendorRoot, triple));
|
|
65
83
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clay-server",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.2",
|
|
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",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@anthropic-ai/claude-agent-sdk": "^0.3.241",
|
|
53
53
|
"@lydell/node-pty": "^1.2.0-beta.3",
|
|
54
|
-
"@openai/codex": "^0.
|
|
54
|
+
"@openai/codex": "^0.152.1",
|
|
55
55
|
"@seald-io/nedb": "^4.1.2",
|
|
56
56
|
"imapflow": "^1.3.1",
|
|
57
57
|
"nodemailer": "^9.0.5",
|