@runuai/host 0.8.38 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.38",
3
+ "version": "0.8.39",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
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
@@ -390,15 +390,15 @@ function openAddAccount(e) {
390
390
  }
391
391
 
392
392
  function addAccountForm(e) {
393
+ const isToken = e.authMode === "token-command"; // Claude: browser sign-in
393
394
  const form = document.createElement("div");
394
395
  form.className = "engine-setup";
395
396
 
396
397
  const note = document.createElement("p");
397
398
  note.className = "setup-note";
398
- note.textContent =
399
- e.kind === "claude"
400
- ? "Paste a token from `claude setup-token` (a long value, usually starting with sk-ant-oat…) or an API key. 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.";
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
402
  form.append(note);
403
403
 
404
404
  const labelField = document.createElement("div");
@@ -412,33 +412,89 @@ function addAccountForm(e) {
412
412
  labelField.append(labelInput);
413
413
  form.append(labelField);
414
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);
462
+ }
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
+
415
481
  const secretField = document.createElement("div");
416
482
  secretField.className = "field";
417
483
  const secretInput = document.createElement("textarea");
418
484
  secretInput.className = "text-input token-area";
419
485
  secretInput.rows = 3;
420
- secretInput.placeholder =
421
- e.kind === "claude" ? "Claude token or API key" : "OpenAI API key";
486
+ secretInput.placeholder = isToken ? "Claude token or API key" : "OpenAI API key";
422
487
  secretInput.autocomplete = "off";
423
488
  secretInput.spellcheck = false;
424
489
  secretField.append(secretInput);
425
490
  form.append(secretField);
426
491
 
427
- const status = document.createElement("div");
428
- status.className = "setup-status";
429
- form.append(status);
430
-
431
- const fail = (msg) => {
432
- status.className = "setup-status err";
433
- status.textContent = msg;
434
- };
435
-
436
- const actions = document.createElement("div");
437
- actions.className = "setup-actions";
492
+ const pasteActions = document.createElement("div");
493
+ pasteActions.className = "setup-actions";
438
494
  const save = document.createElement("button");
439
- save.className = "btn";
495
+ save.className = isToken ? "link-btn" : "btn";
440
496
  save.type = "button";
441
- save.textContent = "Add account";
497
+ save.textContent = isToken ? "Add pasted token" : "Add account";
442
498
  save.addEventListener("click", async () => {
443
499
  const label = labelInput.value.trim();
444
500
  const secret = secretInput.value.trim();
@@ -467,23 +523,48 @@ function addAccountForm(e) {
467
523
  return;
468
524
  }
469
525
  save.disabled = false;
470
- save.textContent = "Add account";
526
+ save.textContent = isToken ? "Add pasted token" : "Add account";
471
527
  fail((result && result.message) || "Couldn't add the account.");
472
528
  });
473
- actions.append(save);
529
+ pasteActions.append(save);
530
+ form.append(pasteActions);
474
531
 
475
- const cancel = document.createElement("button");
476
- cancel.className = "link-btn";
477
- cancel.type = "button";
478
- cancel.textContent = "Cancel";
479
- cancel.addEventListener("click", closeModal);
480
- actions.append(cancel);
481
-
482
- form.append(actions);
483
532
  setTimeout(() => labelInput.focus(), 0);
484
533
  return form;
485
534
  }
486
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;
566
+ }
567
+
487
568
  // --- add-engine modal -------------------------------------------------------
488
569
 
489
570
  function openModal() {
package/ui/style.css CHANGED
@@ -614,6 +614,29 @@ textarea.token-area {
614
614
  white-space: pre-wrap;
615
615
  }
616
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
+
617
640
  .setup-actions {
618
641
  display: flex;
619
642
  align-items: center;