@runuai/host 0.9.56 → 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.
Files changed (2) hide show
  1. package/lib/engine-login.ts +160 -10
  2. package/package.json +1 -1
@@ -742,8 +742,11 @@ export class EngineLoginManager {
742
742
  // Before the one-time input is consumed, token-shaped output could only
743
743
  // be an authorization URL/detail or hostile terminal content. This also
744
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).
745
748
  const token = operation.inputUsed
746
- ? extractClaudeOAuthToken(operation.stdoutTail)
749
+ ? extractClaudeOAuthToken(renderTerminalScreenText(operation.stdoutTail))
747
750
  : null;
748
751
  if (token) {
749
752
  console.log(
@@ -1149,8 +1152,10 @@ export function detectClaudeCliError(tail: string): string | null {
1149
1152
  * secret-checked so it can ride a terminal event message (ADR-116). Anything
1150
1153
  * token-shaped disqualifies the whole line rather than risking a partial. */
1151
1154
  function lastSignificantOutputLine(tail: string): string | null {
1152
- const lines = tail
1153
- .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)
1154
1159
  .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
1155
1160
  .split("\n")
1156
1161
  .map((line) => line.trim())
@@ -1232,6 +1237,146 @@ function stripTerminalControl(value: string): string {
1232
1237
  .replace(/\r/g, "\n");
1233
1238
  }
1234
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
+
1235
1380
  function candidateHttpsUrls(value: string): string[] {
1236
1381
  const plain = stripTerminalControl(value);
1237
1382
  return [...plain.matchAll(/https:\/\/[^\s<>"'\x00-\x1f]+/g)]
@@ -1269,13 +1414,13 @@ export function findSafeHttpsUrl(value: string): string | null {
1269
1414
  export function extractClaudeOAuthToken(value: string): string | null {
1270
1415
  const plain = stripTerminalControl(value);
1271
1416
  // The FULL `sk-ant-oat01-` prefix and a realistic minimum length are both
1272
- // load-bearing: the login CLI's stderr (the Apple runtime's XPC chatter is
1273
- // full of the word "token") interleaves with stdout in the captured tail,
1274
- // and a permissive scan once matched a corrupted hybrid — `sk-ant-at01-…`,
1275
- // one character short, token-shaped, wrong which persisted as a
1276
- // "successful" login whose agents then 401'd forever (live 2026-08-21).
1277
- // A strict prefix makes a mangled interleave a non-match, and the CLI
1278
- // prints the token again on its plain summary line, which then matches.
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.
1279
1424
  const match =
1280
1425
  /(?:^|[^A-Za-z0-9_-])(sk-ant-oat01-[A-Za-z0-9_-]{64,4096})(?![A-Za-z0-9_-])/.exec(
1281
1426
  plain,
@@ -1956,6 +2101,11 @@ export async function createEngineLoginTempDir(
1956
2101
  await marker.sync();
1957
2102
  await marker.close();
1958
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 });
1959
2109
  await syncDirectory(path);
1960
2110
  return path;
1961
2111
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.56",
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>",