@runuai/host 0.8.11 → 0.8.13

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 +177 -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,35 @@ 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
+ // macOS: stdin MUST be /dev/null ("ignore"), never a node pipe. Node's
188
+ // child pipes are SOCKETPAIRS, and Apple's BSD script(1) hard-exits on
189
+ // a socket stdin ("tcgetattr/ioctl: Operation not supported on
190
+ // socket") while tolerating /dev/null (ENOTTY → notty mode). The
191
+ // "Press Enter" auto-ack is therefore Linux-only; the real token flow
192
+ // needs no input before the token renders (verified empirically).
193
+ stdio: [
194
+ process.platform === "darwin" ? "ignore" : "pipe",
195
+ "pipe",
196
+ "pipe",
197
+ ],
169
198
  env: spawnEnv,
170
199
  }),
171
200
  envLocalPath: () => join(env.uaiHome, ".env.local"),
@@ -173,9 +202,43 @@ function defaultSeams(): EngineSeams {
173
202
  procEnv: process.env,
174
203
  loginShellPath: probeLoginShellPath,
175
204
  systemBinDirs: ["/opt/homebrew/bin", "/usr/local/bin"],
205
+ ptyWrap: defaultPtyWrap,
176
206
  };
177
207
  }
178
208
 
209
+ /**
210
+ * Lend a CLI a real PTY via script(1) — no native deps, ships with macOS
211
+ * (BSD) and Linux (util-linux; argument conventions differ, hence the
212
+ * branch). Ink-based CLIs (claude) render nothing under plain pipes: Ink
213
+ * detects a non-TTY stdout and buffers every frame until unmount, and the
214
+ * interactive screens can't complete without a terminal — so the child sat
215
+ * silent forever. The inner `stty cols 400` matters too: at the default 80
216
+ * columns the token line WRAPS and a wrapped token extracts truncated.
217
+ */
218
+ function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
219
+ if (!existsSync("/usr/bin/script")) return null;
220
+ const stty = "stty cols 400 rows 100 2>/dev/null";
221
+ if (process.platform === "darwin") {
222
+ // BSD script: `script -q /dev/null command [args…]` — command is exec'd
223
+ // as an argv (no shell), so route through sh only to set the width.
224
+ return {
225
+ command: "/usr/bin/script",
226
+ args: ["-q", "/dev/null", "/bin/sh", "-c", `${stty}; exec "$0" "$@"`, bin, ...args],
227
+ };
228
+ }
229
+ if (process.platform === "linux") {
230
+ // util-linux script: the command is a single shell string (-c).
231
+ const quoted = [bin, ...args]
232
+ .map((a) => `'${a.replace(/'/g, `'\\''`)}'`)
233
+ .join(" ");
234
+ return {
235
+ command: "/usr/bin/script",
236
+ args: ["-qec", `${stty}; exec ${quoted}`, "/dev/null"],
237
+ };
238
+ }
239
+ return null;
240
+ }
241
+
179
242
  /**
180
243
  * The PATH the owner's login shell exports. Runs `/usr/bin/env` (not
181
244
  * `echo $PATH`) so the value is the real colon-joined exported PATH under any
@@ -412,7 +475,32 @@ export function disconnectEngine(
412
475
  }
413
476
 
414
477
  /**
415
- * Extract a Claude OAuth token from `claude setup-token` stdout. The CLI prints
478
+ * Flatten a PTY byte stream into plain text lines. Strips OSC sequences
479
+ * (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
480
+ * sequences (cursor-column moves become a space — Ink positions words with
481
+ * `ESC[<n>G` instead of writing spaces), stray ESC singles, and normalizes
482
+ * `\r` to `\n`. Identity on already-plain text apart from whitespace runs.
483
+ */
484
+ export function sanitizeTerminalOutput(raw: string): string {
485
+ return raw
486
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC …BEL / …ST
487
+ .replace(/\x1b\[[0-9;:?<=>]*[!-/]*[@-~]/g, " ") // CSI
488
+ .replace(/\x1b[()][0-9A-B]/g, "") // charset selects
489
+ .replace(/\x1b[78=>DEHM]/g, "") // save/restore cursor etc.
490
+ .replace(/\r\n?/g, "\n")
491
+ .replace(/[^\S\n]+/g, " ");
492
+ }
493
+
494
+ /** Lines worth showing a human: non-blank, not spinner or logo-art glyphs. */
495
+ function significantLines(text: string): string[] {
496
+ return text
497
+ .split("\n")
498
+ .map((l) => l.trim())
499
+ .filter((l) => l.length > 1 && !/^[·✢✳✶✻✽*+.…|/\\\-░▒▓█▄▀\s]+$/u.test(l));
500
+ }
501
+
502
+ /**
503
+ * Extract a Claude OAuth token from `claude setup-token` output. The CLI shows
416
504
  * the token (an `sk-ant-oat…` value) on its own line near the end; scan from the
417
505
  * bottom for it, falling back to a lone long token-charset line.
418
506
  */
@@ -550,16 +638,29 @@ function prependPath(prepend: string[], current: string): string {
550
638
  .join(":");
551
639
  }
552
640
 
553
- /** Resolve the CLI and spawn it with the augmented PATH. */
641
+ /** Resolve the CLI and spawn it (optionally under a PTY) with the augmented PATH. */
554
642
  async function spawnEngineCli(
555
643
  kind: EngineKind,
556
644
  args: string[],
557
645
  s: EngineSeams,
646
+ pty = false,
558
647
  ): Promise<ChildProcess> {
559
648
  const { bin, path } = await resolveEngineBin(kind, s);
560
- return s.spawn(bin, args, { ...s.procEnv, PATH: path });
649
+ const wrapped = pty && s.ptyWrap ? s.ptyWrap(bin, args) : null;
650
+ const spawnEnv: NodeJS.ProcessEnv = { ...s.procEnv, PATH: path };
651
+ // A GUI-supervised host has no TERM; give the PTY child a sane one.
652
+ if (wrapped && !spawnEnv.TERM) spawnEnv.TERM = "xterm-256color";
653
+ return wrapped
654
+ ? s.spawn(wrapped.command, wrapped.args, spawnEnv)
655
+ : s.spawn(bin, args, spawnEnv);
561
656
  }
562
657
 
658
+ /** Browser authorization is human-paced — give it real time before bailing. */
659
+ const TOKEN_FLOW_TIMEOUT_MS = 10 * 60_000;
660
+
661
+ const TOKEN_FLOW_FALLBACK =
662
+ "Try again, or run `claude setup-token` in your terminal and paste the token here.";
663
+
563
664
  async function runTokenCommand(
564
665
  kind: EngineKind,
565
666
  label: string,
@@ -568,25 +669,68 @@ async function runTokenCommand(
568
669
  ): Promise<ConnectResult> {
569
670
  let child: ChildProcess;
570
671
  try {
571
- child = await spawnEngineCli(kind, ["setup-token"], s);
672
+ child = await spawnEngineCli(kind, ["setup-token"], s, true);
572
673
  } catch (err) {
573
674
  return { ok: false, message: err instanceof Error ? err.message : String(err) };
574
675
  }
575
676
  return new Promise((resolve) => {
576
677
  let settled = false;
577
- const done = (r: ConnectResult): void => {
578
- if (!settled) {
579
- settled = true;
580
- resolve(r);
678
+ let timer: NodeJS.Timeout | undefined;
679
+ const done = (r: ConnectResult, kill = false): void => {
680
+ if (settled) return;
681
+ settled = true;
682
+ clearTimeout(timer);
683
+ if (kill) {
684
+ try {
685
+ child.kill();
686
+ } catch {
687
+ /* already gone */
688
+ }
581
689
  }
690
+ resolve(r);
582
691
  };
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));
692
+ let raw = "";
693
+ const forwarded = new Set<string>();
694
+ const acked = new Set<string>();
695
+ const saveToken = (token: string): void => {
696
+ upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
697
+ done({ ok: true, message: `${label} connected.` }, true);
698
+ };
699
+ const onChunk = (b: Buffer): void => {
700
+ raw += b.toString("utf8");
701
+ // A pending spinner redraws for minutes — keep only the tail (the token
702
+ // and final screens are at the end) so re-sanitizing stays cheap.
703
+ if (raw.length > 262_144) raw = raw.slice(-131_072);
704
+ const text = sanitizeTerminalOutput(raw);
705
+ // Act only on COMPLETE lines: a trailing partial may still be missing
706
+ // the rest of an escape sequence — or the rest of the TOKEN, and the
707
+ // extractor would happily return a truncated prefix.
708
+ const complete = text.slice(0, text.lastIndexOf("\n") + 1);
709
+ for (const line of significantLines(complete)) {
710
+ // Each distinct line once — Ink redraws the same screen constantly
711
+ // and would flood the UI log.
712
+ if (!forwarded.has(line)) {
713
+ forwarded.add(line);
714
+ onLog(line);
715
+ }
716
+ // Auto-acknowledge keypress gates (once per distinct screen text) —
717
+ // with a PTY the CLI accepts input, and nobody is at its keyboard.
718
+ if (/press enter/i.test(line) && !acked.has(line)) {
719
+ acked.add(line);
720
+ try {
721
+ child.stdin?.write("\r");
722
+ } catch {
723
+ /* best effort */
724
+ }
725
+ }
726
+ }
727
+ // The whole point: capture the token the moment it renders. The CLI's
728
+ // final screen lingers for a keypress; we don't wait for exit.
729
+ const token = extractClaudeToken(complete);
730
+ if (token) saveToken(token);
731
+ };
732
+ child.stdout?.on("data", onChunk);
733
+ child.stderr?.on("data", onChunk);
590
734
  child.on("error", (err: NodeJS.ErrnoException) =>
591
735
  done({
592
736
  ok: false,
@@ -597,18 +741,27 @@ async function runTokenCommand(
597
741
  }),
598
742
  );
599
743
  child.on("exit", () => {
600
- const token = extractClaudeToken(stdout);
744
+ const token = extractClaudeToken(sanitizeTerminalOutput(raw));
601
745
  if (!token) {
602
746
  done({
603
747
  ok: false,
604
- message:
605
- "Couldn't read a token from the CLI output. Try again, or paste the token manually.",
748
+ message: `The sign-in didn't finish. ${TOKEN_FLOW_FALLBACK}`,
606
749
  });
607
750
  return;
608
751
  }
609
- upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
610
- done({ ok: true, message: `${label} connected.` });
752
+ saveToken(token);
611
753
  });
754
+ timer = setTimeout(
755
+ () =>
756
+ done(
757
+ {
758
+ ok: false,
759
+ message: `Timed out waiting for the browser authorization. ${TOKEN_FLOW_FALLBACK}`,
760
+ },
761
+ true,
762
+ ),
763
+ TOKEN_FLOW_TIMEOUT_MS,
764
+ );
612
765
  });
613
766
  }
614
767
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
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>",