@runuai/host 0.8.11 → 0.8.12

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/engines.ts +168 -24
  2. package/package.json +1 -1
package/lib/engines.ts CHANGED
@@ -17,9 +17,16 @@
17
17
  * PATH so `#!/usr/bin/env node` shebangs resolve too.
18
18
  *
19
19
  * Three auth modes:
20
- * - token-command (Claude): `claude setup-token` prints a token to stdout;
21
- * we capture it (an `sk-ant-oat…` value) and persist it as
20
+ * - token-command (Claude): `claude setup-token` shows a token; we run the
21
+ * CLI under a PTY (script(1)), capture the `sk-ant-oat…`
22
+ * value live from the terminal stream and persist it as
22
23
  * CLAUDE_CODE_OAUTH_TOKEN. A pasted token is accepted too.
24
+ * The PTY is load-bearing: the CLI's Ink UI buffers ALL
25
+ * output until unmount when stdout isn't a TTY and its
26
+ * final screens wait for a keypress — under plain pipes it
27
+ * emits nothing and never exits (the eternal "Connecting…"
28
+ * of 0.8.11). We resolve as soon as the token appears and
29
+ * kill the CLI rather than wait for it to end on its own.
23
30
  * - login-command (Codex/Kimi/Grok): `<cli> login` runs a browser OAuth and
24
31
  * writes a config file; success = that file appearing.
25
32
  * - api-key (Cursor): no command — persist a pasted CURSOR_API_KEY.
@@ -147,7 +154,7 @@ export type EngineSpawn = (
147
154
  ) => ChildProcess;
148
155
 
