@runuai/host 0.9.55 → 0.9.57

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.
@@ -125,7 +125,7 @@ export interface EngineLoginSeams {
125
125
  removeTempDir(path: string): Promise<void>;
126
126
  startContainer(
127
127
  request: EngineLoginContainerRequest,
128
- onOutput: (chunk: string) => void,
128
+ onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
129
129
  ): Promise<EngineLoginProcess>;
130
130
  replayCodexCallback(
131
131
  containerName: string,
@@ -186,6 +186,8 @@ interface LoginOperation {
186
186
  settlementPromise: Promise<void> | null;
187
187
  suppressTerminalEvent: boolean;
188
188
  outputTail: string;
189
+ /** stdout-only tail — the credential boundary (stderr never joins it). */
190
+ stdoutTail: string;
189
191
  inputUsed: boolean;
190
192
  callbackUsed: boolean;
191
193
  pendingInput: string | null;
@@ -393,6 +395,7 @@ export class EngineLoginManager {
393
395
  settlementPromise: null,
394
396
  suppressTerminalEvent: false,
395
397
  outputTail: "",
398
+ stdoutTail: "",
396
399
  inputUsed: false,
397
400
  callbackUsed: false,
398
401
  pendingInput: null,
@@ -462,6 +465,7 @@ export class EngineLoginManager {
462
465
  );
463
466
  operation.inputUsed = true;
464
467
  operation.outputTail = "";
468
+ operation.stdoutTail = "";
465
469
  if (!operation.process) {
466
470
  console.log(
467
471
  `[engine-login] input buffered for ${opId} (login process still starting)`,
@@ -650,7 +654,7 @@ export class EngineLoginManager {
650
654
  const containerName = `uai-login-${operation.engine}-${this.seams.randomHex(8)}`;
651
655
  processHandle = await this.seams.startContainer(
652
656
  { engine: operation.engine, containerName, tempDir },
653
- (chunk) => this.onOutput(operation, chunk),
657
+ (chunk, stream) => this.onOutput(operation, chunk, stream ?? "stdout"),
654
658
  );
655
659
  if (!this.isCurrent(operation)) {
656
660
  await this.stopDetachedProcess(processHandle);
@@ -688,13 +692,28 @@ export class EngineLoginManager {
688
692
  }
689
693
  }
690
694
 
691
- private onOutput(operation: LoginOperation, chunk: string): void {
695
+ private onOutput(
696
+ operation: LoginOperation,
697
+ chunk: string,
698
+ stream: "stdout" | "stderr" = "stdout",
699
+ ): void {
692
700
  if (!this.isCurrent(operation) || operation.finishing) return;
693
701
  operation.outputTail = appendUtf8Tail(
694
702
  operation.outputTail,
695
703
  chunk,
696
704
  MAX_OUTPUT_TAIL_BYTES,
697
705
  );
706
+ if (stream === "stdout") {
707
+ // The CREDENTIAL boundary reads stdout only: stderr chunks (the Apple
708
+ // runtime's XPC chatter) interleaving into a merged tail once produced
709
+ // a corrupted token-shaped capture (live 2026-08-21). The merged tail
710
+ // above stays for URL detection and human-facing evidence.
711
+ operation.stdoutTail = appendUtf8Tail(
712
+ operation.stdoutTail,
713
+ chunk,
714
+ MAX_OUTPUT_TAIL_BYTES,
715
+ );
716
+ }
698
717
 
699
718
  if (operation.engine === "claude") {
700
719
  if (operation.inputUsed && !operation.postInputOutputSeen) {
@@ -723,14 +742,18 @@ export class EngineLoginManager {
723
742
  // Before the one-time input is consumed, token-shaped output could only
724
743
  // be an authorization URL/detail or hostile terminal content. This also
725
744
  // prevents an input echo from becoming the credential boundary.
745
+ // The scan reads the RECONSTRUCTED screen, not the raw stream: the
746
+ // TUI's diff renderer skips already-correct cells, so the raw bytes
747
+ // never carry the contiguous token (see renderTerminalScreenText).
726
748
  const token = operation.inputUsed
727
- ? extractClaudeOAuthToken(operation.outputTail)
749
+ ? extractClaudeOAuthToken(renderTerminalScreenText(operation.stdoutTail))
728
750
  : null;
729
751
  if (token) {
730
752
  console.log(
731
753
  `[engine-login] token detected in CLI output for ${operation.opId}; persisting`,
732
754
  );
733
755
  operation.outputTail = "";
756
+ operation.stdoutTail = "";
734
757
  this.beginClaudeFinish(operation, token);
735
758
  return;
736
759
  }
@@ -990,6 +1013,7 @@ export class EngineLoginManager {
990
1013
  private detach(operation: LoginOperation): void {
991
1014
  operation.active = false;
992
1015
  operation.outputTail = "";
1016
+ operation.stdoutTail = "";
993
1017
  operation.pendingInput = null;
994
1018
  operation.callbackTarget = null;
995
1019
  this.clearOperationTimer(operation);
@@ -1128,8 +1152,10 @@ export function detectClaudeCliError(tail: string): string | null {
1128
1152
  * secret-checked so it can ride a terminal event message (ADR-116). Anything
1129
1153
  * token-shaped disqualifies the whole line rather than risking a partial. */
1130
1154
  function lastSignificantOutputLine(tail: string): string | null {
1131
- const lines = tail
1132
- .replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
1155
+ // stripTerminalControl handles the shapes the old inline regex missed —
1156
+ // private-parameter CSI (kitty's ESC[<u, ESC[>4m) and charset selects
1157
+ // (ESC(B) — whose husks once surfaced verbatim as "(B[>4m[<u" evidence.
1158
+ const lines = stripTerminalControl(tail)
1133
1159
  .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
1134
1160
  .split("\n")
1135
1161
  .map((line) => line.trim())
@@ -1211,6 +1237,146 @@ function stripTerminalControl(value: string): string {
1211
1237
  .replace(/\r/g, "\n");
1212
1238
  }
1213
1239
 
1240
+ const SCREEN_MAX_ROWS = 200;
1241
+ const SCREEN_MAX_COLS = 600;
1242
+
1243
+ /**
1244
+ * Rebuild the visible terminal screen from raw TUI output. The login CLI's
1245
+ * renderer diffs frames: a repainted line SKIPS cells that already show the
1246
+ * right character and jumps the cursor over them. The minted token paints
1247
+ * over the "Paste code here if prompted >" prompt, whose `code` leaves an
1248
+ * 'o' exactly where `sk-ant-oat01-`'s 'o' lands — so the byte stream carries
1249
+ * `sk-ant-` ESC[10G `at01-…` and NEVER the contiguous token (live
1250
+ * 2026-08-21, first Linux host; the Air's earlier "corrupted" capture was
1251
+ * the same hole, not stderr interleave). Only a spatial reconstruction sees
1252
+ * what the operator sees, so the credential scan reads this rendering, never
1253
+ * the raw stream. Cells no frame painted stay spaces — a hole can only be
1254
+ * filled by content some frame actually painted there, never closed up.
1255
+ */
1256
+ export function renderTerminalScreenText(value: string): string {
1257
+ const rows: (string[] | undefined)[] = [];
1258
+ let row = 0;
1259
+ let col = 0;
1260
+ let savedRow = 0;
1261
+ let savedCol = 0;
1262
+ const clamp = () => {
1263
+ row = Math.min(Math.max(row, 0), SCREEN_MAX_ROWS - 1);
1264
+ col = Math.min(Math.max(col, 0), SCREEN_MAX_COLS);
1265
+ };
1266
+ const line = (): string[] => (rows[row] ??= []);
1267
+ const pattern =
1268
+ // OSC | CSI(params, final) | charset | bare ESC final | CR | LF | text
1269
+ /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[([0-9;:?<=>]*)[!-/]*([@-~])|\x1b[()][0-9A-B]|\x1b([78=>DEHM])|(\r)|(\n)|([^\x1b\r\n\x00-\x1f\x7f]+)|[\s\S]/g;
1270
+ for (const part of value.matchAll(pattern)) {
1271
+ const [, csiParams, csiFinal, escFinal, cr, lf, text] = part;
1272
+ if (text !== undefined) {
1273
+ const cells = line();
1274
+ for (const ch of text) {
1275
+ if (col < SCREEN_MAX_COLS) cells[col] = ch;
1276
+ col += 1;
1277
+ }
1278
+ clamp();
1279
+ continue;
1280
+ }
1281
+ if (cr !== undefined) {
1282
+ col = 0;
1283
+ continue;
1284
+ }
1285
+ if (lf !== undefined) {
1286
+ row += 1;
1287
+ clamp();
1288
+ continue;
1289
+ }
1290
+ if (escFinal !== undefined) {
1291
+ if (escFinal === "7") [savedRow, savedCol] = [row, col];
1292
+ else if (escFinal === "8") [row, col] = [savedRow, savedCol];
1293
+ else if (escFinal === "D" || escFinal === "E") row += 1;
1294
+ else if (escFinal === "M") row -= 1;
1295
+ if (escFinal === "E") col = 0;
1296
+ clamp();
1297
+ continue;
1298
+ }
1299
+ if (csiFinal === undefined) continue;
1300
+ const params = csiParams ?? "";
1301
+ // Private-parameter sequences (kitty keyboard pops, DEC modes) are not
1302
+ // cursor movement — never let e.g. `ESC[<u` restore a stale cursor.
1303
+ if (/[?<=>]/.test(params)) continue;
1304
+ const numbers = params.split(";").map((entry) => Number.parseInt(entry, 10));
1305
+ const n = (index: number, fallback: number): number => {
1306
+ const parsed = numbers[index];
1307
+ return Number.isFinite(parsed) && parsed! > 0 ? parsed! : fallback;
1308
+ };
1309
+ switch (csiFinal) {
1310
+ case "G":
1311
+ col = n(0, 1) - 1;
1312
+ break;
1313
+ case "A":
1314
+ row -= n(0, 1);
1315
+ break;
1316
+ case "B":
1317
+ row += n(0, 1);
1318
+ break;
1319
+ case "C":
1320
+ col += n(0, 1);
1321
+ break;
1322
+ case "D":
1323
+ col -= n(0, 1);
1324
+ break;
1325
+ case "E":
1326
+ row += n(0, 1);
1327
+ col = 0;
1328
+ break;
1329
+ case "F":
1330
+ row -= n(0, 1);
1331
+ col = 0;
1332
+ break;
1333
+ case "d":
1334
+ row = n(0, 1) - 1;
1335
+ break;
1336
+ case "H":
1337
+ case "f":
1338
+ row = n(0, 1) - 1;
1339
+ col = n(1, 1) - 1;
1340
+ break;
1341
+ case "s":
1342
+ [savedRow, savedCol] = [row, col];
1343
+ break;
1344
+ case "u":
1345
+ [row, col] = [savedRow, savedCol];
1346
+ break;
1347
+ case "K": {
1348
+ const cells = line();
1349
+ const mode = Number.isFinite(numbers[0]) ? numbers[0]! : 0;
1350
+ if (mode === 0) cells.length = Math.min(cells.length, col);
1351
+ else if (mode === 1) {
1352
+ for (let i = 0; i <= col && i < cells.length; i += 1) cells[i] = " ";
1353
+ } else if (mode === 2) cells.length = 0;
1354
+ break;
1355
+ }
1356
+ case "J": {
1357
+ const mode = Number.isFinite(numbers[0]) ? numbers[0]! : 0;
1358
+ if (mode === 0) {
1359
+ line().length = Math.min(line().length, col);
1360
+ rows.length = Math.min(rows.length, row + 1);
1361
+ } else if (mode === 1) {
1362
+ for (let i = 0; i < row; i += 1) rows[i] = undefined;
1363
+ const cells = line();
1364
+ for (let i = 0; i <= col && i < cells.length; i += 1) cells[i] = " ";
1365
+ } else {
1366
+ rows.length = 0;
1367
+ }
1368
+ break;
1369
+ }
1370
+ default:
1371
+ break;
1372
+ }
1373
+ clamp();
1374
+ }
1375
+ return rows
1376
+ .map((cells) => Array.from(cells ?? [], (cell) => cell ?? " ").join(""))
1377
+ .join("\n");
1378
+ }
1379
+
1214
1380
  function candidateHttpsUrls(value: string): string[] {
1215
1381
  const plain = stripTerminalControl(value);
1216
1382
  return [...plain.matchAll(/https:\/\/[^\s<>"'\x00-\x1f]+/g)]
@@ -1247,9 +1413,18 @@ export function findSafeHttpsUrl(value: string): string | null {
1247
1413
 
1248
1414
  export function extractClaudeOAuthToken(value: string): string | null {
1249
1415
  const plain = stripTerminalControl(value);
1250
- const match = /(?:^|[^A-Za-z0-9_-])(sk-ant-[A-Za-z0-9_-]{16,4096})(?![A-Za-z0-9_-])/.exec(
1251
- plain,
1252
- );
1416
+ // The FULL `sk-ant-oat01-` prefix and a realistic minimum length are both
1417
+ // load-bearing: the TUI's diff renderer skips screen cells that already
1418
+ // hold the right character, so naive control-stripping glues the runs into
1419
+ // `sk-ant-at01-…` — one character short, token-shaped, wrong — which a
1420
+ // permissive scan once persisted as a "successful" login whose agents then
1421
+ // 401'd forever (live 2026-08-21). The strict prefix makes any mangled
1422
+ // capture a non-match; the actual token match happens against the
1423
+ // reconstructed screen (renderTerminalScreenText), where it is contiguous.
1424
+ const match =
1425
+ /(?:^|[^A-Za-z0-9_-])(sk-ant-oat01-[A-Za-z0-9_-]{64,4096})(?![A-Za-z0-9_-])/.exec(
1426
+ plain,
1427
+ );
1253
1428
  return match?.[1] ?? null;
1254
1429
  }
1255
1430
 
@@ -1388,7 +1563,7 @@ function callbackReplayUrl(
1388
1563
 
1389
1564
  async function startLoginContainer(
1390
1565
  request: EngineLoginContainerRequest,
1391
- onOutput: (chunk: string) => void,
1566
+ onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
1392
1567
  ): Promise<EngineLoginProcess> {
1393
1568
  const args = engineLoginContainerArgs(request);
1394
1569
  const child = spawn(engineLoginBackend().command, args, {
@@ -1397,8 +1572,8 @@ async function startLoginContainer(
1397
1572
  });
1398
1573
  child.stdout.setEncoding("utf8");
1399
1574
  child.stderr.setEncoding("utf8");
1400
- child.stdout.on("data", (chunk: string) => onOutput(chunk));
1401
- child.stderr.on("data", (chunk: string) => onOutput(chunk));
1575
+ child.stdout.on("data", (chunk: string) => onOutput(chunk, "stdout"));
1576
+ child.stderr.on("data", (chunk: string) => onOutput(chunk, "stderr"));
1402
1577
  child.stdin.on("error", () => {});
1403
1578
 
1404
1579
  let stopped = false;
@@ -1926,6 +2101,11 @@ export async function createEngineLoginTempDir(
1926
2101
  await marker.sync();
1927
2102
  await marker.close();
1928
2103
  marker = null;
2104
+ // The container is told HOME and CODEX_HOME live under the leaf, and
2105
+ // codex refuses to start when CODEX_HOME does not exist (claude
2106
+ // self-creates its HOME). Create both up front.
2107
+ await mkdir(join(path, "home"), { mode: 0o700 });
2108
+ await mkdir(join(path, "codex"), { mode: 0o700 });
1929
2109
  await syncDirectory(path);
1930
2110
  return path;
1931
2111
  } catch (error) {
@@ -2299,6 +2299,11 @@ export class Orchestrator {
2299
2299
  // Token revoked/expired: the env-injected credential only changes on
2300
2300
  // a fresh runner. Re-spawn on the re-resolved account (picks up a
2301
2301
  // reconnected token); bounded so a still-bad token can't loop.
2302
+ // Log the matched evidence — the 2026-08-21 corrupted-token loop was
2303
+ // diagnosed blind because the trigger text never appeared anywhere.
2304
+ console.warn(
2305
+ `[orchestrator] ${channel.taskId}/${agentId}: claude auth-revoked pattern matched: ${event.message.slice(0, 300)}`,
2306
+ );
2302
2307
  await this.refreshAgentToken(channel, agent);
2303
2308
  break;
2304
2309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.55",
3
+ "version": "0.9.57",
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>",
@@ -545,7 +545,7 @@ export async function prepareLinuxDefinitionInstallRollback(
545
545
  }
546
546
  }
547
547
 
548
- function assertSystemdDisabledForRollback(
548
+ export function assertSystemdDisabledForRollback(
549
549
  execute: StepExecutor,
550
550
  allowMissing: boolean,
551
551
  ): void {
@@ -553,11 +553,25 @@ function assertSystemdDisabledForRollback(
553
553
  const disabled = execute(false, "systemctl", disableArgs);
554
554
  if (disabled.status === 0) return;
555
555
  if (allowMissing) {
556
+ // Disabling a unit whose FILE is already gone answers with stderr-only
557
+ // "does not exist" on AL2023 (live 2026-08-21: a poisoned rollback from
558
+ // an earlier failed install could never complete, wedging every later
559
+ // install at "could not begin"). Gone is as disabled as it gets.
560
+ if (/does not exist|no such file or directory/i.test(disabled.stderr)) {
561
+ return;
562
+ }
556
563
  const probeArgs = ["--user", "is-enabled", UNIT];
557
564
  const probe = execute(false, "systemctl", probeArgs);
565
+ const probeState = probe.stdout.trim();
566
+ const neverInstalled =
567
+ probe.status !== 0 &&
568
+ probeState === "" &&
569
+ /no such file or directory|failed to get unit file state/i.test(
570
+ probe.stderr,
571
+ );
558
572
  if (
559
573
  probe.status !== 0 &&
560
- DISABLED_SYSTEMD_STATES.has(probe.stdout.trim())
574
+ (DISABLED_SYSTEMD_STATES.has(probeState) || neverInstalled)
561
575
  ) {
562
576
  return;
563
577
  }