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