@runuai/host 0.9.50 → 0.9.52

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.
Files changed (2) hide show
  1. package/lib/engine-login.ts +103 -10
  2. package/package.json +1 -1
@@ -190,6 +190,10 @@ interface LoginOperation {
190
190
  /** ADR-116: when set, the captured credential lands in a labeled extra
191
191
  * account instead of the default slot. */
192
192
  accountLabel: string | null;
193
+ /** Diagnosis breadcrumb: has ANY output arrived after the one-time code
194
+ * was written? Distinguishes "stdin never delivered" from "CLI answered
195
+ * and we ignored it" without reading the PTY transcript. */
196
+ postInputOutputSeen: boolean;
193
197
  }
194
198
 
195
199
  const defaultTimers: EngineLoginTimers = {
@@ -391,6 +395,7 @@ export class EngineLoginManager {
391
395
  pendingInput: null,
392
396
  callbackTarget: null,
393
397
  accountLabel: label ?? null,
398
+ postInputOutputSeen: false,
394
399
  };
395
400
  this.operations.set(opId, operation);
396
401
  this.engineOperations.set(engine, operation);
@@ -413,17 +418,35 @@ export class EngineLoginManager {
413
418
  /** Consume Claude's one-time code exactly once. */
414
419
  input(opId: string, text: string): boolean {
415
420
  const operation = this.operations.get(opId);
416
- if (
417
- !operation ||
418
- !this.isCurrent(operation) ||
419
- operation.engine !== "claude" ||
420
- operation.stage !== "awaiting_input" ||
421
- operation.inputUsed ||
422
- operation.finishing
423
- ) {
421
+ // Every rejection names itself in the host log: a silently dropped code
422
+ // reads as "Waiting for the host…" forever on the cloud pane (live
423
+ // 2026-08-20, the guard chain gave the operator nothing to act on).
424
+ // Never log `text` — it is one-time OAuth material.
425
+ if (!operation) {
426
+ console.warn(`[engine-login] input dropped: unknown opId ${opId}`);
427
+ return false;
428
+ }
429
+ const rejection = !this.isCurrent(operation)
430
+ ? "operation belongs to a previous bridge generation"
431
+ : operation.engine !== "claude"
432
+ ? `engine is ${operation.engine}, not claude`
433
+ : operation.stage !== "awaiting_input"
434
+ ? `stage is ${operation.stage}, not awaiting_input`
435
+ : operation.inputUsed
436
+ ? "a code was already submitted for this operation"
437
+ : operation.finishing
438
+ ? "the operation is already finishing"
439
+ : null;
440
+ if (rejection !== null) {
441
+ console.warn(
442
+ `[engine-login] input dropped for ${opId}: ${rejection}`,
443
+ );
424
444
  return false;
425
445
  }
426
446
  if (!isValidOneTimeInput(text)) {
447
+ console.warn(
448
+ `[engine-login] input rejected for ${opId}: failed one-time-input validation (length ${text.length})`,
449
+ );
427
450
  void this.fail(
428
451
  operation,
429
452
  "invalid_input",
@@ -431,6 +454,9 @@ export class EngineLoginManager {
431
454
  );
432
455
  return false;
433
456
  }
457
+ console.log(
458
+ `[engine-login] input accepted for ${opId} (${text.length} chars)`,
459
+ );
434
460
  operation.inputUsed = true;
435
461
  operation.outputTail = "";
436
462
  // CARRIAGE RETURN, not newline: `claude setup-token` is an Ink raw-mode
@@ -441,13 +467,20 @@ export class EngineLoginManager {
441
467
  // hung on exactly this.
442
468
  const value = `${text}\r`;
443
469
  if (!operation.process) {
470
+ console.log(
471
+ `[engine-login] input buffered for ${opId} (login process still starting)`,
472
+ );
444
473
  operation.pendingInput = value;
445
474
  return true;
446
475
  }
447
476
  try {
448
477
  operation.process.writeInput(value);
478
+ console.log(`[engine-login] input written to the login process for ${opId}`);
449
479
  return true;
450
480
  } catch {
481
+ console.warn(
482
+ `[engine-login] input write FAILED for ${opId}: the login process rejected stdin`,
483
+ );
451
484
  void this.fail(
452
485
  operation,
453
486
  "invalid_input",
@@ -636,6 +669,29 @@ export class EngineLoginManager {
636
669
  );
637
670
 
638
671
  if (operation.engine === "claude") {
672
+ if (operation.inputUsed && !operation.postInputOutputSeen) {
673
+ operation.postInputOutputSeen = true;
674
+ console.log(
675
+ `[engine-login] output resumed after input for ${operation.opId} — the code reached the login TUI`,
676
+ );
677
+ }
678
+ // The TUI answers a bad/expired/mismatched code with an error AND an
679
+ // open prompt — no exit, no token. Detect it and fail visibly instead
680
+ // of the silent wait-for-timeout that hid the 2026-08-20 hangs.
681
+ if (operation.inputUsed && !operation.finishing) {
682
+ const cliError = detectClaudeCliError(operation.outputTail);
683
+ if (cliError) {
684
+ console.warn(
685
+ `[engine-login] login CLI rejected the code for ${operation.opId}: ${cliError}`,
686
+ );
687
+ void this.fail(
688
+ operation,
689
+ "invalid_input",
690
+ `The sign-in CLI rejected the code: ${cliError} Make sure the code comes from THIS attempt's authorization page, then retry.`,
691
+ );
692
+ return;
693
+ }
694
+ }
639
695
  // Before the one-time input is consumed, token-shaped output could only
640
696
  // be an authorization URL/detail or hostile terminal content. This also
641
697
  // prevents an input echo from becoming the credential boundary.
@@ -643,6 +699,9 @@ export class EngineLoginManager {
643
699
  ? extractClaudeOAuthToken(operation.outputTail)
644
700
  : null;
645
701
  if (token) {
702
+ console.log(
703
+ `[engine-login] token detected in CLI output for ${operation.opId}; persisting`,
704
+ );
646
705
  operation.outputTail = "";
647
706
  this.beginClaudeFinish(operation, token);
648
707
  return;
@@ -650,6 +709,9 @@ export class EngineLoginManager {
650
709
  if (operation.stage === "starting") {
651
710
  const authorizeUrl = findSafeHttpsUrl(operation.outputTail);
652
711
  if (authorizeUrl) {
712
+ console.log(
713
+ `[engine-login] authorize URL found for ${operation.opId}; awaiting the one-time code`,
714
+ );
653
715
  operation.stage = "awaiting_input";
654
716
  safeEmit(operation.emit, authorizeEvent(operation, authorizeUrl));
655
717
  safeEmit(operation.emit, {
@@ -694,12 +756,17 @@ export class EngineLoginManager {
694
756
  if (operation.finishPromise) await operation.finishPromise;
695
757
  return;
696
758
  }
759
+ const evidence = lastSignificantOutputLine(operation.outputTail);
760
+ console.warn(
761
+ `[engine-login] login process for ${operation.opId} ended (exit ${result.code ?? "spawn-failed"}) before completion` +
762
+ (evidence ? `; last output: ${evidence}` : ""),
763
+ );
697
764
  await this.fail(
698
765
  operation,
699
766
  result.spawnFailed ? "unavailable" : "start_failed",
700
767
  result.spawnFailed
701
768
  ? "The login runtime is unavailable."
702
- : `${engineLabel(operation.engine)} login did not complete.`,
769
+ : `${engineLabel(operation.engine)} login did not complete.${evidence ? ` Last output: ${evidence}` : ""}`,
703
770
  );
704
771
  }
705
772
 
@@ -1008,6 +1075,27 @@ export function createEngineLoginManager(
1008
1075
  return new EngineLoginManager(options);
1009
1076
  }
1010
1077
 
1078
+ /** Detect the TUI's post-input rejection ("OAuth error: Invalid code…",
1079
+ * expired codes, …) in the sanitized output. ANSI stripping can eat spaces
1080
+ * ("Invalidcode"), so the patterns tolerate missing whitespace. Exported for
1081
+ * tests. */
1082
+ export function detectClaudeCliError(tail: string): string | null {
1083
+ const lines = tail
1084
+ .replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
1085
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
1086
+ .split("\n")
1087
+ .map((line) => line.trim())
1088
+ .filter(Boolean);
1089
+ const match = lines.find((line) =>
1090
+ /oauth\s*error|invalid\s*cod|code\s*.{0,12}expired|authentication\s*failed/i.test(
1091
+ line,
1092
+ ),
1093
+ );
1094
+ if (!match) return null;
1095
+ if (/sk-ant-|Bearer |[A-Za-z0-9_-]{40,}/.test(match)) return null;
1096
+ return match.slice(0, 200);
1097
+ }
1098
+
1011
1099
  /** The last visible, sanitized line of the login CLI's output — bounded and
1012
1100
  * secret-checked so it can ride a terminal event message (ADR-116). Anything
1013
1101
  * token-shaped disqualifies the whole line rather than risking a partial. */
@@ -1401,7 +1489,12 @@ export function engineLoginContainerArgs(
1401
1489
  // a client id (live 2026-08-18). stty targets the PTY `script`
1402
1490
  // just allocated.
1403
1491
  "stty cols 500 rows 50 2>/dev/null; exec claude setup-token",
1404
- "/dev/null",
1492
+ // The PTY transcript lands in the op's private bind leaf (same
1493
+ // trust class as the codex auth.json captured there; the leaf is
1494
+ // 0700 and deleted at cleanup). A stuck flow can then be read
1495
+ // VERBATIM from the host — the 2026-08-20 hang was undiagnosable
1496
+ // because the typescript went to /dev/null.
1497
+ `${LOGIN_MOUNT}/typescript`,
1405
1498
  ]
1406
1499
  : ["codex", "login"];
1407
1500
  if (engineLoginBackend().apple) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.50",
3
+ "version": "0.9.52",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",