@runuai/host 0.9.58 → 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.
@@ -801,6 +801,12 @@ export class EngineLoginManager {
801
801
  if (operation.stage === "starting") {
802
802
  const authorization = findCodexAuthorization(operation.outputTail);
803
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
+ );
804
810
  operation.callbackTarget = authorization.target;
805
811
  operation.stage = "awaiting_callback";
806
812
  safeEmit(
@@ -1613,10 +1619,121 @@ function callbackReplayUrl(
1613
1619
  return callback.toString();
1614
1620
  }
1615
1621
 
1616
- async function startLoginContainer(
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(
1617
1733
  request: EngineLoginContainerRequest,
1618
1734
  onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
1619
1735
  ): Promise<EngineLoginProcess> {
1736
+ const apple = engineLoginBackend().apple;
1620
1737
  const args = engineLoginContainerArgs(request);
1621
1738
  const child = spawn(engineLoginBackend().command, args, {
1622
1739
  shell: false,
@@ -1624,8 +1741,41 @@ async function startLoginContainer(
1624
1741
  });
1625
1742
  child.stdout.setEncoding("utf8");
1626
1743
  child.stderr.setEncoding("utf8");
1627
- child.stdout.on("data", (chunk: string) => onOutput(chunk, "stdout"));
1628
- child.stderr.on("data", (chunk: string) => onOutput(chunk, "stderr"));
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
+ }
1629
1779
  child.stdin.on("error", () => {});
1630
1780
 
1631
1781
  let stopped = false;
@@ -1800,9 +1950,13 @@ export function engineLoginContainerArgs(
1800
1950
  ...command,
1801
1951
  ];
1802
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.
1803
1958
  return [
1804
1959
  "run",
1805
- "--rm",
1806
1960
  "--name",
1807
1961
  request.containerName,
1808
1962
  "--label",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.58",
3
+ "version": "0.9.59",
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>",
@@ -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
- printf '{"ok":false,"error":{"code":"MISSING_DEPENDENCY","message":"%s not found on PATH"}}\n' "$bin" >&2
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" \
@@ -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