@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.
- package/lib/engines.ts +168 -24
- 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`
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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(
|
|
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
|
-
|
|
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