149
156
  export interface EngineSeams {
150
- /** Spawn a CLI, piping stdout/stderr. */
157
+ /** Spawn a CLI, piping stdin/stdout/stderr. */
151
158
  spawn: EngineSpawn;
152
159
  /** Absolute path to the `.env.local` the running host reads (UAI_HOME). */
153
160
  envLocalPath: () => string;
@@ -159,13 +166,26 @@ export interface EngineSeams {
159
166
  loginShellPath: () => Promise<string | null>;
160
167
  /** System-wide bin dirs probed for engine CLIs (tests pin to []). */
161
168
  systemBinDirs: string[];
169
+ /**
170
+ * Wrap a CLI invocation so the child gets a real PTY (script(1)), or null
171
+ * to spawn directly. Tests pin null; token-command needs the wrap (see
172
+ * defaultPtyWrap for why).
173
+ */
174
+ ptyWrap: ((bin: string, args: string[]) => PtyCommand | null) | null;
175
+ }
176
+
177
+ /** A wrapped invocation: what to actually spawn to run a CLI under a PTY. */
178
+ export interface PtyCommand {
179
+ command: string;
180
+ args: string[];
162
181
  }
163
182
 
164
183
  function defaultSeams(): EngineSeams {
165
184
  return {
166
185
  spawn: (command, args, spawnEnv) =>
167
186
  nodeSpawn(command, args, {
168
- stdio: ["ignore", "pipe", "pipe"],
187
+ // stdin stays open (pipe): the token flow answers "Press Enter" gates.
188
+ stdio: ["pipe", "pipe", "pipe"],
169
189
  env: spawnEnv,
170
190
  }),
171
191
  envLocalPath: () => join(env.uaiHome, ".env.local"),
@@ -173,9 +193,43 @@ function defaultSeams(): EngineSeams {
173
193
  procEnv: process.env,
174
194
  loginShellPath: probeLoginShellPath,
175
195
  systemBinDirs: ["/opt/homebrew/bin", "/usr/local/bin"],
196
+ ptyWrap: defaultPtyWrap,
176
197
  };
177
198
  }
178
199
 
200
+ /**
201
+ * Lend a CLI a real PTY via script(1) — no native deps, ships with macOS
202
+ * (BSD) and Linux (util-linux; argument conventions differ, hence the
203
+ * branch). Ink-based CLIs (claude) render nothing under plain pipes: Ink
204
+ * detects a non-TTY stdout and buffers every frame until unmount, and the
205
+ * interactive screens can't complete without a terminal — so the child sat
206
+ * silent forever. The inner `stty cols 400` matters too: at the default 80
207
+ * columns the token line WRAPS and a wrapped token extracts truncated.
208
+ */
209
+ function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
210
+ if (!existsSync("/usr/bin/script")) return null;
211
+ const stty = "stty cols 400 rows 100 2>/dev/null";
212
+ if (process.platform === "darwin") {
213
+ // BSD script: `script -q /dev/null command [args…]` — command is exec'd
214
+ // as an argv (no shell), so route through sh only to set the width.
215
+ return {
216
+ command: "/usr/bin/script",
217
+ args: ["-q", "/dev/null", "/bin/sh", "-c", `${stty}; exec "$0" "$@"`, bin, ...args],
218
+ };
219
+ }
220
+ if (process.platform === "linux") {
221
+ // util-linux script: the command is a single shell string (-c).
222
+ const quoted = [bin, ...args]
223
+ .map((a) => `'${a.replace(/'/g, `'\\''`)}'`)
224
+ .join(" ");
225
+ return {
226
+ command: "/usr/bin/script",
227
+ args: ["-qec", `${stty}; exec ${quoted}`, "/dev/null"],
228
+ };
229
+ }
230
+ return null;
231
+ }
232
+
179
233
  /**
180
234
  * The PATH the owner's login shell exports. Runs `/usr/bin/env` (not
181
235
  * `echo $PATH`) so the value is the real colon-joined exported PATH under any
@@ -412,7 +466,32 @@ export function disconnectEngine(
412
466
  }
413
467
 
414
468
  /**
415
- * Extract a Claude OAuth token from `claude setup-token` stdout. The CLI prints
469
+ * Flatten a PTY byte stream into plain text lines. Strips OSC sequences
470
+ * (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
471
+ * sequences (cursor-column moves become a space — Ink positions words with
472
+ * `ESC[<n>G` instead of writing spaces), stray ESC singles, and normalizes
473
+ * `\r` to `\n`. Identity on already-plain text apart from whitespace runs.
474
+ */
475
+ export function sanitizeTerminalOutput(raw: string): string {
476
+ return raw
477
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC …BEL / …ST
478
+ .replace(/\x1b\[[0-9;:?<=>]*[!-/]*[@-~]/g, " ") // CSI
479
+ .replace(/\x1b[()][0-9A-B]/g, "") // charset selects
480
+ .replace(/\x1b[78=>DEHM]/g, "") // save/restore cursor etc.
481
+ .replace(/\r\n?/g, "\n")
482
+ .replace(/[^\S\n]+/g, " ");
483
+ }
484
+
485
+ /** Lines worth showing a human: non-blank, not spinner or logo-art glyphs. */
486
+ function significantLines(text: string): string[] {
487
+ return text
488
+ .split("\n")
489
+ .map((l) => l.trim())
490
+ .filter((l) => l.length > 1 && !/^[·✢✳✶✻✽*+.…|/\\\-░▒▓█▄▀\s]+$/u.test(l));
491
+ }
492
+
493
+ /**
494
+ * Extract a Claude OAuth token from `claude setup-token` output. The CLI shows
416
495
  * the token (an `sk-ant-oat…` value) on its own line near the end; scan from the
417
496
  * bottom for it, falling back to a lone long token-charset line.
418
497
  */
@@ -550,16 +629,29 @@ function prependPath(prepend: string[], current: string): string {
550
629
  .join(":");
551
630
  }
552
631
 
553
- /** Resolve the CLI and spawn it with the augmented PATH. */
632
+ /** Resolve the CLI and spawn it (optionally under a PTY) with the augmented PATH. */
554
633
  async function spawnEngineCli(
555
634
  kind: EngineKind,
556
635
  args: string[],
557
636
  s: EngineSeams,
637
+ pty = false,
558
638
  ): Promise<ChildProcess> {
559
639
  const { bin, path } = await resolveEngineBin(kind, s);
560
- return s.spawn(bin, args, { ...s.procEnv, PATH: path });
640
+ const wrapped = pty && s.ptyWrap ? s.ptyWrap(bin, args) : null;
641
+ const spawnEnv: NodeJS.ProcessEnv = { ...s.procEnv, PATH: path };
642
+ // A GUI-supervised host has no TERM; give the PTY child a sane one.
643
+ if (wrapped && !spawnEnv.TERM) spawnEnv.TERM = "xterm-256color";
644
+ return wrapped
645
+ ? s.spawn(wrapped.command, wrapped.args, spawnEnv)
646
+ : s.spawn(bin, args, spawnEnv);
561
647
  }
562
648
 
649
+ /** Browser authorization is human-paced — give it real time before bailing. */
650
+ const TOKEN_FLOW_TIMEOUT_MS = 10 * 60_000;
651
+
652
+ const TOKEN_FLOW_FALLBACK =
653
+ "Try again, or run `claude setup-token` in your terminal and paste the token here.";
654
+
563
655
  async function runTokenCommand(
564
656
  kind: EngineKind,
565
657
  label: string,
@@ -568,25 +660,68 @@ async function runTokenCommand(
568
660
  ): Promise<ConnectResult> {
569
661
  let child: ChildProcess;
570
662
  try {
571
- child = await spawnEngineCli(kind, ["setup-token"], s);
663
+ child = await spawnEngineCli(kind, ["setup-token"], s, true);
572
664
  } catch (err) {
573
665
  return { ok: false, message: err instanceof Error ? err.message : String(err) };
574
666
  }
575
667
  return new Promise((resolve) => {
576
668
  let settled = false;
577
- const done = (r: ConnectResult): void => {
578
- if (!settled) {
579
- settled = true;
580
- resolve(r);
669
+ let timer: NodeJS.Timeout | undefined;
670
+ const done = (r: ConnectResult, kill = false): void => {
671
+ if (settled) return;
672
+ settled = true;
673
+ clearTimeout(timer);
674
+ if (kill) {
675
+ try {
676
+ child.kill();
677
+ } catch {
678
+ /* already gone */
679
+ }
581
680
  }
681
+ resolve(r);
582
682
  };
583
- let stdout = "";
584
- child.stdout?.on("data", (b: Buffer) => {
585
- const text = b.toString("utf8");
586
- stdout += text;
587
- relay(text, onLog);
588
- });
589
- child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
683
+ let raw = "";
684
+ const forwarded = new Set<string>();
685
+ const acked = new Set<string>();
686
+ const saveToken = (token: string): void => {
687
+ upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
688
+ done({ ok: true, message: `${label} connected.` }, true);
689
+ };
690
+ const onChunk = (b: Buffer): void => {
691
+ raw += b.toString("utf8");
692
+ // A pending spinner redraws for minutes — keep only the tail (the token
693
+ // and final screens are at the end) so re-sanitizing stays cheap.
694
+ if (raw.length > 262_144) raw = raw.slice(-131_072);
695
+ const text = sanitizeTerminalOutput(raw);
696
+ // Act only on COMPLETE lines: a trailing partial may still be missing
697
+ // the rest of an escape sequence — or the rest of the TOKEN, and the
698
+ // extractor would happily return a truncated prefix.
699
+ const complete = text.slice(0, text.lastIndexOf("\n") + 1);
700
+ for (const line of significantLines(complete)) {
701
+ // Each distinct line once — Ink redraws the same screen constantly
702
+ // and would flood the UI log.
703
+ if (!forwarded.has(line)) {
704
+ forwarded.add(line);
705
+ onLog(line);
706
+ }
707
+ // Auto-acknowledge keypress gates (once per distinct screen text) —
708
+ // with a PTY the CLI accepts input, and nobody is at its keyboard.
709
+ if (/press enter/i.test(line) && !acked.has(line)) {
710
+ acked.add(line);
711
+ try {
712
+ child.stdin?.write("\r");
713
+ } catch {
714
+ /* best effort */
715
+ }
716
+ }
717
+ }
718
+ // The whole point: capture the token the moment it renders. The CLI's
719
+ // final screen lingers for a keypress; we don't wait for exit.
720
+ const token = extractClaudeToken(complete);
721
+ if (token) saveToken(token);
722
+ };
723
+ child.stdout?.on("data", onChunk);
724
+ child.stderr?.on("data", onChunk);
590
725
  child.on("error", (err: NodeJS.ErrnoException) =>
591
726
  done({
592
727
  ok: false,
@@ -597,18 +732,27 @@ async function runTokenCommand(
597
732
  }),
598
733
  );
599
734
  child.on("exit", () => {
600
- const token = extractClaudeToken(stdout);
735
+ const token = extractClaudeToken(sanitizeTerminalOutput(raw));
601
736
  if (!token) {
602
737
  done({
603
738
  ok: false,
604
- message:
605
- "Couldn't read a token from the CLI output. Try again, or paste the token manually.",
739
+ message: `The sign-in didn't finish. ${TOKEN_FLOW_FALLBACK}`,
606
740
  });
607
741
  return;
608
742
  }
609
- upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
610
- done({ ok: true, message: `${label} connected.` });
743
+ saveToken(token);
611
744
  });
745
+ timer = setTimeout(
746
+ () =>
747
+ done(
748
+ {
749
+ ok: false,
750
+ message: `Timed out waiting for the browser authorization. ${TOKEN_FLOW_FALLBACK}`,
751
+ },
752
+ true,
753
+ ),
754
+ TOKEN_FLOW_TIMEOUT_MS,
755
+ );
612
756
  });
613
757
  }
614
758
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.11",
3
+ "version": "0.8.12",
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>",