@runuai/host 0.8.37 → 0.8.39
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/engines.ts +29 -1
- package/package.json +1 -1
- package/src/ui/server.ts +54 -0
- package/ui/app.js +189 -21
- package/ui/style.css +33 -0
package/lib/engines.ts
CHANGED
|
@@ -414,6 +414,13 @@ export interface ConnectOptions {
|
|
|
414
414
|
apiKey?: string;
|
|
415
415
|
/** token-command manual fallback: a pasted token or API key (Claude). */
|
|
416
416
|
pastedToken?: string;
|
|
417
|
+
/**
|
|
418
|
+
* ADR-076: when set, a token captured by the token-command flow (or pasted)
|
|
419
|
+
* is handed to this sink INSTEAD of being persisted as the default credential
|
|
420
|
+
* — used to register an EXTRA account. The sink's result becomes the connect
|
|
421
|
+
* result.
|
|
422
|
+
*/
|
|
423
|
+
onToken?: (token: string) => ConnectResult | Promise<ConnectResult>;
|
|
417
424
|
}
|
|
418
425
|
|
|
419
426
|
export interface ConnectResult {
|
|
@@ -465,6 +472,9 @@ export async function connectEngine(
|
|
|
465
472
|
message: "That doesn't look like a token. Paste just the value.",
|
|
466
473
|
};
|
|
467
474
|
}
|
|
475
|
+
// Adding an extra account? Route the pasted value to the sink instead of
|
|
476
|
+
// the default credential.
|
|
477
|
+
if (opts.onToken) return await opts.onToken(token);
|
|
468
478
|
// One paste box serves both credential kinds — classify by prefix so an
|
|
469
479
|
// `sk-ant-api…` platform key doesn't get stored as an OAuth token.
|
|
470
480
|
upsertEnvLocal(
|
|
@@ -476,7 +486,7 @@ export async function connectEngine(
|
|
|
476
486
|
);
|
|
477
487
|
return { ok: true, message: `${d.label} connected.` };
|
|
478
488
|
}
|
|
479
|
-
return runTokenCommand(kind, d.label, onLog, s);
|
|
489
|
+
return runTokenCommand(kind, d.label, onLog, s, opts.onToken);
|
|
480
490
|
}
|
|
481
491
|
|
|
482
492
|
return runLoginCommand(kind, d.label, onLog, s);
|
|
@@ -1028,6 +1038,7 @@ async function runTokenCommand(
|
|
|
1028
1038
|
label: string,
|
|
1029
1039
|
onLog: (line: string) => void,
|
|
1030
1040
|
s: EngineSeams,
|
|
1041
|
+
onToken?: (token: string) => ConnectResult | Promise<ConnectResult>,
|
|
1031
1042
|
): Promise<ConnectResult> {
|
|
1032
1043
|
let child: ChildProcess;
|
|
1033
1044
|
try {
|
|
@@ -1052,9 +1063,26 @@ async function runTokenCommand(
|
|
|
1052
1063
|
resolve(r);
|
|
1053
1064
|
};
|
|
1054
1065
|
let raw = "";
|
|
1066
|
+
let capturing = false;
|
|
1055
1067
|
const forwarded = new Set<string>();
|
|
1056
1068
|
const acked = new Set<string>();
|
|
1057
1069
|
const saveToken = (token: string): void => {
|
|
1070
|
+
// The token renders on several redraws (and again on exit) — capture once.
|
|
1071
|
+
if (capturing) return;
|
|
1072
|
+
capturing = true;
|
|
1073
|
+
// ADR-076: adding an EXTRA account routes the captured token to the sink
|
|
1074
|
+
// (which stores it as a separate account) instead of the default env.
|
|
1075
|
+
if (onToken) {
|
|
1076
|
+
Promise.resolve(onToken(token))
|
|
1077
|
+
.then((r) => done(r, true))
|
|
1078
|
+
.catch((err) =>
|
|
1079
|
+
done(
|
|
1080
|
+
{ ok: false, message: err instanceof Error ? err.message : String(err) },
|
|
1081
|
+
true,
|
|
1082
|
+
),
|
|
1083
|
+
);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1058
1086
|
upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
|
|
1059
1087
|
done({ ok: true, message: `${label} connected.` }, true);
|
|
1060
1088
|
};
|
package/package.json
CHANGED
package/src/ui/server.ts
CHANGED
|
@@ -162,6 +162,8 @@ async function handle(
|
|
|
162
162
|
return await handleEngineInstall(req, res);
|
|
163
163
|
case "/api/engines/disconnect":
|
|
164
164
|
return await handleEngineDisconnect(req, res, opts);
|
|
165
|
+
case "/api/engines/accounts/connect":
|
|
166
|
+
return await handleEngineAccountConnect(req, res, opts);
|
|
165
167
|
case "/api/engines/accounts/add":
|
|
166
168
|
return await handleEngineAccountAdd(req, res, opts);
|
|
167
169
|
case "/api/engines/accounts/remove":
|
|
@@ -357,6 +359,58 @@ async function handleEngineAccountAdd(
|
|
|
357
359
|
});
|
|
358
360
|
}
|
|
359
361
|
|
|
362
|
+
/**
|
|
363
|
+
* POST /api/engines/accounts/connect `{kind, label}` → run the engine's
|
|
364
|
+
* token-command sign-in (streamed NDJSON exactly like /api/engines/connect) and
|
|
365
|
+
* store the captured token as an EXTRA account (ADR-076) rather than the default
|
|
366
|
+
* credential. Only meaningful for token-command kinds (Claude); the browser
|
|
367
|
+
* OAuth authorizes whichever account you sign in with, so run it signed-out /
|
|
368
|
+
* incognito for the browser to prompt for the second account.
|
|
369
|
+
*/
|
|
370
|
+
async function handleEngineAccountConnect(
|
|
371
|
+
req: IncomingMessage,
|
|
372
|
+
res: ServerResponse,
|
|
373
|
+
opts: UiServerOptions,
|
|
374
|
+
): Promise<void> {
|
|
375
|
+
const body = await readJsonBody(req);
|
|
376
|
+
const kind = body?.kind;
|
|
377
|
+
if (!isEngineKind(kind)) {
|
|
378
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
379
|
+
}
|
|
380
|
+
const label = typeof body?.label === "string" ? body.label.trim() : "";
|
|
381
|
+
if (!label) {
|
|
382
|
+
return sendError(res, 400, "give the account a label");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
res.writeHead(200, {
|
|
386
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
387
|
+
"cache-control": "no-store",
|
|
388
|
+
});
|
|
389
|
+
const emit = (obj: unknown): void => {
|
|
390
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
let result: { ok: boolean; message: string };
|
|
394
|
+
try {
|
|
395
|
+
result = await connectEngine(
|
|
396
|
+
kind,
|
|
397
|
+
{ onToken: (token) => addEngineAccount(kind, label, { token }) },
|
|
398
|
+
(line) => emit({ line }),
|
|
399
|
+
);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
result = {
|
|
402
|
+
ok: false,
|
|
403
|
+
message: err instanceof Error ? err.message : "sign-in failed",
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (result.ok) {
|
|
407
|
+
opts.readvertise?.();
|
|
408
|
+
void ensureStandardImage();
|
|
409
|
+
}
|
|
410
|
+
emit({ done: true, ok: result.ok, message: result.message });
|
|
411
|
+
res.end();
|
|
412
|
+
}
|
|
413
|
+
|
|
360
414
|
/** POST /api/engines/accounts/remove `{id}` → forget an EXTRA account. */
|
|
361
415
|
async function handleEngineAccountRemove(
|
|
362
416
|
req: IncomingMessage,
|
package/ui/app.js
CHANGED
|
@@ -368,33 +368,201 @@ function engineAccounts(e, accounts, canAdd) {
|
|
|
368
368
|
add.className = "link-btn";
|
|
369
369
|
add.type = "button";
|
|
370
370
|
add.textContent = "+ Add account";
|
|
371
|
-
add.addEventListener("click", () =>
|
|
371
|
+
add.addEventListener("click", () => openAddAccount(e));
|
|
372
372
|
box.append(add);
|
|
373
373
|
}
|
|
374
374
|
return box;
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
-
/**
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
377
|
+
/**
|
|
378
|
+
* Add-account modal (ADR-076). Replaces the old back-to-back window.prompt()
|
|
379
|
+
* flow: a single-line prompt hid what you pasted and silently truncated long
|
|
380
|
+
* `claude setup-token` values, which then surfaced downstream as a 401 on the
|
|
381
|
+
* rotated account. A real form (visible multi-line textarea + inline
|
|
382
|
+
* validation) makes a bad paste obvious before it's saved.
|
|
383
|
+
*/
|
|
384
|
+
function openAddAccount(e) {
|
|
385
|
+
$("engine-modal-title").textContent = `Add ${e.label} account`;
|
|
386
|
+
const body = $("engine-modal-body");
|
|
387
|
+
body.replaceChildren();
|
|
388
|
+
body.append(addAccountForm(e));
|
|
389
|
+
openModal();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function addAccountForm(e) {
|
|
393
|
+
const isToken = e.authMode === "token-command"; // Claude: browser sign-in
|
|
394
|
+
const form = document.createElement("div");
|
|
395
|
+
form.className = "engine-setup";
|
|
396
|
+
|
|
397
|
+
const note = document.createElement("p");
|
|
398
|
+
note.className = "setup-note";
|
|
399
|
+
note.textContent = isToken
|
|
400
|
+
? "Sign in with the account you want to add — a browser opens to authorize Claude and the token is captured here automatically. This account rotates in automatically when another Claude account hits its limit."
|
|
401
|
+
: "Paste an OpenAI API key (starts with sk-…). This account rotates in automatically when another account of this kind hits its limit.";
|
|
402
|
+
form.append(note);
|
|
403
|
+
|
|
404
|
+
const labelField = document.createElement("div");
|
|
405
|
+
labelField.className = "field";
|
|
406
|
+
const labelInput = document.createElement("input");
|
|
407
|
+
labelInput.type = "text";
|
|
408
|
+
labelInput.className = "text-input";
|
|
409
|
+
labelInput.placeholder = "Label (e.g. work)";
|
|
410
|
+
labelInput.autocomplete = "off";
|
|
411
|
+
labelInput.spellcheck = false;
|
|
412
|
+
labelField.append(labelInput);
|
|
413
|
+
form.append(labelField);
|
|
414
|
+
|
|
415
|
+
const status = document.createElement("div");
|
|
416
|
+
status.className = "setup-status";
|
|
417
|
+
const log = document.createElement("pre");
|
|
418
|
+
log.className = "log";
|
|
419
|
+
log.hidden = true;
|
|
420
|
+
const pushLine = (line) => {
|
|
421
|
+
log.textContent += (log.textContent ? "\n" : "") + line;
|
|
422
|
+
log.scrollTop = log.scrollHeight;
|
|
423
|
+
};
|
|
424
|
+
const fail = (msg) => {
|
|
425
|
+
status.className = "setup-status err";
|
|
426
|
+
status.textContent = msg;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const actions = document.createElement("div");
|
|
430
|
+
actions.className = "setup-actions";
|
|
431
|
+
|
|
432
|
+
// --- Automatic sign-in (token-command / Claude) --------------------------
|
|
433
|
+
if (isToken) {
|
|
434
|
+
const signIn = document.createElement("button");
|
|
435
|
+
signIn.className = "btn";
|
|
436
|
+
signIn.type = "button";
|
|
437
|
+
signIn.textContent = "Sign in with Claude";
|
|
438
|
+
signIn.addEventListener("click", async () => {
|
|
439
|
+
const label = labelInput.value.trim();
|
|
440
|
+
if (!label) return fail("Give the account a label first.");
|
|
441
|
+
signIn.disabled = true;
|
|
442
|
+
signIn.textContent = "Signing in…";
|
|
443
|
+
status.className = "setup-status";
|
|
444
|
+
status.textContent = "A browser will open to authorize Claude…";
|
|
445
|
+
log.hidden = false;
|
|
446
|
+
log.textContent = "";
|
|
447
|
+
const result = await runStream(
|
|
448
|
+
"/api/engines/accounts/connect",
|
|
449
|
+
{ kind: e.kind, label },
|
|
450
|
+
pushLine,
|
|
451
|
+
);
|
|
452
|
+
if (result.ok) {
|
|
453
|
+
await poll();
|
|
454
|
+
closeModal();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
signIn.disabled = false;
|
|
458
|
+
signIn.textContent = "Try again";
|
|
459
|
+
fail(result.message || "Sign-in failed.");
|
|
460
|
+
});
|
|
461
|
+
actions.append(signIn);
|
|
396
462
|
}
|
|
397
|
-
|
|
463
|
+
|
|
464
|
+
const cancel = document.createElement("button");
|
|
465
|
+
cancel.className = "link-btn";
|
|
466
|
+
cancel.type = "button";
|
|
467
|
+
cancel.textContent = "Cancel";
|
|
468
|
+
cancel.addEventListener("click", closeModal);
|
|
469
|
+
actions.append(cancel);
|
|
470
|
+
form.append(actions, status, log);
|
|
471
|
+
|
|
472
|
+
// --- Manual fallback: copyable command + paste box -----------------------
|
|
473
|
+
if (isToken) {
|
|
474
|
+
const or = document.createElement("p");
|
|
475
|
+
or.className = "setup-note setup-or";
|
|
476
|
+
or.textContent =
|
|
477
|
+
"Can't use the browser here? Run this in a terminal, then paste the token:";
|
|
478
|
+
form.append(or, copyRow("claude setup-token"));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const secretField = document.createElement("div");
|
|
482
|
+
secretField.className = "field";
|
|
483
|
+
const secretInput = document.createElement("textarea");
|
|
484
|
+
secretInput.className = "text-input token-area";
|
|
485
|
+
secretInput.rows = 3;
|
|
486
|
+
secretInput.placeholder = isToken ? "Claude token or API key" : "OpenAI API key";
|
|
487
|
+
secretInput.autocomplete = "off";
|
|
488
|
+
secretInput.spellcheck = false;
|
|
489
|
+
secretField.append(secretInput);
|
|
490
|
+
form.append(secretField);
|
|
491
|
+
|
|
492
|
+
const pasteActions = document.createElement("div");
|
|
493
|
+
pasteActions.className = "setup-actions";
|
|
494
|
+
const save = document.createElement("button");
|
|
495
|
+
save.className = isToken ? "link-btn" : "btn";
|
|
496
|
+
save.type = "button";
|
|
497
|
+
save.textContent = isToken ? "Add pasted token" : "Add account";
|
|
498
|
+
save.addEventListener("click", async () => {
|
|
499
|
+
const label = labelInput.value.trim();
|
|
500
|
+
const secret = secretInput.value.trim();
|
|
501
|
+
if (!label) return fail("Give the account a label.");
|
|
502
|
+
if (!secret) return fail("Paste a token or API key.");
|
|
503
|
+
// Mirrors the host-side check — a token with internal whitespace/newlines is
|
|
504
|
+
// a mangled paste, not a value; catch it here so it never becomes a 401.
|
|
505
|
+
if (/\s/.test(secret)) {
|
|
506
|
+
return fail("That value has spaces or line breaks — paste just the token.");
|
|
507
|
+
}
|
|
508
|
+
save.disabled = true;
|
|
509
|
+
save.textContent = "Adding…";
|
|
510
|
+
const payload =
|
|
511
|
+
e.kind === "claude"
|
|
512
|
+
? { kind: e.kind, label, token: secret }
|
|
513
|
+
: { kind: e.kind, label, apiKey: secret };
|
|
514
|
+
let result;
|
|
515
|
+
try {
|
|
516
|
+
result = await postJSON("/api/engines/accounts/add", payload);
|
|
517
|
+
} catch {
|
|
518
|
+
result = { ok: false, message: "Couldn't reach the host." };
|
|
519
|
+
}
|
|
520
|
+
if (result && result.ok) {
|
|
521
|
+
await poll();
|
|
522
|
+
closeModal();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
save.disabled = false;
|
|
526
|
+
save.textContent = isToken ? "Add pasted token" : "Add account";
|
|
527
|
+
fail((result && result.message) || "Couldn't add the account.");
|
|
528
|
+
});
|
|
529
|
+
pasteActions.append(save);
|
|
530
|
+
form.append(pasteActions);
|
|
531
|
+
|
|
532
|
+
setTimeout(() => labelInput.focus(), 0);
|
|
533
|
+
return form;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** A monospace command line with a Copy button (writes to the clipboard). */
|
|
537
|
+
function copyRow(cmd) {
|
|
538
|
+
const row = document.createElement("div");
|
|
539
|
+
row.className = "copy-row";
|
|
540
|
+
const code = document.createElement("code");
|
|
541
|
+
code.className = "copy-cmd";
|
|
542
|
+
code.textContent = cmd;
|
|
543
|
+
const btn = document.createElement("button");
|
|
544
|
+
btn.className = "link-btn";
|
|
545
|
+
btn.type = "button";
|
|
546
|
+
btn.textContent = "Copy";
|
|
547
|
+
btn.addEventListener("click", async () => {
|
|
548
|
+
try {
|
|
549
|
+
await navigator.clipboard.writeText(cmd);
|
|
550
|
+
btn.textContent = "Copied";
|
|
551
|
+
setTimeout(() => {
|
|
552
|
+
btn.textContent = "Copy";
|
|
553
|
+
}, 1500);
|
|
554
|
+
} catch {
|
|
555
|
+
// Clipboard API blocked (non-secure context) — select the text so the
|
|
556
|
+
// user can copy it manually.
|
|
557
|
+
const range = document.createRange();
|
|
558
|
+
range.selectNodeContents(code);
|
|
559
|
+
const sel = window.getSelection();
|
|
560
|
+
sel.removeAllRanges();
|
|
561
|
+
sel.addRange(range);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
row.append(code, btn);
|
|
565
|
+
return row;
|
|
398
566
|
}
|
|
399
567
|
|
|
400
568
|
// --- add-engine modal -------------------------------------------------------
|
package/ui/style.css
CHANGED
|
@@ -604,6 +604,39 @@ code,
|
|
|
604
604
|
border-color: var(--fg-muted);
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
+
/* Add-account token field: long secrets must be fully visible (no truncation)
|
|
608
|
+
and wrap so a bad paste is obvious before saving. */
|
|
609
|
+
textarea.token-area {
|
|
610
|
+
resize: vertical;
|
|
611
|
+
min-height: 3.2rem;
|
|
612
|
+
line-height: 1.35;
|
|
613
|
+
word-break: break-all;
|
|
614
|
+
white-space: pre-wrap;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* Copyable command row (e.g. `claude setup-token`) + Copy button. */
|
|
618
|
+
.copy-row {
|
|
619
|
+
display: flex;
|
|
620
|
+
align-items: center;
|
|
621
|
+
gap: 0.6rem;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.copy-cmd {
|
|
625
|
+
flex: 1;
|
|
626
|
+
padding: 0.45rem 0.7rem;
|
|
627
|
+
border: 1px solid var(--line);
|
|
628
|
+
border-radius: 8px;
|
|
629
|
+
background: var(--bg);
|
|
630
|
+
color: var(--fg);
|
|
631
|
+
font-family: var(--mono);
|
|
632
|
+
font-size: 0.85rem;
|
|
633
|
+
word-break: break-all;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.setup-or {
|
|
637
|
+
margin-top: 0.4rem;
|
|
638
|
+
}
|
|
639
|
+
|
|
607
640
|
.setup-actions {
|
|
608
641
|
display: flex;
|
|
609
642
|
align-items: center;
|