@runuai/host 0.9.58 → 0.9.60
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 +195 -14
- package/lib/mcp-gateway.ts +13 -9
- 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
|
@@ -555,20 +555,34 @@ export class EngineLoginManager {
|
|
|
555
555
|
/** Validate and replay a Codex callback only to this operation's listener. */
|
|
556
556
|
async callback(opId: string, value: string): Promise<boolean> {
|
|
557
557
|
const operation = this.operations.get(opId);
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
!operation.callbackTarget
|
|
567
|
-
) {
|
|
558
|
+
// Same named-rejection contract as input() (0.9.51): every silent exit
|
|
559
|
+
// here left an operator staring at "Waiting for the host…" with zero
|
|
560
|
+
// evidence on either side (live 2026-08-21 — the callback path was the
|
|
561
|
+
// last breadcrumb-free room in the login pipeline).
|
|
562
|
+
const refuse = (reason: string): false => {
|
|
563
|
+
console.log(
|
|
564
|
+
`[engine-login] callback refused for ${opId}: ${reason}`,
|
|
565
|
+
);
|
|
568
566
|
return false;
|
|
567
|
+
};
|
|
568
|
+
if (!operation) return refuse("unknown opId");
|
|
569
|
+
if (!this.isCurrent(operation)) return refuse("superseded operation");
|
|
570
|
+
if (operation.engine !== "codex") return refuse("wrong engine");
|
|
571
|
+
if (operation.stage !== "awaiting_callback") {
|
|
572
|
+
return refuse(`wrong stage (${operation.stage})`);
|
|
569
573
|
}
|
|
574
|
+
if (operation.callbackUsed) return refuse("callback already used");
|
|
575
|
+
if (operation.finishing) return refuse("operation finishing");
|
|
576
|
+
if (!operation.process) return refuse("no login process");
|
|
577
|
+
if (!operation.callbackTarget) return refuse("no callback target");
|
|
578
|
+
console.log(
|
|
579
|
+
`[engine-login] callback accepted for ${opId} (${value.length} chars); validating`,
|
|
580
|
+
);
|
|
570
581
|
const query = validateCodexCallback(value, operation.callbackTarget);
|
|
571
582
|
if (!query) {
|
|
583
|
+
console.warn(
|
|
584
|
+
`[engine-login] callback failed validation for ${opId} (state/shape mismatch with this attempt's authorize URL)`,
|
|
585
|
+
);
|
|
572
586
|
await this.fail(
|
|
573
587
|
operation,
|
|
574
588
|
"invalid_callback",
|
|
@@ -576,6 +590,9 @@ export class EngineLoginManager {
|
|
|
576
590
|
);
|
|
577
591
|
return false;
|
|
578
592
|
}
|
|
593
|
+
console.log(
|
|
594
|
+
`[engine-login] callback validated for ${opId}; replaying into the login container`,
|
|
595
|
+
);
|
|
579
596
|
operation.callbackUsed = true;
|
|
580
597
|
const callbackUrl = callbackReplayUrl(operation.callbackTarget, query);
|
|
581
598
|
const replay = (async () => {
|
|
@@ -584,6 +601,9 @@ export class EngineLoginManager {
|
|
|
584
601
|
operation.process!.containerName,
|
|
585
602
|
callbackUrl,
|
|
586
603
|
);
|
|
604
|
+
console.log(
|
|
605
|
+
`[engine-login] callback replay delivered for ${operation.opId}; waiting for the CLI to finish`,
|
|
606
|
+
);
|
|
587
607
|
} catch {
|
|
588
608
|
if (this.isCurrent(operation)) {
|
|
589
609
|
await this.fail(
|
|
@@ -801,6 +821,12 @@ export class EngineLoginManager {
|
|
|
801
821
|
if (operation.stage === "starting") {
|
|
802
822
|
const authorization = findCodexAuthorization(operation.outputTail);
|
|
803
823
|
if (authorization) {
|
|
824
|
+
// Symmetric with the claude branch: this breadcrumb's ABSENCE was
|
|
825
|
+
// unreadable during the 2026-08-21 hunt — silence meant either "no
|
|
826
|
+
// output arrived" or "detection fired, codex just never says so".
|
|
827
|
+
console.log(
|
|
828
|
+
`[engine-login] codex authorize URL found for ${operation.opId}; awaiting the callback URL`,
|
|
829
|
+
);
|
|
804
830
|
operation.callbackTarget = authorization.target;
|
|
805
831
|
operation.stage = "awaiting_callback";
|
|
806
832
|
safeEmit(
|
|
@@ -1613,10 +1639,121 @@ function callbackReplayUrl(
|
|
|
1613
1639
|
return callback.toString();
|
|
1614
1640
|
}
|
|
1615
1641
|
|
|
1616
|
-
|
|
1642
|
+
const LOGS_FOLLOW_ATTEMPTS = 20;
|
|
1643
|
+
const LOGS_FOLLOW_RETRY_MS = 200;
|
|
1644
|
+
const LOGS_FOLLOW_ATTACH_PROBE_MS = 500;
|
|
1645
|
+
|
|
1646
|
+
function delayMs(ms: number): Promise<void> {
|
|
1647
|
+
return new Promise((resolve) => {
|
|
1648
|
+
const timer = setTimeout(resolve, ms);
|
|
1649
|
+
unrefTimer(timer);
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* Stream a login container's output via `docker logs --follow`, which
|
|
1655
|
+
* replays from byte zero — immune to the run/attach race that loses the
|
|
1656
|
+
* container's first burst (see startLoginContainer). The container may not
|
|
1657
|
+
* exist yet when the first attempt fires, and the client's own
|
|
1658
|
+
* "No such container" stderr must never leak into the operation's output
|
|
1659
|
+
* tail, so each attempt buffers until it is provably attached: container
|
|
1660
|
+
* stdout, or survival past a probe window, count as proof; a quick exit
|
|
1661
|
+
* with the not-found error is a retry.
|
|
1662
|
+
*/
|
|
1663
|
+
async function followLoginContainerLogs(
|
|
1664
|
+
containerName: string,
|
|
1665
|
+
runChild: { once(event: "close", listener: () => void): unknown },
|
|
1666
|
+
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
1667
|
+
): Promise<void> {
|
|
1668
|
+
let runClosed = false;
|
|
1669
|
+
runChild.once("close", () => {
|
|
1670
|
+
runClosed = true;
|
|
1671
|
+
});
|
|
1672
|
+
for (let attempt = 0; attempt < LOGS_FOLLOW_ATTEMPTS; attempt += 1) {
|
|
1673
|
+
const outcome = await followLoginContainerLogsOnce(
|
|
1674
|
+
containerName,
|
|
1675
|
+
onOutput,
|
|
1676
|
+
);
|
|
1677
|
+
if (outcome === "done") return;
|
|
1678
|
+
// The container is absent. Before it ever starts that means "retry";
|
|
1679
|
+
// after the run child closed it means the container already exited and
|
|
1680
|
+
// was removed — nothing will ever appear.
|
|
1681
|
+
if (runClosed) return;
|
|
1682
|
+
await delayMs(LOGS_FOLLOW_RETRY_MS);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function followLoginContainerLogsOnce(
|
|
1687
|
+
containerName: string,
|
|
1688
|
+
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
1689
|
+
): Promise<"done" | "absent"> {
|
|
1690
|
+
return new Promise((resolve) => {
|
|
1691
|
+
const follower = spawn(
|
|
1692
|
+
engineLoginBackend().command,
|
|
1693
|
+
["logs", "--follow", containerName],
|
|
1694
|
+
{ shell: false, stdio: ["ignore", "pipe", "pipe"] },
|
|
1695
|
+
);
|
|
1696
|
+
follower.stdout.setEncoding("utf8");
|
|
1697
|
+
follower.stderr.setEncoding("utf8");
|
|
1698
|
+
const buffered: Array<{ chunk: string; stream: "stdout" | "stderr" }> = [];
|
|
1699
|
+
let stderrTail = "";
|
|
1700
|
+
let attached = false;
|
|
1701
|
+
const flush = (): void => {
|
|
1702
|
+
attached = true;
|
|
1703
|
+
for (const entry of buffered) onOutput(entry.chunk, entry.stream);
|
|
1704
|
+
buffered.length = 0;
|
|
1705
|
+
};
|
|
1706
|
+
const deliver = (chunk: string, stream: "stdout" | "stderr"): void => {
|
|
1707
|
+
if (attached) {
|
|
1708
|
+
onOutput(chunk, stream);
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
buffered.push({ chunk, stream });
|
|
1712
|
+
if (buffered.length > 256) buffered.shift();
|
|
1713
|
+
};
|
|
1714
|
+
follower.stdout.on("data", (chunk: string) => {
|
|
1715
|
+
// Container stdout is proof of attachment — the docker client itself
|
|
1716
|
+
// never writes to stdout here.
|
|
1717
|
+
if (!attached) flush();
|
|
1718
|
+
onOutput(chunk, "stdout");
|
|
1719
|
+
});
|
|
1720
|
+
follower.stderr.on("data", (chunk: string) => {
|
|
1721
|
+
stderrTail = appendUtf8Tail(stderrTail, chunk, 4_096);
|
|
1722
|
+
deliver(chunk, "stderr");
|
|
1723
|
+
});
|
|
1724
|
+
const probe = setTimeout(() => {
|
|
1725
|
+
// Still running after the probe window: the client accepted the
|
|
1726
|
+
// container and is following; anything buffered is container stderr.
|
|
1727
|
+
if (follower.exitCode === null) flush();
|
|
1728
|
+
}, LOGS_FOLLOW_ATTACH_PROBE_MS);
|
|
1729
|
+
unrefTimer(probe);
|
|
1730
|
+
follower.once("error", () => {
|
|
1731
|
+
clearTimeout(probe);
|
|
1732
|
+
resolve("absent");
|
|
1733
|
+
});
|
|
1734
|
+
follower.once("close", () => {
|
|
1735
|
+
clearTimeout(probe);
|
|
1736
|
+
if (
|
|
1737
|
+
!attached &&
|
|
1738
|
+
/no such container|dead or marked for removal/i.test(stderrTail)
|
|
1739
|
+
) {
|
|
1740
|
+
resolve("absent");
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
// A real follow that ended (container exited): deliver whatever was
|
|
1744
|
+
// buffered — a fast-crashing container's stderr arrives exactly here.
|
|
1745
|
+
if (!attached) flush();
|
|
1746
|
+
resolve("done");
|
|
1747
|
+
});
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/** Exported for the follower-path tests and live harnesses. */
|
|
1752
|
+
export async function startLoginContainer(
|
|
1617
1753
|
request: EngineLoginContainerRequest,
|
|
1618
1754
|
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
1619
1755
|
): Promise<EngineLoginProcess> {
|
|
1756
|
+
const apple = engineLoginBackend().apple;
|
|
1620
1757
|
const args = engineLoginContainerArgs(request);
|
|
1621
1758
|
const child = spawn(engineLoginBackend().command, args, {
|
|
1622
1759
|
shell: false,
|
|
@@ -1624,8 +1761,41 @@ async function startLoginContainer(
|
|
|
1624
1761
|
});
|
|
1625
1762
|
child.stdout.setEncoding("utf8");
|
|
1626
1763
|
child.stderr.setEncoding("utf8");
|
|
1627
|
-
|
|
1628
|
-
|
|
1764
|
+
if (apple) {
|
|
1765
|
+
// The Apple CLI keeps the attach-stdio path: its behavior differs from
|
|
1766
|
+
// Docker's and the claude flow's continuous TUI repaint has proven it
|
|
1767
|
+
// live; the transcript fallback covers its noise-corruption cases.
|
|
1768
|
+
child.stdout.on("data", (chunk: string) => onOutput(chunk, "stdout"));
|
|
1769
|
+
child.stderr.on("data", (chunk: string) => onOutput(chunk, "stderr"));
|
|
1770
|
+
} else {
|
|
1771
|
+
// Docker: the attached stream can MISS the container's first output
|
|
1772
|
+
// burst — `docker run` starts the container and the attach stream races
|
|
1773
|
+
// its earliest writes. codex login prints its authorize URL within
|
|
1774
|
+
// milliseconds and then goes silent, so a lost burst is a dead login
|
|
1775
|
+
// (live 2026-08-21 on the first Linux hosts: dockerd had the URL,
|
|
1776
|
+
// `docker logs` showed it, the attached client never relayed a byte).
|
|
1777
|
+
// Output truth therefore comes from `docker logs --follow`, which
|
|
1778
|
+
// replays from byte zero; the run child keeps ONLY stdin and lifecycle.
|
|
1779
|
+
// Its stdio still drains so the client can never block on backpressure.
|
|
1780
|
+
child.stdout.on("data", () => {});
|
|
1781
|
+
child.stderr.on("data", () => {});
|
|
1782
|
+
const logsDone = followLoginContainerLogs(
|
|
1783
|
+
request.containerName,
|
|
1784
|
+
child,
|
|
1785
|
+
onOutput,
|
|
1786
|
+
);
|
|
1787
|
+
// Without `--rm`, the exited container outlives the run child so the
|
|
1788
|
+
// follower can finish its replay; remove it once the follower is done
|
|
1789
|
+
// (bounded — a wedged follower must not leak containers).
|
|
1790
|
+
child.once("close", () => {
|
|
1791
|
+
void (async () => {
|
|
1792
|
+
await Promise.race([logsDone, delayMs(2_000)]);
|
|
1793
|
+
await removeEngineLoginContainer(request.containerName).catch(
|
|
1794
|
+
() => undefined,
|
|
1795
|
+
);
|
|
1796
|
+
})();
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1629
1799
|
child.stdin.on("error", () => {});
|
|
1630
1800
|
|
|
1631
1801
|
let stopped = false;
|
|
@@ -1800,9 +1970,13 @@ export function engineLoginContainerArgs(
|
|
|
1800
1970
|
...command,
|
|
1801
1971
|
];
|
|
1802
1972
|
}
|
|
1973
|
+
// No `--rm` in the Docker dialect: output truth comes from
|
|
1974
|
+
// `docker logs --follow` (see startLoginContainer), and an auto-removed
|
|
1975
|
+
// fast-exiting container would take its logs with it. The host removes
|
|
1976
|
+
// the container explicitly when the run child closes; the labeled stale
|
|
1977
|
+
// reconcile sweeps anything a crash leaves behind.
|
|
1803
1978
|
return [
|
|
1804
1979
|
"run",
|
|
1805
|
-
"--rm",
|
|
1806
1980
|
"--name",
|
|
1807
1981
|
request.containerName,
|
|
1808
1982
|
"--label",
|
|
@@ -1882,6 +2056,13 @@ export function codexCallbackReplayDockerRequest(
|
|
|
1882
2056
|
"--fail",
|
|
1883
2057
|
"--silent",
|
|
1884
2058
|
"--show-error",
|
|
2059
|
+
// Follow the callback's redirect to the CLI's /success page: codex
|
|
2060
|
+
// writes auth.json on the callback but EXITS only after serving that
|
|
2061
|
+
// page — a real browser follows; a replay that stops at the redirect
|
|
2062
|
+
// leaves a completed login waiting forever (live 2026-08-24: the
|
|
2063
|
+
// first-ever completed pane codex login needed a manual /success
|
|
2064
|
+
// fetch to let the CLI exit).
|
|
2065
|
+
"--location",
|
|
1885
2066
|
"--max-time",
|
|
1886
2067
|
"10",
|
|
1887
2068
|
"--output",
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -73,21 +73,25 @@ function gatewayPort(value: string | undefined): number {
|
|
|
73
73
|
export const MCP_GATEWAY_PORT = gatewayPort(
|
|
74
74
|
process.env.UAI_MCP_GATEWAY_PORT,
|
|
75
75
|
);
|
|
76
|
-
/** Loopback
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* the
|
|
80
|
-
*
|
|
81
|
-
*
|
|
76
|
+
/** Loopback works only where the container runtime forwards
|
|
77
|
+
* host.docker.internal INTO the host loopback — macOS Docker
|
|
78
|
+
* (Desktop/OrbStack). Native Linux docker resolves host.docker.internal to
|
|
79
|
+
* the bridge gateway IP, where a loopback bind is unreachable: every task's
|
|
80
|
+
* MCP config carried a dead URL and the gateway showed connection-refused
|
|
81
|
+
* from inside containers (live 2026-08-24, first Linux host with an MCP
|
|
82
|
+
* connection). Apple vmnet guests cannot reach host loopback either. Both
|
|
83
|
+
* non-mac-docker cases therefore bind all interfaces — the unguessable
|
|
84
|
+
* per-task path token remains the auth (same posture the operator override
|
|
85
|
+
* has always allowed). Resolved per call: the runtime pin lands before the
|
|
86
|
+
* production listener starts, and a pin never changes within a process. */
|
|
82
87
|
function gatewayBindAddress(): string {
|
|
83
88
|
const configured = process.env.UAI_MCP_GATEWAY_BIND;
|
|
84
89
|
if (configured) return configured;
|
|
85
90
|
// The pin, not the ready-gated identity: the production listener starts
|
|
86
91
|
// during boot while activation still reports `checking`, and a loopback
|
|
87
92
|
// bind chosen then would strand every vmnet guest.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
: "127.0.0.1";
|
|
93
|
+
if (pinnedContainerRuntimeProvider() === "apple-container") return "0.0.0.0";
|
|
94
|
+
return process.platform === "darwin" ? "127.0.0.1" : "0.0.0.0";
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
/** Host address a task container dials to reach this gateway. Docker guests
|
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
|