@runuai/host 0.9.56 → 0.9.58
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/engine-login.ts +212 -10
- package/package.json +1 -1
package/lib/engine-login.ts
CHANGED
|
@@ -66,6 +66,8 @@ const LOGIN_MOUNT = "/run/uai-engine-login";
|
|
|
66
66
|
const LOGIN_HOME = `${LOGIN_MOUNT}/home`;
|
|
67
67
|
const CODEX_HOME = `${LOGIN_MOUNT}/codex`;
|
|
68
68
|
const MAX_OUTPUT_TAIL_BYTES = 64 * 1_024;
|
|
69
|
+
/** script(1) transcripts of a login run a few KB; 1MB is a hostile-file cap. */
|
|
70
|
+
const MAX_TRANSCRIPT_BYTES = 1_024 * 1_024;
|
|
69
71
|
const MAX_CODEX_AUTH_BYTES = 1024 * 1024;
|
|
70
72
|
const DOCKER_CONTROL_TIMEOUT_MS = 15_000;
|
|
71
73
|
const DOCKER_CLEANUP_TIMEOUT_MS = 5_000;
|
|
@@ -132,6 +134,14 @@ export interface EngineLoginSeams {
|
|
|
132
134
|
callbackUrl: string,
|
|
133
135
|
): Promise<void>;
|
|
134
136
|
reconcileStaleContainers(): Promise<void>;
|
|
137
|
+
/**
|
|
138
|
+
* Read the script(1) PTY transcript from the login leaf (null when absent
|
|
139
|
+
* or unreadable). The transcript is written INSIDE the container, so it is
|
|
140
|
+
* the pure PTY byte stream — structurally free of the container runtime's
|
|
141
|
+
* own stdio chatter, which on the Apple CLI painted XPC noise over the
|
|
142
|
+
* very cells the TUI's diff renderer skipped (live 2026-08-21).
|
|
143
|
+
*/
|
|
144
|
+
readTranscript(tempDir: string): Promise<string | null>;
|
|
135
145
|
persistClaudeToken(token: string): Promise<void>;
|
|
136
146
|
persistCodexAuth(sourceFile: string): Promise<void>;
|
|
137
147
|
/** ADR-116: labeled extra-account captures (never the default slots). */
|
|
@@ -272,6 +282,17 @@ function defaultSeams(options: EngineLoginManagerOptions): EngineLoginSeams {
|
|
|
272
282
|
replayCodexCallback,
|
|
273
283
|
reconcileStaleContainers: () =>
|
|
274
284
|
reconcileStaleEngineLoginResources({ tempRoot }),
|
|
285
|
+
readTranscript: async (tempDir) => {
|
|
286
|
+
try {
|
|
287
|
+
const body = await readBoundedRegularFile(
|
|
288
|
+
join(tempDir, "typescript"),
|
|
289
|
+
MAX_TRANSCRIPT_BYTES,
|
|
290
|
+
);
|
|
291
|
+
return body.toString("utf8");
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
275
296
|
persistClaudeToken: (token) => persistClaudeTokenAtomic(token),
|
|
276
297
|
persistCodexAuth: (sourceFile) => persistCodexAuthAtomic(sourceFile),
|
|
277
298
|
addClaudeAccount: (label, token) =>
|
|
@@ -742,8 +763,11 @@ export class EngineLoginManager {
|
|
|
742
763
|
// Before the one-time input is consumed, token-shaped output could only
|
|
743
764
|
// be an authorization URL/detail or hostile terminal content. This also
|
|
744
765
|
// prevents an input echo from becoming the credential boundary.
|
|
766
|
+
// The scan reads the RECONSTRUCTED screen, not the raw stream: the
|
|
767
|
+
// TUI's diff renderer skips already-correct cells, so the raw bytes
|
|
768
|
+
// never carry the contiguous token (see renderTerminalScreenText).
|
|
745
769
|
const token = operation.inputUsed
|
|
746
|
-
? extractClaudeOAuthToken(operation.stdoutTail)
|
|
770
|
+
? extractClaudeOAuthToken(renderTerminalScreenText(operation.stdoutTail))
|
|
747
771
|
: null;
|
|
748
772
|
if (token) {
|
|
749
773
|
console.log(
|
|
@@ -804,6 +828,37 @@ export class EngineLoginManager {
|
|
|
804
828
|
if (operation.finishPromise) await operation.finishPromise;
|
|
805
829
|
return;
|
|
806
830
|
}
|
|
831
|
+
if (
|
|
832
|
+
operation.engine === "claude" &&
|
|
833
|
+
result.code === 0 &&
|
|
834
|
+
operation.inputUsed &&
|
|
835
|
+
operation.tempDir
|
|
836
|
+
) {
|
|
837
|
+
// A clean exit after the code went in means the CLI almost certainly
|
|
838
|
+
// minted and printed the token — a stream capture that saw nothing is
|
|
839
|
+
// the capture's failure, not the login's. The relayed stream can be
|
|
840
|
+
// polluted by the container runtime's own chatter (the Apple CLI's XPC
|
|
841
|
+
// noise painted characters over the cells the TUI's diff renderer
|
|
842
|
+
// skipped, corrupting the reconstructed screen — live 2026-08-21 on
|
|
843
|
+
// the Air, straight after the Linux fix). The PTY transcript is
|
|
844
|
+
// written inside the container and cannot contain that noise, so it
|
|
845
|
+
// gets the authoritative re-read before this operation is failed.
|
|
846
|
+
const transcript = await this.seams.readTranscript(operation.tempDir);
|
|
847
|
+
if (!this.isCurrent(operation) || operation.finishing) return;
|
|
848
|
+
const token = transcript
|
|
849
|
+
? extractClaudeOAuthToken(renderTerminalScreenText(transcript))
|
|
850
|
+
: null;
|
|
851
|
+
if (token) {
|
|
852
|
+
console.log(
|
|
853
|
+
`[engine-login] token recovered from the PTY transcript for ${operation.opId}; persisting`,
|
|
854
|
+
);
|
|
855
|
+
operation.outputTail = "";
|
|
856
|
+
operation.stdoutTail = "";
|
|
857
|
+
this.beginClaudeFinish(operation, token);
|
|
858
|
+
if (operation.finishPromise) await operation.finishPromise;
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
807
862
|
const evidence = lastSignificantOutputLine(operation.outputTail);
|
|
808
863
|
console.warn(
|
|
809
864
|
`[engine-login] login process for ${operation.opId} ended (exit ${result.code ?? "spawn-failed"}) before completion` +
|
|
@@ -1149,8 +1204,10 @@ export function detectClaudeCliError(tail: string): string | null {
|
|
|
1149
1204
|
* secret-checked so it can ride a terminal event message (ADR-116). Anything
|
|
1150
1205
|
* token-shaped disqualifies the whole line rather than risking a partial. */
|
|
1151
1206
|
function lastSignificantOutputLine(tail: string): string | null {
|
|
1152
|
-
|
|
1153
|
-
|
|
1207
|
+
// stripTerminalControl handles the shapes the old inline regex missed —
|
|
1208
|
+
// private-parameter CSI (kitty's ESC[<u, ESC[>4m) and charset selects
|
|
1209
|
+
// (ESC(B) — whose husks once surfaced verbatim as "(B[>4m[<u" evidence.
|
|
1210
|
+
const lines = stripTerminalControl(tail)
|
|
1154
1211
|
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
|
|
1155
1212
|
.split("\n")
|
|
1156
1213
|
.map((line) => line.trim())
|
|
@@ -1232,6 +1289,146 @@ function stripTerminalControl(value: string): string {
|
|
|
1232
1289
|
.replace(/\r/g, "\n");
|
|
1233
1290
|
}
|
|
1234
1291
|
|
|
1292
|
+
const SCREEN_MAX_ROWS = 200;
|
|
1293
|
+
const SCREEN_MAX_COLS = 600;
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Rebuild the visible terminal screen from raw TUI output. The login CLI's
|
|
1297
|
+
* renderer diffs frames: a repainted line SKIPS cells that already show the
|
|
1298
|
+
* right character and jumps the cursor over them. The minted token paints
|
|
1299
|
+
* over the "Paste code here if prompted >" prompt, whose `code` leaves an
|
|
1300
|
+
* 'o' exactly where `sk-ant-oat01-`'s 'o' lands — so the byte stream carries
|
|
1301
|
+
* `sk-ant-` ESC[10G `at01-…` and NEVER the contiguous token (live
|
|
1302
|
+
* 2026-08-21, first Linux host; the Air's earlier "corrupted" capture was
|
|
1303
|
+
* the same hole, not stderr interleave). Only a spatial reconstruction sees
|
|
1304
|
+
* what the operator sees, so the credential scan reads this rendering, never
|
|
1305
|
+
* the raw stream. Cells no frame painted stay spaces — a hole can only be
|
|
1306
|
+
* filled by content some frame actually painted there, never closed up.
|
|
1307
|
+
*/
|
|
1308
|
+
export function renderTerminalScreenText(value: string): string {
|
|
1309
|
+
const rows: (string[] | undefined)[] = [];
|
|
1310
|
+
let row = 0;
|
|
1311
|
+
let col = 0;
|
|
1312
|
+
let savedRow = 0;
|
|
1313
|
+
let savedCol = 0;
|
|
1314
|
+
const clamp = () => {
|
|
1315
|
+
row = Math.min(Math.max(row, 0), SCREEN_MAX_ROWS - 1);
|
|
1316
|
+
col = Math.min(Math.max(col, 0), SCREEN_MAX_COLS);
|
|
1317
|
+
};
|
|
1318
|
+
const line = (): string[] => (rows[row] ??= []);
|
|
1319
|
+
const pattern =
|
|
1320
|
+
// OSC | CSI(params, final) | charset | bare ESC final | CR | LF | text
|
|
1321
|
+
/\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;
|
|
1322
|
+
for (const part of value.matchAll(pattern)) {
|
|
1323
|
+
const [, csiParams, csiFinal, escFinal, cr, lf, text] = part;
|
|
1324
|
+
if (text !== undefined) {
|
|
1325
|
+
const cells = line();
|
|
1326
|
+
for (const ch of text) {
|
|
1327
|
+
if (col < SCREEN_MAX_COLS) cells[col] = ch;
|
|
1328
|
+
col += 1;
|
|
1329
|
+
}
|
|
1330
|
+
clamp();
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
if (cr !== undefined) {
|
|
1334
|
+
col = 0;
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
if (lf !== undefined) {
|
|
1338
|
+
row += 1;
|
|
1339
|
+
clamp();
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
if (escFinal !== undefined) {
|
|
1343
|
+
if (escFinal === "7") [savedRow, savedCol] = [row, col];
|
|
1344
|
+
else if (escFinal === "8") [row, col] = [savedRow, savedCol];
|
|
1345
|
+
else if (escFinal === "D" || escFinal === "E") row += 1;
|
|
1346
|
+
else if (escFinal === "M") row -= 1;
|
|
1347
|
+
if (escFinal === "E") col = 0;
|
|
1348
|
+
clamp();
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
if (csiFinal === undefined) continue;
|
|
1352
|
+
const params = csiParams ?? "";
|
|
1353
|
+
// Private-parameter sequences (kitty keyboard pops, DEC modes) are not
|
|
1354
|
+
// cursor movement — never let e.g. `ESC[<u` restore a stale cursor.
|
|
1355
|
+
if (/[?<=>]/.test(params)) continue;
|
|
1356
|
+
const numbers = params.split(";").map((entry) => Number.parseInt(entry, 10));
|
|
1357
|
+
const n = (index: number, fallback: number): number => {
|
|
1358
|
+
const parsed = numbers[index];
|
|
1359
|
+
return Number.isFinite(parsed) && parsed! > 0 ? parsed! : fallback;
|
|
1360
|
+
};
|
|
1361
|
+
switch (csiFinal) {
|
|
1362
|
+
case "G":
|
|
1363
|
+
col = n(0, 1) - 1;
|
|
1364
|
+
break;
|
|
1365
|
+
case "A":
|
|
1366
|
+
row -= n(0, 1);
|
|
1367
|
+
break;
|
|
1368
|
+
case "B":
|
|
1369
|
+
row += n(0, 1);
|
|
1370
|
+
break;
|
|
1371
|
+
case "C":
|
|
1372
|
+
col += n(0, 1);
|
|
1373
|
+
break;
|
|
1374
|
+
case "D":
|
|
1375
|
+
col -= n(0, 1);
|
|
1376
|
+
break;
|
|
1377
|
+
case "E":
|
|
1378
|
+
row += n(0, 1);
|
|
1379
|
+
col = 0;
|
|
1380
|
+
break;
|
|
1381
|
+
case "F":
|
|
1382
|
+
row -= n(0, 1);
|
|
1383
|
+
col = 0;
|
|
1384
|
+
break;
|
|
1385
|
+
case "d":
|
|
1386
|
+
row = n(0, 1) - 1;
|
|
1387
|
+
break;
|
|
1388
|
+
case "H":
|
|
1389
|
+
case "f":
|
|
1390
|
+
row = n(0, 1) - 1;
|
|
1391
|
+
col = n(1, 1) - 1;
|
|
1392
|
+
break;
|
|
1393
|
+
case "s":
|
|
1394
|
+
[savedRow, savedCol] = [row, col];
|
|
1395
|
+
break;
|
|
1396
|
+
case "u":
|
|
1397
|
+
[row, col] = [savedRow, savedCol];
|
|
1398
|
+
break;
|
|
1399
|
+
case "K": {
|
|
1400
|
+
const cells = line();
|
|
1401
|
+
const mode = Number.isFinite(numbers[0]) ? numbers[0]! : 0;
|
|
1402
|
+
if (mode === 0) cells.length = Math.min(cells.length, col);
|
|
1403
|
+
else if (mode === 1) {
|
|
1404
|
+
for (let i = 0; i <= col && i < cells.length; i += 1) cells[i] = " ";
|
|
1405
|
+
} else if (mode === 2) cells.length = 0;
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
case "J": {
|
|
1409
|
+
const mode = Number.isFinite(numbers[0]) ? numbers[0]! : 0;
|
|
1410
|
+
if (mode === 0) {
|
|
1411
|
+
line().length = Math.min(line().length, col);
|
|
1412
|
+
rows.length = Math.min(rows.length, row + 1);
|
|
1413
|
+
} else if (mode === 1) {
|
|
1414
|
+
for (let i = 0; i < row; i += 1) rows[i] = undefined;
|
|
1415
|
+
const cells = line();
|
|
1416
|
+
for (let i = 0; i <= col && i < cells.length; i += 1) cells[i] = " ";
|
|
1417
|
+
} else {
|
|
1418
|
+
rows.length = 0;
|
|
1419
|
+
}
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
default:
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1425
|
+
clamp();
|
|
1426
|
+
}
|
|
1427
|
+
return rows
|
|
1428
|
+
.map((cells) => Array.from(cells ?? [], (cell) => cell ?? " ").join(""))
|
|
1429
|
+
.join("\n");
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1235
1432
|
function candidateHttpsUrls(value: string): string[] {
|
|
1236
1433
|
const plain = stripTerminalControl(value);
|
|
1237
1434
|
return [...plain.matchAll(/https:\/\/[^\s<>"'\x00-\x1f]+/g)]
|
|
@@ -1269,13 +1466,13 @@ export function findSafeHttpsUrl(value: string): string | null {
|
|
|
1269
1466
|
export function extractClaudeOAuthToken(value: string): string | null {
|
|
1270
1467
|
const plain = stripTerminalControl(value);
|
|
1271
1468
|
// The FULL `sk-ant-oat01-` prefix and a realistic minimum length are both
|
|
1272
|
-
// load-bearing: the
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1469
|
+
// load-bearing: the TUI's diff renderer skips screen cells that already
|
|
1470
|
+
// hold the right character, so naive control-stripping glues the runs into
|
|
1471
|
+
// `sk-ant-at01-…` — one character short, token-shaped, wrong — which a
|
|
1472
|
+
// permissive scan once persisted as a "successful" login whose agents then
|
|
1473
|
+
// 401'd forever (live 2026-08-21). The strict prefix makes any mangled
|
|
1474
|
+
// capture a non-match; the actual token match happens against the
|
|
1475
|
+
// reconstructed screen (renderTerminalScreenText), where it is contiguous.
|
|
1279
1476
|
const match =
|
|
1280
1477
|
/(?:^|[^A-Za-z0-9_-])(sk-ant-oat01-[A-Za-z0-9_-]{64,4096})(?![A-Za-z0-9_-])/.exec(
|
|
1281
1478
|
plain,
|
|
@@ -1956,6 +2153,11 @@ export async function createEngineLoginTempDir(
|
|
|
1956
2153
|
await marker.sync();
|
|
1957
2154
|
await marker.close();
|
|
1958
2155
|
marker = null;
|
|
2156
|
+
// The container is told HOME and CODEX_HOME live under the leaf, and
|
|
2157
|
+
// codex refuses to start when CODEX_HOME does not exist (claude
|
|
2158
|
+
// self-creates its HOME). Create both up front.
|
|
2159
|
+
await mkdir(join(path, "home"), { mode: 0o700 });
|
|
2160
|
+
await mkdir(join(path, "codex"), { mode: 0o700 });
|
|
1959
2161
|
await syncDirectory(path);
|
|
1960
2162
|
return path;
|
|
1961
2163
|
} catch (error) {
|