@runuai/host 0.8.44 → 0.9.0
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/README.md +5 -1
- package/bin/uai-host.mjs +120 -5
- package/lib/agents/mode.ts +57 -0
- package/lib/mcp-connections.ts +63 -2
- package/lib/orchestrator.ts +15 -5
- package/lib/tunnel-registry.ts +104 -0
- package/package.json +3 -3
- package/src/cli.ts +7 -0
- package/src/main.ts +25 -14
- package/src/ui/server.ts +2 -0
- package/src/ui/types.ts +5 -0
package/README.md
CHANGED
|
@@ -18,7 +18,11 @@ One process does two things:
|
|
|
18
18
|
## Requirements
|
|
19
19
|
|
|
20
20
|
- **Docker** (running) — tasks are containers.
|
|
21
|
-
- **Node
|
|
21
|
+
- **Node 22, 24, 25 or 26; Node 24 LTS recommended.** Those are the majors the
|
|
22
|
+
pinned SQLite dependency ships a prebuilt binary for. Tested release majors
|
|
23
|
+
are 22, 24 and 26; Node 25 warns and continues when the binding loads.
|
|
24
|
+
Anything else — including Node 20, 21 and 23 — has to compile from source
|
|
25
|
+
and fails on a machine without a C++ toolchain.
|
|
22
26
|
|
|
23
27
|
## Install & enroll
|
|
24
28
|
|
package/bin/uai-host.mjs
CHANGED
|
@@ -5,10 +5,125 @@
|
|
|
5
5
|
* The host ships TypeScript source and runs it through tsx — no build step —
|
|
6
6
|
* so every runtime path (drizzle migrations, scripts/agent/*.sh, the
|
|
7
7
|
* images/standard build context) resolves off its source file exactly as in
|
|
8
|
-
* dev. This shim
|
|
9
|
-
*
|
|
8
|
+
* dev. This shim verifies that the native SQLite dependency can load,
|
|
9
|
+
* registers tsx's ESM loader, then hands off to src/cli.ts. argv passes
|
|
10
|
+
* straight through: src/cli.ts reads process.argv.slice(2).
|
|
10
11
|
*/
|
|
11
|
-
import { register } from "tsx/esm/api";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// A builtin — importing it here loads nothing of the app and no tsx.
|
|
14
|
+
import { stripVTControlCharacters } from "node:util";
|
|
15
|
+
|
|
16
|
+
const minimumNodeMajor = 20;
|
|
17
|
+
const testedNodeMajors = new Set([22, 24, 26]);
|
|
18
|
+
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The commands that BOOT the agent, and so genuinely need the native module.
|
|
22
|
+
*
|
|
23
|
+
* Everything else — `logs`, `status`, `open`, `stop`, `uninstall`, `setup`,
|
|
24
|
+
* `--help` — only inspects or dismantles an install, and all of them were
|
|
25
|
+
* verified to run with the module unloadable. Failing them on a broken probe
|
|
26
|
+
* takes away the diagnostic path (`logs`) and the recovery path (`stop`,
|
|
27
|
+
* `uninstall`) from precisely the user the probe exists to help: they are told
|
|
28
|
+
* to reinstall, and cannot remove the wedged service first.
|
|
29
|
+
*
|
|
30
|
+
* `start`/`restart`/`install` don't open the database in THIS process — they
|
|
31
|
+
* hand off to launchd/systemd — but they all end in a running agent, so
|
|
32
|
+
* failing fast here beats a crash-looping service the user then has to chase
|
|
33
|
+
* through the log.
|
|
34
|
+
*/
|
|
35
|
+
const commandsNeedingNativeSqlite = new Set([
|
|
36
|
+
"run",
|
|
37
|
+
"start",
|
|
38
|
+
"restart",
|
|
39
|
+
"install",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Of those, the ones whose `--dry-run` prints a plan and boots nothing.
|
|
44
|
+
*
|
|
45
|
+
* `run` is deliberately absent: it ignores the flag and boots regardless, so
|
|
46
|
+
* exempting on the flag alone would wave through the one command that really
|
|
47
|
+
* does need the module.
|
|
48
|
+
*/
|
|
49
|
+
const dryRunnableCommands = new Set(["start", "restart", "install"]);
|
|
50
|
+
|
|
51
|
+
/** Whether this invocation will actually end in a running agent. */
|
|
52
|
+
function needsNativeSqlite(argv) {
|
|
53
|
+
const command = argv[2] ?? "";
|
|
54
|
+
if (!commandsNeedingNativeSqlite.has(command)) return false;
|
|
55
|
+
return !(dryRunnableCommands.has(command) && argv.includes("--dry-run"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function errorSummary(error) {
|
|
59
|
+
// Native loader errors can carry ANSI colour, which lands as escape noise in
|
|
60
|
+
// a log file or a non-TTY pipe. Strip it before picking the line to show.
|
|
61
|
+
const message = stripVTControlCharacters(
|
|
62
|
+
error instanceof Error ? error.message : String(error),
|
|
63
|
+
);
|
|
64
|
+
const firstLine = message
|
|
65
|
+
.split(/\r?\n/)
|
|
66
|
+
.map((line) => line.trim())
|
|
67
|
+
.find(Boolean);
|
|
68
|
+
if (!firstLine) return "unknown error";
|
|
69
|
+
return firstLine.length > 240 ? `${firstLine.slice(0, 237)}...` : firstLine;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nativeRecoveryAdvice() {
|
|
73
|
+
return process.versions.electron
|
|
74
|
+
? "Update or reinstall the Uai Host desktop app and retry."
|
|
75
|
+
: "Reinstall @runuai/host; if that still fails, switch to Node.js 24 LTS and reinstall.";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function probeNativeSqlite() {
|
|
79
|
+
try {
|
|
80
|
+
const { default: Database } = await import("better-sqlite3");
|
|
81
|
+
const database = new Database(":memory:");
|
|
82
|
+
database.close();
|
|
83
|
+
return { ok: true };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { ok: false, error };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function main() {
|
|
90
|
+
if (!Number.isInteger(nodeMajor) || nodeMajor < minimumNodeMajor) {
|
|
91
|
+
console.error(
|
|
92
|
+
`uai-host requires Node.js 20 or newer; found v${process.versions.node}. Install Node.js 24 LTS and retry.`,
|
|
93
|
+
);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sqliteProbe = await probeNativeSqlite();
|
|
99
|
+
if (!sqliteProbe.ok) {
|
|
100
|
+
const detail =
|
|
101
|
+
`uai-host could not load its native SQLite module on Node.js v${process.versions.node}. ` +
|
|
102
|
+
`${nativeRecoveryAdvice()} Cause: ${errorSummary(sqliteProbe.error)}`;
|
|
103
|
+
if (needsNativeSqlite(process.argv)) {
|
|
104
|
+
console.error(detail);
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Still say it — a broken module IS why their service is down — but let
|
|
109
|
+
// the command through so they can read the log and uninstall.
|
|
110
|
+
console.warn(`${detail} Continuing — this command does not need it.`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Only when the probe actually succeeded — otherwise this contradicts the
|
|
114
|
+
// warning just printed, telling the user SQLite loaded right after saying it
|
|
115
|
+
// could not.
|
|
116
|
+
if (sqliteProbe.ok && !testedNodeMajors.has(nodeMajor)) {
|
|
117
|
+
console.warn(
|
|
118
|
+
`uai-host has not been tested on Node.js v${process.versions.node}; continuing because native SQLite loaded. Node.js 24 LTS is recommended.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Keep this import behind the native probe: the preflight must run in plain
|
|
123
|
+
// Node before tsx (and the TypeScript application graph) is loaded.
|
|
124
|
+
const { register } = await import("tsx/esm/api");
|
|
125
|
+
register();
|
|
126
|
+
await import(new URL("../src/cli.ts", import.meta.url).href);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await main();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which agent adapters this host runs — `UAI_AGENTS`.
|
|
3
|
+
*
|
|
4
|
+
* REAL IS THE DEFAULT. It did not used to be: the check was
|
|
5
|
+
* `UAI_AGENTS === "real" ? real : mock`, so every value except that one
|
|
6
|
+
* literal — including unset — silently selected the echo mock. That was a
|
|
7
|
+
* sane default while the adapters were unproven and the only hosts were the
|
|
8
|
+
* author's, but it shipped to npm unchanged. A user installs `@runuai/host`,
|
|
9
|
+
* enrolls, starts a task, and the host looks perfectly healthy while every
|
|
10
|
+
* agent echoes canned text. Nothing in the logs or `uai-host status` said so.
|
|
11
|
+
*
|
|
12
|
+
* The desktop app never hit it because it hardcodes `UAI_AGENTS: "real"`
|
|
13
|
+
* (apps/host-desktop/src/agent-path.ts) — the npm path had no equivalent, so
|
|
14
|
+
* the bug was invisible to exactly the people who could reproduce it.
|
|
15
|
+
*
|
|
16
|
+
* So: a host runs real agents unless it is explicitly told not to. `mock` is
|
|
17
|
+
* the opt-in for tests and for dev boxes with no Docker, and an unrecognised
|
|
18
|
+
* value resolves to `real` with a warning rather than quietly downgrading —
|
|
19
|
+
* a typo must never cost someone a day of echoed output again.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type AgentMode = "real" | "mock";
|
|
23
|
+
|
|
24
|
+
/** Values that select the in-process echo mock. */
|
|
25
|
+
const MOCK_VALUES = new Set(["mock", "fake", "echo"]);
|
|
26
|
+
/** Values that select the real Claude/Codex adapters (the default). */
|
|
27
|
+
const REAL_VALUES = new Set(["real", "prod", "production"]);
|
|
28
|
+
|
|
29
|
+
export interface AgentModeResolution {
|
|
30
|
+
mode: AgentMode;
|
|
31
|
+
/** Set when the raw value was neither recognised nor empty. */
|
|
32
|
+
warning?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve `UAI_AGENTS` to a mode. Pure and case/whitespace insensitive so a
|
|
37
|
+
* stray `UAI_AGENTS=Mock ` from a hand-edited .env.local behaves as written.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveAgentMode(
|
|
40
|
+
raw: string | undefined = process.env.UAI_AGENTS,
|
|
41
|
+
): AgentModeResolution {
|
|
42
|
+
const value = (raw ?? "").trim().toLowerCase();
|
|
43
|
+
if (value === "") return { mode: "real" };
|
|
44
|
+
if (MOCK_VALUES.has(value)) return { mode: "mock" };
|
|
45
|
+
if (REAL_VALUES.has(value)) return { mode: "real" };
|
|
46
|
+
return {
|
|
47
|
+
mode: "real",
|
|
48
|
+
warning:
|
|
49
|
+
`unrecognised UAI_AGENTS=${JSON.stringify(raw)} — running REAL agents. ` +
|
|
50
|
+
`Use "mock" for the echo agent, or unset it.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The mode this process is running in. */
|
|
55
|
+
export function agentMode(): AgentMode {
|
|
56
|
+
return resolveAgentMode().mode;
|
|
57
|
+
}
|
package/lib/mcp-connections.ts
CHANGED
|
@@ -98,6 +98,51 @@ async function mcpInitialize(
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Collapse whitespace and cap length — this text lands in the connect dialog. */
|
|
102
|
+
function oneLine(s: string, max = 300): string {
|
|
103
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
104
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The vendor's own explanation for a failed response, so the connect dialog
|
|
109
|
+
* can show it instead of a bare status code. Vendors bury it in one of a few
|
|
110
|
+
* shapes — RFC 6750's `error_description`, a bare `error`/`message`, or a
|
|
111
|
+
* GraphQL-style `errors[]` — so try each, then fall back to the raw body.
|
|
112
|
+
* Never throws: a missing or unparseable body just means no reason to add.
|
|
113
|
+
*/
|
|
114
|
+
async function failureReason(res: Response): Promise<string | null> {
|
|
115
|
+
const text = await res.text().catch(() => "");
|
|
116
|
+
if (!text.trim()) return null;
|
|
117
|
+
let parsed: unknown;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(text);
|
|
120
|
+
} catch {
|
|
121
|
+
return oneLine(text);
|
|
122
|
+
}
|
|
123
|
+
const body = (parsed ?? {}) as Record<string, unknown>;
|
|
124
|
+
const errors = body.errors;
|
|
125
|
+
const first = Array.isArray(errors)
|
|
126
|
+
? (errors[0] as Record<string, unknown> | undefined)
|
|
127
|
+
: undefined;
|
|
128
|
+
// `error` is a string in some payloads and an object wrapping `message` in
|
|
129
|
+
// others — New Relic alone returns both shapes plus `errors[]`, depending
|
|
130
|
+
// on which of its tiers answers. Prefer the descriptive fields; the bare
|
|
131
|
+
// `error` string ("unauthorized") is the last resort.
|
|
132
|
+
const nested =
|
|
133
|
+
typeof body.error === "object" && body.error !== null
|
|
134
|
+
? (body.error as Record<string, unknown>)
|
|
135
|
+
: undefined;
|
|
136
|
+
const reason = [
|
|
137
|
+
body.error_description,
|
|
138
|
+
body.message,
|
|
139
|
+
first?.message,
|
|
140
|
+
nested?.message,
|
|
141
|
+
body.error,
|
|
142
|
+
].find((c): c is string => typeof c === "string" && c.trim().length > 0);
|
|
143
|
+
return oneLine(reason ?? text);
|
|
144
|
+
}
|
|
145
|
+
|
|
101
146
|
// --- OAuth discovery (RFC 9728 → RFC 8414) -----------------------------------
|
|
102
147
|
|
|
103
148
|
/** Candidate well-known URLs, path-aware per spec, root fallback last. */
|
|
@@ -249,9 +294,25 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
|
|
|
249
294
|
const header = { headerName: op.headerName, headerValue: op.headerValue };
|
|
250
295
|
const res = await mcpInitialize(op.url, header);
|
|
251
296
|
if (res.status === 401 || res.status === 403) {
|
|
252
|
-
|
|
297
|
+
// 401 and 403 send you to different fixes, so don't collapse them into
|
|
298
|
+
// one "rejected" message. 401 is a credential the server did not
|
|
299
|
+
// recognise — re-check the key. 403 is one it DID recognise and then
|
|
300
|
+
// refused: the key is fine, but its user lacks permission or the
|
|
301
|
+
// account is not entitled to this server. Telling someone to re-paste
|
|
302
|
+
// a working key is the expensive failure here.
|
|
303
|
+
const lead =
|
|
304
|
+
res.status === 401
|
|
305
|
+
? "the server rejected the token (401)"
|
|
306
|
+
: "the token was accepted but this server refused the request " +
|
|
307
|
+
"(403) — the key is likely valid but lacks permission for it";
|
|
308
|
+
const why = await failureReason(res);
|
|
309
|
+
throw new Error(why ? `${lead}: ${why}` : lead);
|
|
310
|
+
}
|
|
311
|
+
if (!res.ok) {
|
|
312
|
+
const why = await failureReason(res);
|
|
313
|
+
const lead = `unexpected response (${res.status})`;
|
|
314
|
+
throw new Error(why ? `${lead}: ${why}` : lead);
|
|
253
315
|
}
|
|
254
|
-
if (!res.ok) throw new Error(`unexpected response (${res.status})`);
|
|
255
316
|
db.insert(schema.mcpConnections)
|
|
256
317
|
.values({
|
|
257
318
|
id: op.connectionId,
|
package/lib/orchestrator.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { inArray } from "drizzle-orm";
|
|
|
25
25
|
import { getDb, schema } from "./db";
|
|
26
26
|
import { mockAgentFactory } from "./agents/mock";
|
|
27
27
|
import { realAgentFactory } from "./agents/factory";
|
|
28
|
+
import { resolveAgentMode } from "./agents/mode";
|
|
28
29
|
import {
|
|
29
30
|
type AgentEvent,
|
|
30
31
|
type AgentSession,
|
|
@@ -2636,11 +2637,20 @@ const globalForOrchestrator = globalThis as unknown as {
|
|
|
2636
2637
|
|
|
2637
2638
|
export function getOrchestrator(): Orchestrator {
|
|
2638
2639
|
if (!globalForOrchestrator.__uaiOrchestrator) {
|
|
2639
|
-
//
|
|
2640
|
-
//
|
|
2641
|
-
//
|
|
2642
|
-
|
|
2643
|
-
|
|
2640
|
+
// Real Claude/Codex CLIs inside the task containers unless `UAI_AGENTS`
|
|
2641
|
+
// explicitly asks for the echo mock (see ./agents/mode). Mock is loud —
|
|
2642
|
+
// a host that answers every prompt with canned text has to say so, or it
|
|
2643
|
+
// reads as a working host producing nonsense.
|
|
2644
|
+
const { mode, warning } = resolveAgentMode();
|
|
2645
|
+
if (warning) console.warn(`[host-agent] ${warning}`);
|
|
2646
|
+
if (mode === "mock") {
|
|
2647
|
+
console.warn(
|
|
2648
|
+
"[host-agent] ⚠ MOCK AGENTS — every agent echoes canned text and no " +
|
|
2649
|
+
"real CLI runs. Unset UAI_AGENTS (or set it to `real`) for a host " +
|
|
2650
|
+
"that does actual work.",
|
|
2651
|
+
);
|
|
2652
|
+
}
|
|
2653
|
+
const factory = mode === "real" ? realAgentFactory : mockAgentFactory;
|
|
2644
2654
|
globalForOrchestrator.__uaiOrchestrator = new Orchestrator(factory);
|
|
2645
2655
|
}
|
|
2646
2656
|
if (!globalForOrchestrator.__uaiRecoverRan) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel upstreams, plus the frames that arrive before an upstream exists.
|
|
3
|
+
*
|
|
4
|
+
* `tunnel.open` is handled ASYNCHRONOUSLY on the host: resolving a target can
|
|
5
|
+
* await the task lifecycle lock and, for an ad-hoc preview, `docker run` of a
|
|
6
|
+
* sidecar (ADR-043). The frames that follow it — `tunnel.data`,
|
|
7
|
+
* `tunnel.requestEnd`, `tunnel.close` — are handled synchronously off the same
|
|
8
|
+
* WebSocket, and the cloud emits `requestEnd` for a bodyless GET on the tick
|
|
9
|
+
* after `open`. So they routinely land while the open is still resolving.
|
|
10
|
+
*
|
|
11
|
+
* Looking the tunnel up with `tunnels.get(id)?.` silently dropped them, and a
|
|
12
|
+
* dropped `requestEnd` is unrecoverable rather than merely late. The HTTP open
|
|
13
|
+
* deliberately leaves its `ClientRequest` unended so request bodies can stream,
|
|
14
|
+
* and node writes no header until `write`/`end` — so the upstream socket
|
|
15
|
+
* CONNECTS and then receives nothing at all. On the wire that reads as an
|
|
16
|
+
* established TCP connection carrying zero bytes, an upstream that never saw a
|
|
17
|
+
* request, and a flat 30s `504 host did not respond in time` from the cloud's
|
|
18
|
+
* ack deadline: four symptoms that each point somewhere different, none of them
|
|
19
|
+
* at the open that never finished.
|
|
20
|
+
*
|
|
21
|
+
* So ops for an opening tunnel are QUEUED and replayed on `register`. The
|
|
22
|
+
* queue is bounded by the request itself — the cloud stops sending once its
|
|
23
|
+
* ack deadline fires — and is dropped by `abandon` when an open fails, so a
|
|
24
|
+
* tunnel that never opens leaves nothing behind.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface TunnelUpstream {
|
|
28
|
+
write(chunk: Buffer): boolean;
|
|
29
|
+
end(): void;
|
|
30
|
+
destroy(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type TunnelOp = (upstream: TunnelUpstream) => void;
|
|
34
|
+
|
|
35
|
+
export class TunnelRegistry {
|
|
36
|
+
private readonly open = new Map<string, TunnelUpstream>();
|
|
37
|
+
/** tunnelId -> ops that arrived while its `tunnel.open` was still resolving. */
|
|
38
|
+
private readonly opening = new Map<string, TunnelOp[]>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mark a tunnel as opening. MUST be called synchronously when `tunnel.open`
|
|
42
|
+
* is received — before the first `await` of target resolution — or the frames
|
|
43
|
+
* that follow have nothing to queue against and are dropped, which is the
|
|
44
|
+
* whole bug this class exists for.
|
|
45
|
+
*/
|
|
46
|
+
markOpening(tunnelId: string): void {
|
|
47
|
+
this.opening.set(tunnelId, []);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Target resolved: publish the upstream and replay what arrived meanwhile. */
|
|
51
|
+
register(tunnelId: string, upstream: TunnelUpstream): void {
|
|
52
|
+
this.open.set(tunnelId, upstream);
|
|
53
|
+
const queued = this.opening.get(tunnelId);
|
|
54
|
+
this.opening.delete(tunnelId);
|
|
55
|
+
if (!queued) return;
|
|
56
|
+
for (const op of queued) op(upstream);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Open failed (no target, or it errored) — nothing will ever service these. */
|
|
60
|
+
abandon(tunnelId: string): void {
|
|
61
|
+
this.opening.delete(tunnelId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get(tunnelId: string): TunnelUpstream | undefined {
|
|
65
|
+
return this.open.get(tunnelId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
delete(tunnelId: string): void {
|
|
69
|
+
this.open.delete(tunnelId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run `op` against the upstream now, or queue it if the tunnel is still
|
|
74
|
+
* opening. A tunnel this registry has never heard of is dropped, exactly as
|
|
75
|
+
* the old `?.` did — that is a frame for a tunnel already torn down, not a
|
|
76
|
+
* race.
|
|
77
|
+
*/
|
|
78
|
+
apply(tunnelId: string, op: TunnelOp): void {
|
|
79
|
+
const upstream = this.open.get(tunnelId);
|
|
80
|
+
if (upstream) {
|
|
81
|
+
op(upstream);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.opening.get(tunnelId)?.push(op);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Tear the tunnel down now, or as soon as it opens. Queued ops are discarded
|
|
89
|
+
* rather than replayed first: they exist to be written to an upstream that is
|
|
90
|
+
* about to be destroyed, and a `write` after `destroy` is at best wasted and
|
|
91
|
+
* at worst an unhandled error on a socket nobody is listening to any more.
|
|
92
|
+
*/
|
|
93
|
+
abort(tunnelId: string): void {
|
|
94
|
+
const upstream = this.open.get(tunnelId);
|
|
95
|
+
this.open.delete(tunnelId);
|
|
96
|
+
if (upstream) {
|
|
97
|
+
upstream.destroy();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (this.opening.has(tunnelId)) {
|
|
101
|
+
this.opening.set(tunnelId, [(u) => u.destroy()]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runuai/host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Diogo Perillo <diogo.perillo@gmail.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"uai-host": "./bin/uai-host.mjs"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
|
-
"node": ">=
|
|
31
|
+
"node": "22.x || >=24 <27"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@sentry/node": "^10.66.0",
|
|
70
|
-
"better-sqlite3": "
|
|
70
|
+
"better-sqlite3": "12.11.1",
|
|
71
71
|
"dotenv": "^16.4.5",
|
|
72
72
|
"drizzle-orm": "^0.36.0",
|
|
73
73
|
"tsx": "^4.19.2",
|
package/src/cli.ts
CHANGED
|
@@ -134,6 +134,13 @@ async function cmdStatus(): Promise<void> {
|
|
|
134
134
|
);
|
|
135
135
|
if (cloud.lastError) console.log(` ${dim("last error: " + cloud.lastError)}`);
|
|
136
136
|
console.log(` service ${dim(`pid ${status.pid} · up ${fmtDuration(status.uptime * 1000)}`)}`);
|
|
137
|
+
// Only worth a line when it's the surprising answer. A mock host looks
|
|
138
|
+
// identical to a real one everywhere else — connected, tasks running, output
|
|
139
|
+
// streaming — so this is the only place it can be caught before the work is.
|
|
140
|
+
if (status.agentMode === "mock") {
|
|
141
|
+
console.log(` agents ${yellow("mock")} ${dim("— echo only, no real CLI runs")}`);
|
|
142
|
+
console.log(` ${dim("unset UAI_AGENTS in " + envLocalPath() + ", then: uai-host restart")}`);
|
|
143
|
+
}
|
|
137
144
|
console.log(` ui ${dim(`http://127.0.0.1:${port}`)}`);
|
|
138
145
|
console.log(` log ${dim(status.logPath)}`);
|
|
139
146
|
console.log(` tasks ${tasks.tasks.length === 0 ? dim("none") : tasks.tasks.length}`);
|
package/src/main.ts
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
ensurePreviewSidecar,
|
|
60
60
|
invalidatePreviewSidecar,
|
|
61
61
|
} from "../lib/preview-sidecar";
|
|
62
|
+
import { TunnelRegistry } from "../lib/tunnel-registry";
|
|
62
63
|
import { newId } from "../lib/ulid";
|
|
63
64
|
import {
|
|
64
65
|
capabilities as agentKindCapabilities,
|
|
@@ -126,13 +127,7 @@ let reconnectAttempt = 0;
|
|
|
126
127
|
let inFlight = 0;
|
|
127
128
|
let shutdownRequested = false;
|
|
128
129
|
let pendingBinaryTunnelId: string | null = null;
|
|
129
|
-
const tunnels = new
|
|
130
|
-
|
|
131
|
-
interface TunnelUpstream {
|
|
132
|
-
write(chunk: Buffer): boolean;
|
|
133
|
-
end(): void;
|
|
134
|
-
destroy(): void;
|
|
135
|
-
}
|
|
130
|
+
const tunnels = new TunnelRegistry();
|
|
136
131
|
|
|
137
132
|
interface PausableSource {
|
|
138
133
|
pause(): unknown;
|
|
@@ -301,7 +296,8 @@ function connect(): void {
|
|
|
301
296
|
const tunnelId = pendingBinaryTunnelId;
|
|
302
297
|
pendingBinaryTunnelId = null;
|
|
303
298
|
if (!tunnelId) return;
|
|
304
|
-
|
|
299
|
+
const chunk = rawDataToBuffer(data);
|
|
300
|
+
tunnels.apply(tunnelId, (upstream) => upstream.write(chunk));
|
|
305
301
|
return;
|
|
306
302
|
}
|
|
307
303
|
|
|
@@ -315,6 +311,10 @@ function connect(): void {
|
|
|
315
311
|
void handleCommand(socket, frame);
|
|
316
312
|
break;
|
|
317
313
|
case "tunnel.open":
|
|
314
|
+
// Synchronously, BEFORE handleTunnelOpen's first await: the frames that
|
|
315
|
+
// follow this one (a bodyless GET's `requestEnd` arrives on the next
|
|
316
|
+
// tick) need somewhere to queue while the target resolves.
|
|
317
|
+
tunnels.markOpening(frame.tunnelId);
|
|
318
318
|
void handleTunnelOpen(socket, frame);
|
|
319
319
|
break;
|
|
320
320
|
case "tunnel.data":
|
|
@@ -322,7 +322,10 @@ function connect(): void {
|
|
|
322
322
|
break;
|
|
323
323
|
case "tunnel.requestEnd":
|
|
324
324
|
// Request body complete → finish the upstream request so it can reply.
|
|
325
|
-
|
|
325
|
+
// Queued if the open is still resolving: node flushes no request header
|
|
326
|
+
// until `end`, so losing this strands the upstream on a connected
|
|
327
|
+
// socket that never receives a byte.
|
|
328
|
+
tunnels.apply(frame.tunnelId, (upstream) => upstream.end());
|
|
326
329
|
break;
|
|
327
330
|
case "tunnel.close":
|
|
328
331
|
closeTunnel(socket, frame.tunnelId, frame.reason);
|
|
@@ -497,6 +500,8 @@ async function handleTunnelOpen(
|
|
|
497
500
|
): Promise<void> {
|
|
498
501
|
const target = await resolveTunnelTarget(frame);
|
|
499
502
|
if (!target) {
|
|
503
|
+
// Nothing will ever service what queued behind this open.
|
|
504
|
+
tunnels.abandon(frame.tunnelId);
|
|
500
505
|
send(wsSocket, {
|
|
501
506
|
kind: "tunnel.ack",
|
|
502
507
|
tunnelId: frame.tunnelId,
|
|
@@ -555,7 +560,9 @@ function handleHttpTunnelOpen(
|
|
|
555
560
|
},
|
|
556
561
|
);
|
|
557
562
|
|
|
558
|
-
|
|
563
|
+
// Registering replays anything that arrived while the target resolved — for a
|
|
564
|
+
// bodyless GET that is the `requestEnd` this request cannot reply without.
|
|
565
|
+
tunnels.register(frame.tunnelId, upstream);
|
|
559
566
|
|
|
560
567
|
upstream.on("error", (err) => {
|
|
561
568
|
tunnels.delete(frame.tunnelId);
|
|
@@ -595,7 +602,6 @@ function handleRawTunnelOpen(
|
|
|
595
602
|
target: UpstreamAddr,
|
|
596
603
|
): void {
|
|
597
604
|
const upstream = new Socket();
|
|
598
|
-
tunnels.set(frame.tunnelId, upstream);
|
|
599
605
|
|
|
600
606
|
let acked = false;
|
|
601
607
|
let responseBuffer = Buffer.alloc(0);
|
|
@@ -660,6 +666,11 @@ function handleRawTunnelOpen(
|
|
|
660
666
|
});
|
|
661
667
|
|
|
662
668
|
upstream.connect(target.port, target.host);
|
|
669
|
+
// AFTER `connect`, not before: replaying a queued write onto a socket that
|
|
670
|
+
// has not started connecting errors, while node buffers writes made once it
|
|
671
|
+
// is connecting. An upgrade's own request goes out from the `connect`
|
|
672
|
+
// handler, so only client frames that overtook the open replay here.
|
|
673
|
+
tunnels.register(frame.tunnelId, upstream);
|
|
663
674
|
}
|
|
664
675
|
|
|
665
676
|
function closeTunnel(
|
|
@@ -667,9 +678,9 @@ function closeTunnel(
|
|
|
667
678
|
tunnelId: string,
|
|
668
679
|
reason: string | undefined,
|
|
669
680
|
): void {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
681
|
+
// `abort`, not get+delete: a client that gives up while the target is still
|
|
682
|
+
// resolving would otherwise leave the tunnel to open onto nobody.
|
|
683
|
+
tunnels.abort(tunnelId);
|
|
673
684
|
send(wsSocket, { kind: "tunnel.close", tunnelId, reason });
|
|
674
685
|
}
|
|
675
686
|
|
package/src/ui/server.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { join } from "node:path";
|
|
|
23
23
|
import { desc, isNull } from "drizzle-orm";
|
|
24
24
|
import type { ZodType } from "zod";
|
|
25
25
|
|
|
26
|
+
import { agentMode } from "../../lib/agents/mode";
|
|
26
27
|
import { schema, type Db } from "../../lib/db";
|
|
27
28
|
import { parsePreviewPortRuntimes } from "../../lib/preview-ports";
|
|
28
29
|
import { getCloudState } from "../../lib/cloud-state";
|
|
@@ -186,6 +187,7 @@ async function handle(
|
|
|
186
187
|
cloudUrl: opts.cloudUrl,
|
|
187
188
|
hostName: hostname(),
|
|
188
189
|
hostId: opts.hostId,
|
|
190
|
+
agentMode: agentMode(),
|
|
189
191
|
});
|
|
190
192
|
case "/api/cloud":
|
|
191
193
|
return sendJson(res, CloudResponse, getCloudState());
|
package/src/ui/types.ts
CHANGED
|
@@ -17,6 +17,11 @@ export const StatusResponse = z.object({
|
|
|
17
17
|
cloudUrl: z.string(), // UAI_CLOUD_URL
|
|
18
18
|
hostName: z.string(), // os.hostname()
|
|
19
19
|
hostId: z.string(), // UAI_HOST_ID
|
|
20
|
+
// Which agent adapters this host runs (UAI_AGENTS). Optional: a newer CLI
|
|
21
|
+
// can query a service that is still on the pre-upgrade build, which had no
|
|
22
|
+
// such field — `npm i -g` swaps the CLI before `uai-host restart` swaps the
|
|
23
|
+
// service, so that window is normal, not exotic.
|
|
24
|
+
agentMode: z.enum(["real", "mock"]).optional(),
|
|
20
25
|
});
|
|
21
26
|
export type StatusResponse = z.infer<typeof StatusResponse>;
|
|
22
27
|
|