@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.
- package/lib/engine-login.ts +192 -12
- package/lib/orchestrator.ts +5 -0
- package/package.json +1 -1
- package/scripts/install/linux.ts +16 -2
package/lib/engine-login.ts
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
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) {
|
package/lib/orchestrator.ts
CHANGED
|
@@ -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
package/scripts/install/linux.ts
CHANGED
|
@@ -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(
|
|
574
|
+
(DISABLED_SYSTEMD_STATES.has(probeState) || neverInstalled)
|
|
561
575
|
) {
|
|
562
576
|
return;
|
|
563
577
|
}
|