@runuai/host 0.9.57 → 0.9.59
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 +210 -4
- package/package.json +1 -1
- package/scripts/agent/_common.sh +27 -2
- package/scripts/agent/task-down.sh +1 -0
- package/scripts/agent/task-up.sh +1 -0
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) =>
|
|
@@ -780,6 +801,12 @@ export class EngineLoginManager {
|
|
|
780
801
|
if (operation.stage === "starting") {
|
|
781
802
|
const authorization = findCodexAuthorization(operation.outputTail);
|
|
782
803
|
if (authorization) {
|
|
804
|
+
// Symmetric with the claude branch: this breadcrumb's ABSENCE was
|
|
805
|
+
// unreadable during the 2026-08-21 hunt — silence meant either "no
|
|
806
|
+
// output arrived" or "detection fired, codex just never says so".
|
|
807
|
+
console.log(
|
|
808
|
+
`[engine-login] codex authorize URL found for ${operation.opId}; awaiting the callback URL`,
|
|
809
|
+
);
|
|
783
810
|
operation.callbackTarget = authorization.target;
|
|
784
811
|
operation.stage = "awaiting_callback";
|
|
785
812
|
safeEmit(
|
|
@@ -807,6 +834,37 @@ export class EngineLoginManager {
|
|
|
807
834
|
if (operation.finishPromise) await operation.finishPromise;
|
|
808
835
|
return;
|
|
809
836
|
}
|
|
837
|
+
if (
|
|
838
|
+
operation.engine === "claude" &&
|
|
839
|
+
result.code === 0 &&
|
|
840
|
+
operation.inputUsed &&
|
|
841
|
+
operation.tempDir
|
|
842
|
+
) {
|
|
843
|
+
// A clean exit after the code went in means the CLI almost certainly
|
|
844
|
+
// minted and printed the token — a stream capture that saw nothing is
|
|
845
|
+
// the capture's failure, not the login's. The relayed stream can be
|
|
846
|
+
// polluted by the container runtime's own chatter (the Apple CLI's XPC
|
|
847
|
+
// noise painted characters over the cells the TUI's diff renderer
|
|
848
|
+
// skipped, corrupting the reconstructed screen — live 2026-08-21 on
|
|
849
|
+
// the Air, straight after the Linux fix). The PTY transcript is
|
|
850
|
+
// written inside the container and cannot contain that noise, so it
|
|
851
|
+
// gets the authoritative re-read before this operation is failed.
|
|
852
|
+
const transcript = await this.seams.readTranscript(operation.tempDir);
|
|
853
|
+
if (!this.isCurrent(operation) || operation.finishing) return;
|
|
854
|
+
const token = transcript
|
|
855
|
+
? extractClaudeOAuthToken(renderTerminalScreenText(transcript))
|
|
856
|
+
: null;
|
|
857
|
+
if (token) {
|
|
858
|
+
console.log(
|
|
859
|
+
`[engine-login] token recovered from the PTY transcript for ${operation.opId}; persisting`,
|
|
860
|
+
);
|
|
861
|
+
operation.outputTail = "";
|
|
862
|
+
operation.stdoutTail = "";
|
|
863
|
+
this.beginClaudeFinish(operation, token);
|
|
864
|
+
if (operation.finishPromise) await operation.finishPromise;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
810
868
|
const evidence = lastSignificantOutputLine(operation.outputTail);
|
|
811
869
|
console.warn(
|
|
812
870
|
`[engine-login] login process for ${operation.opId} ended (exit ${result.code ?? "spawn-failed"}) before completion` +
|
|
@@ -1561,10 +1619,121 @@ function callbackReplayUrl(
|
|
|
1561
1619
|
return callback.toString();
|
|
1562
1620
|
}
|
|
1563
1621
|
|
|
1564
|
-
|
|
1622
|
+
const LOGS_FOLLOW_ATTEMPTS = 20;
|
|
1623
|
+
const LOGS_FOLLOW_RETRY_MS = 200;
|
|
1624
|
+
const LOGS_FOLLOW_ATTACH_PROBE_MS = 500;
|
|
1625
|
+
|
|
1626
|
+
function delayMs(ms: number): Promise<void> {
|
|
1627
|
+
return new Promise((resolve) => {
|
|
1628
|
+
const timer = setTimeout(resolve, ms);
|
|
1629
|
+
unrefTimer(timer);
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Stream a login container's output via `docker logs --follow`, which
|
|
1635
|
+
* replays from byte zero — immune to the run/attach race that loses the
|
|
1636
|
+
* container's first burst (see startLoginContainer). The container may not
|
|
1637
|
+
* exist yet when the first attempt fires, and the client's own
|
|
1638
|
+
* "No such container" stderr must never leak into the operation's output
|
|
1639
|
+
* tail, so each attempt buffers until it is provably attached: container
|
|
1640
|
+
* stdout, or survival past a probe window, count as proof; a quick exit
|
|
1641
|
+
* with the not-found error is a retry.
|
|
1642
|
+
*/
|
|
1643
|
+
async function followLoginContainerLogs(
|
|
1644
|
+
containerName: string,
|
|
1645
|
+
runChild: { once(event: "close", listener: () => void): unknown },
|
|
1646
|
+
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
1647
|
+
): Promise<void> {
|
|
1648
|
+
let runClosed = false;
|
|
1649
|
+
runChild.once("close", () => {
|
|
1650
|
+
runClosed = true;
|
|
1651
|
+
});
|
|
1652
|
+
for (let attempt = 0; attempt < LOGS_FOLLOW_ATTEMPTS; attempt += 1) {
|
|
1653
|
+
const outcome = await followLoginContainerLogsOnce(
|
|
1654
|
+
containerName,
|
|
1655
|
+
onOutput,
|
|
1656
|
+
);
|
|
1657
|
+
if (outcome === "done") return;
|
|
1658
|
+
// The container is absent. Before it ever starts that means "retry";
|
|
1659
|
+
// after the run child closed it means the container already exited and
|
|
1660
|
+
// was removed — nothing will ever appear.
|
|
1661
|
+
if (runClosed) return;
|
|
1662
|
+
await delayMs(LOGS_FOLLOW_RETRY_MS);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function followLoginContainerLogsOnce(
|
|
1667
|
+
containerName: string,
|
|
1668
|
+
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
1669
|
+
): Promise<"done" | "absent"> {
|
|
1670
|
+
return new Promise((resolve) => {
|
|
1671
|
+
const follower = spawn(
|
|
1672
|
+
engineLoginBackend().command,
|
|
1673
|
+
["logs", "--follow", containerName],
|
|
1674
|
+
{ shell: false, stdio: ["ignore", "pipe", "pipe"] },
|
|
1675
|
+
);
|
|
1676
|
+
follower.stdout.setEncoding("utf8");
|
|
1677
|
+
follower.stderr.setEncoding("utf8");
|
|
1678
|
+
const buffered: Array<{ chunk: string; stream: "stdout" | "stderr" }> = [];
|
|
1679
|
+
let stderrTail = "";
|
|
1680
|
+
let attached = false;
|
|
1681
|
+
const flush = (): void => {
|
|
1682
|
+
attached = true;
|
|
1683
|
+
for (const entry of buffered) onOutput(entry.chunk, entry.stream);
|
|
1684
|
+
buffered.length = 0;
|
|
1685
|
+
};
|
|
1686
|
+
const deliver = (chunk: string, stream: "stdout" | "stderr"): void => {
|
|
1687
|
+
if (attached) {
|
|
1688
|
+
onOutput(chunk, stream);
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
buffered.push({ chunk, stream });
|
|
1692
|
+
if (buffered.length > 256) buffered.shift();
|
|
1693
|
+
};
|
|
1694
|
+
follower.stdout.on("data", (chunk: string) => {
|
|
1695
|
+
// Container stdout is proof of attachment — the docker client itself
|
|
1696
|
+
// never writes to stdout here.
|
|
1697
|
+
if (!attached) flush();
|
|
1698
|
+
onOutput(chunk, "stdout");
|
|
1699
|
+
});
|
|
1700
|
+
follower.stderr.on("data", (chunk: string) => {
|
|
1701
|
+
stderrTail = appendUtf8Tail(stderrTail, chunk, 4_096);
|
|
1702
|
+
deliver(chunk, "stderr");
|
|
1703
|
+
});
|
|
1704
|
+
const probe = setTimeout(() => {
|
|
1705
|
+
// Still running after the probe window: the client accepted the
|
|
1706
|
+
// container and is following; anything buffered is container stderr.
|
|
1707
|
+
if (follower.exitCode === null) flush();
|
|
1708
|
+
}, LOGS_FOLLOW_ATTACH_PROBE_MS);
|
|
1709
|
+
unrefTimer(probe);
|
|
1710
|
+
follower.once("error", () => {
|
|
1711
|
+
clearTimeout(probe);
|
|
1712
|
+
resolve("absent");
|
|
1713
|
+
});
|
|
1714
|
+
follower.once("close", () => {
|
|
1715
|
+
clearTimeout(probe);
|
|
1716
|
+
if (
|
|
1717
|
+
!attached &&
|
|
1718
|
+
/no such container|dead or marked for removal/i.test(stderrTail)
|
|
1719
|
+
) {
|
|
1720
|
+
resolve("absent");
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
// A real follow that ended (container exited): deliver whatever was
|
|
1724
|
+
// buffered — a fast-crashing container's stderr arrives exactly here.
|
|
1725
|
+
if (!attached) flush();
|
|
1726
|
+
resolve("done");
|
|
1727
|
+
});
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/** Exported for the follower-path tests and live harnesses. */
|
|
1732
|
+
export async function startLoginContainer(
|
|
1565
1733
|
request: EngineLoginContainerRequest,
|
|
1566
1734
|
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
1567
1735
|
): Promise<EngineLoginProcess> {
|
|
1736
|
+
const apple = engineLoginBackend().apple;
|
|
1568
1737
|
const args = engineLoginContainerArgs(request);
|
|
1569
1738
|
const child = spawn(engineLoginBackend().command, args, {
|
|
1570
1739
|
shell: false,
|
|
@@ -1572,8 +1741,41 @@ async function startLoginContainer(
|
|
|
1572
1741
|
});
|
|
1573
1742
|
child.stdout.setEncoding("utf8");
|
|
1574
1743
|
child.stderr.setEncoding("utf8");
|
|
1575
|
-
|
|
1576
|
-
|
|
1744
|
+
if (apple) {
|
|
1745
|
+
// The Apple CLI keeps the attach-stdio path: its behavior differs from
|
|
1746
|
+
// Docker's and the claude flow's continuous TUI repaint has proven it
|
|
1747
|
+
// live; the transcript fallback covers its noise-corruption cases.
|
|
1748
|
+
child.stdout.on("data", (chunk: string) => onOutput(chunk, "stdout"));
|
|
1749
|
+
child.stderr.on("data", (chunk: string) => onOutput(chunk, "stderr"));
|
|
1750
|
+
} else {
|
|
1751
|
+
// Docker: the attached stream can MISS the container's first output
|
|
1752
|
+
// burst — `docker run` starts the container and the attach stream races
|
|
1753
|
+
// its earliest writes. codex login prints its authorize URL within
|
|
1754
|
+
// milliseconds and then goes silent, so a lost burst is a dead login
|
|
1755
|
+
// (live 2026-08-21 on the first Linux hosts: dockerd had the URL,
|
|
1756
|
+
// `docker logs` showed it, the attached client never relayed a byte).
|
|
1757
|
+
// Output truth therefore comes from `docker logs --follow`, which
|
|
1758
|
+
// replays from byte zero; the run child keeps ONLY stdin and lifecycle.
|
|
1759
|
+
// Its stdio still drains so the client can never block on backpressure.
|
|
1760
|
+
child.stdout.on("data", () => {});
|
|
1761
|
+
child.stderr.on("data", () => {});
|
|
1762
|
+
const logsDone = followLoginContainerLogs(
|
|
1763
|
+
request.containerName,
|
|
1764
|
+
child,
|
|
1765
|
+
onOutput,
|
|
1766
|
+
);
|
|
1767
|
+
// Without `--rm`, the exited container outlives the run child so the
|
|
1768
|
+
// follower can finish its replay; remove it once the follower is done
|
|
1769
|
+
// (bounded — a wedged follower must not leak containers).
|
|
1770
|
+
child.once("close", () => {
|
|
1771
|
+
void (async () => {
|
|
1772
|
+
await Promise.race([logsDone, delayMs(2_000)]);
|
|
1773
|
+
await removeEngineLoginContainer(request.containerName).catch(
|
|
1774
|
+
() => undefined,
|
|
1775
|
+
);
|
|
1776
|
+
})();
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1577
1779
|
child.stdin.on("error", () => {});
|
|
1578
1780
|
|
|
1579
1781
|
let stopped = false;
|
|
@@ -1748,9 +1950,13 @@ export function engineLoginContainerArgs(
|
|
|
1748
1950
|
...command,
|
|
1749
1951
|
];
|
|
1750
1952
|
}
|
|
1953
|
+
// No `--rm` in the Docker dialect: output truth comes from
|
|
1954
|
+
// `docker logs --follow` (see startLoginContainer), and an auto-removed
|
|
1955
|
+
// fast-exiting container would take its logs with it. The host removes
|
|
1956
|
+
// the container explicitly when the run child closes; the labeled stale
|
|
1957
|
+
// reconcile sweeps anything a crash leaves behind.
|
|
1751
1958
|
return [
|
|
1752
1959
|
"run",
|
|
1753
|
-
"--rm",
|
|
1754
1960
|
"--name",
|
|
1755
1961
|
request.containerName,
|
|
1756
1962
|
"--label",
|
package/package.json
CHANGED
package/scripts/agent/_common.sh
CHANGED
|
@@ -43,12 +43,37 @@ agent_required_bins="sqlite3 jq git"
|
|
|
43
43
|
if [ "${UAI_TASK_RUNTIME:-docker}" != "apple-container" ]; then
|
|
44
44
|
agent_required_bins="$agent_required_bins docker"
|
|
45
45
|
fi
|
|
46
|
+
# Collect EVERY missing tool before failing: reporting them one at a time
|
|
47
|
+
# cost one full fix-and-Resume round trip per binary on a fresh Amazon
|
|
48
|
+
# Linux box (sqlite3, then jq — live 2026-08-21). Bash 3.2: plain string
|
|
49
|
+
# accumulation, no arrays.
|
|
50
|
+
missing_bins=""
|
|
46
51
|
for bin in $agent_required_bins; do
|
|
47
52
|
if ! command -v "$bin" >/dev/null 2>&1; then
|
|
48
|
-
|
|
49
|
-
exit 127
|
|
53
|
+
missing_bins="$missing_bins $bin"
|
|
50
54
|
fi
|
|
51
55
|
done
|
|
56
|
+
if [ -n "$missing_bins" ]; then
|
|
57
|
+
missing_bins="${missing_bins# }"
|
|
58
|
+
printf '{"ok":false,"error":{"code":"MISSING_DEPENDENCY","message":"missing on PATH: %s — install with your package manager (dnf/apt/brew), then Resume"}}\n' "$missing_bins" >&2
|
|
59
|
+
exit 127
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# The docker BINARY existing does not make `docker compose` work: the v2
|
|
64
|
+
# compose plugin ships separately, and a bare-engine install (Amazon Linux
|
|
65
|
+
# 2023's docker package) answers `docker compose` with the top-level help
|
|
66
|
+
# text — the operator saw exactly that as their task error (live
|
|
67
|
+
# 2026-08-21). Called LAZILY by the task scripts right before their first
|
|
68
|
+
# compose use, never at source time: argument validation must precede any
|
|
69
|
+
# docker invocation (the task-down/task-up tests enforce that ordering).
|
|
70
|
+
uai_require_compose_plugin() {
|
|
71
|
+
[ "${UAI_TASK_RUNTIME:-docker}" = "apple-container" ] && return 0
|
|
72
|
+
if ! docker compose version >/dev/null 2>&1; then
|
|
73
|
+
printf '{"ok":false,"error":{"code":"MISSING_DEPENDENCY","message":"missing on PATH: docker-compose-plugin — install the Docker Compose v2 plugin, then Resume"}}\n' >&2
|
|
74
|
+
exit 127
|
|
75
|
+
fi
|
|
76
|
+
}
|
|
52
77
|
|
|
53
78
|
# ---------------------------------------------------------------------------
|
|
54
79
|
# Logger. All log lines go to stderr so stdout stays reserved for JSON.
|
|
@@ -91,6 +91,7 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
|
91
91
|
"container verification after cleanup"
|
|
92
92
|
fi
|
|
93
93
|
else
|
|
94
|
+
uai_require_compose_plugin
|
|
94
95
|
step "COMPOSE_DOWN_FAILED" "docker compose down -v --rmi local"
|
|
95
96
|
if [ ! -L "$task_dir" ] && [ -f "$task_dir/.uai/docker-compose.yml" ]; then
|
|
96
97
|
docker compose -p "$compose_project" -f "$task_dir/.uai/docker-compose.yml" \
|
package/scripts/agent/task-up.sh
CHANGED
|
@@ -519,6 +519,7 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
|
519
519
|
"remove prior task containers"
|
|
520
520
|
fi
|
|
521
521
|
else
|
|
522
|
+
uai_require_compose_plugin
|
|
522
523
|
if [ -f "$task_uai_dir/docker-compose.yml" ]; then
|
|
523
524
|
docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" \
|
|
524
525
|
down --remove-orphans >/dev/null 2>&1 || true
|