@runuai/host 0.8.13 → 0.8.17
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 +23 -0
- package/lib/codex-auth.ts +112 -28
- package/lib/engines.ts +307 -10
- package/lib/env.ts +29 -10
- package/lib/obs.ts +514 -0
- package/lib/orchestrator.ts +25 -2
- package/package.json +2 -1
- package/scripts/agent/task-up.sh +19 -11
- package/src/cli.ts +33 -1
- package/src/load-env.ts +54 -11
- package/src/main.ts +21 -2
- package/src/ui/server.ts +46 -3
- package/src/ui/types.ts +3 -0
- package/ui/app.js +62 -9
package/README.md
CHANGED
|
@@ -89,3 +89,26 @@ no build step (ADR-032).
|
|
|
89
89
|
- [docs/host-ui.md](https://github.com/runuai/uai/blob/main/docs/host-ui.md) — the local UI surface.
|
|
90
90
|
- [docs/host-packaging.md](https://github.com/runuai/uai/blob/main/docs/host-packaging.md) — how this package is built and published.
|
|
91
91
|
</content>
|
|
92
|
+
|
|
93
|
+
## Telemetry
|
|
94
|
+
|
|
95
|
+
Installed hosts report **crashes only** to Sentry by default. Sent on a
|
|
96
|
+
crash: the error and stack trace (paths and secret-shaped strings
|
|
97
|
+
redacted), recent bridge-connection breadcrumbs, OS/runtime versions, the
|
|
98
|
+
package version, and a pseudonymous host id. No sessions, no usage pings,
|
|
99
|
+
and no request data — nothing is sent until something actually crashes.
|
|
100
|
+
Telemetry is designed to exclude chat, prompts, env values, and repo
|
|
101
|
+
content; error text is scrubbed for secret shapes, but redaction is
|
|
102
|
+
pattern-based, not perfect. Repo checkouts and dev/test runs never report,
|
|
103
|
+
and reporting only activates after this notice has been shown once (the
|
|
104
|
+
`install`/`pair`/`setup`/`start`/`restart` commands print it — a silently
|
|
105
|
+
upgraded service stays off until one of them runs).
|
|
106
|
+
|
|
107
|
+
Opt out any time — add to `$UAI_HOME/.env.local` (default
|
|
108
|
+
`~/.uai/.env.local`), then `uai-host restart`:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
UAI_TELEMETRY_DISABLED=1
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`UAI_SENTRY_DSN=<dsn>` redirects reports to your own Sentry instead.
|
package/lib/codex-auth.ts
CHANGED
|
@@ -9,12 +9,24 @@
|
|
|
9
9
|
* re-login otherwise only helps NEW tasks. This re-copies the fresh `~/.codex`
|
|
10
10
|
* into the running task containers so live tasks self-heal.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Three triggers, covering every path:
|
|
13
13
|
* - {@link watchCodexAuth} — fs.watch on `~/.codex`, for `codex login` run
|
|
14
14
|
* from a terminal while the host stays up.
|
|
15
15
|
* - {@link reinjectCodexRunningTasks} at host start — for the desktop
|
|
16
16
|
* "Connect Codex" flow, which writes `~/.codex` then restarts the host
|
|
17
17
|
* (durable sessions reattach to containers that still hold the old creds).
|
|
18
|
+
* Sequenced AFTER boot recovery (main.ts awaits recoveryComplete()).
|
|
19
|
+
* - {@link injectCodexIntoContainer} — recovery calls it right after
|
|
20
|
+
* `docker start` of an exited container, before agents can respawn.
|
|
21
|
+
*
|
|
22
|
+
* Copies go ONLY into containers docker confirms are RUNNING. A DB-active row
|
|
23
|
+
* can be a stopped container (boot, pre-recovery), and `docker cp` into a
|
|
24
|
+
* stopped container SUCCEEDS while the `docker exec … chown` that follows
|
|
25
|
+
* cannot run — stranding auth.json/config.toml mode 0600 owned by the host
|
|
26
|
+
* uid (macOS 501:20), unreadable to the container's `node` (1000:1000), and
|
|
27
|
+
* Codex dies at startup. Every docker step's exit status is checked; failures
|
|
28
|
+
* surface as actionable errors (never credential contents — only file NAMES
|
|
29
|
+
* and docker stderr are logged).
|
|
18
30
|
*
|
|
19
31
|
* Codex auth is host-wide (the operator's single `~/.codex`, not per-user), so
|
|
20
32
|
* this targets ALL running tasks — matching task-up, which copies it into every
|
|
@@ -43,8 +55,11 @@ const EXEC_TIMEOUT_MS = 15_000;
|
|
|
43
55
|
/** Injectable seams (defaults hit the real DB / docker / fs) so the copy logic
|
|
44
56
|
* is testable without either. */
|
|
45
57
|
export interface CodexDeps {
|
|
46
|
-
exec?: (
|
|
47
|
-
|
|
58
|
+
exec?: (
|
|
59
|
+
args: string[],
|
|
60
|
+
) => Promise<{ status: number | null; stdout: string; stderr: string }>;
|
|
61
|
+
/** DB-active task containers — may include stopped ones; docker decides. */
|
|
62
|
+
activeTaskContainers?: () => string[];
|
|
48
63
|
fileExists?: (path: string) => boolean;
|
|
49
64
|
}
|
|
50
65
|
|
|
@@ -54,10 +69,10 @@ function ownerCodexDir(): string {
|
|
|
54
69
|
|
|
55
70
|
const defaultExec: NonNullable<CodexDeps["exec"]> = async (args) => {
|
|
56
71
|
const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
|
|
57
|
-
return { status: res.status, stderr: res.stderr };
|
|
72
|
+
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
|
58
73
|
};
|
|
59
74
|
|
|
60
|
-
function
|
|
75
|
+
function defaultActiveTaskContainers(): string[] {
|
|
61
76
|
return getDb()
|
|
62
77
|
.select({ taskId: schema.hostTasks.taskId })
|
|
63
78
|
.from(schema.hostTasks)
|
|
@@ -66,50 +81,119 @@ function defaultRunningContainers(): string[] {
|
|
|
66
81
|
.map((r) => `task-${r.taskId}-app-1`);
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The containers docker reports as RUNNING right now, or null when docker
|
|
86
|
+
* itself didn't answer — the caller must then skip injection rather than
|
|
87
|
+
* docker-cp into containers of unknown state.
|
|
88
|
+
*/
|
|
89
|
+
async function dockerRunningNames(
|
|
90
|
+
exec: NonNullable<CodexDeps["exec"]>,
|
|
91
|
+
): Promise<Set<string> | null> {
|
|
92
|
+
const res = await exec(["ps", "--filter", "status=running", "--format", "{{.Names}}"]);
|
|
93
|
+
if (res.status !== 0) return null;
|
|
94
|
+
return new Set(
|
|
95
|
+
res.stdout
|
|
96
|
+
.split("\n")
|
|
97
|
+
.map((l) => l.trim())
|
|
98
|
+
.filter(Boolean),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
/**
|
|
70
103
|
* Re-copy `~/.codex/<items>` into one container — mirrors task-up.sh exactly:
|
|
71
|
-
* ensure the dir (root), cp each item that exists, chown back to node
|
|
72
|
-
*
|
|
73
|
-
*
|
|
104
|
+
* ensure the dir (root), cp each item that exists, chown back to node:node
|
|
105
|
+
* (docker cp preserves the HOST uid/gid, and 0600 files owned by macOS
|
|
106
|
+
* 501:20 are unreadable to the container's node). Any nonzero step throws
|
|
107
|
+
* with the step name and docker's stderr — never file contents.
|
|
74
108
|
*/
|
|
75
109
|
async function copyCodexInto(container: string, deps: CodexDeps): Promise<void> {
|
|
76
110
|
const exec = deps.exec ?? defaultExec;
|
|
77
111
|
const exists = deps.fileExists ?? existsSync;
|
|
78
112
|
const dir = ownerCodexDir();
|
|
79
|
-
|
|
113
|
+
const must = async (args: string[], step: string): Promise<void> => {
|
|
114
|
+
const res = await exec(args);
|
|
115
|
+
if (res.status !== 0) {
|
|
116
|
+
const detail = res.stderr.trim().slice(0, 200);
|
|
117
|
+
throw new Error(
|
|
118
|
+
`${step} failed (${res.status === null ? "docker timeout" : `exit ${res.status}`})` +
|
|
119
|
+
(detail ? `: ${detail}` : ""),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
await must(
|
|
124
|
+
["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.codex"],
|
|
125
|
+
"mkdir /home/node/.codex",
|
|
126
|
+
);
|
|
80
127
|
for (const item of CODEX_ITEMS) {
|
|
81
128
|
const src = join(dir, item);
|
|
82
129
|
if (!exists(src)) continue;
|
|
83
|
-
await
|
|
130
|
+
await must(["cp", src, `${container}:/home/node/.codex/`], `docker cp ${item}`);
|
|
84
131
|
}
|
|
85
|
-
await
|
|
86
|
-
"exec",
|
|
87
|
-
"-
|
|
88
|
-
|
|
89
|
-
container,
|
|
90
|
-
"chown",
|
|
91
|
-
"-R",
|
|
92
|
-
"node:node",
|
|
93
|
-
"/home/node/.codex",
|
|
94
|
-
]);
|
|
132
|
+
await must(
|
|
133
|
+
["exec", "-u", "root", container, "chown", "-R", "node:node", "/home/node/.codex"],
|
|
134
|
+
"chown -R node:node /home/node/.codex",
|
|
135
|
+
);
|
|
95
136
|
}
|
|
96
137
|
|
|
97
138
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
139
|
+
* Inject the owner's `~/.codex` into ONE container known to be running.
|
|
140
|
+
* Recovery calls this immediately after `docker start`, BEFORE uai-init and
|
|
141
|
+
* any agent respawn, so a recovered container never runs Codex against
|
|
142
|
+
* host-uid-owned credentials. Returns false after logging an actionable
|
|
143
|
+
* error (no secrets) — recovery of the task itself must not fail over Codex.
|
|
144
|
+
*/
|
|
145
|
+
export async function injectCodexIntoContainer(
|
|
146
|
+
container: string,
|
|
147
|
+
deps: CodexDeps = {},
|
|
148
|
+
): Promise<boolean> {
|
|
149
|
+
const exists = deps.fileExists ?? existsSync;
|
|
150
|
+
if (!exists(join(ownerCodexDir(), "auth.json"))) return true; // nothing to inject
|
|
151
|
+
try {
|
|
152
|
+
await copyCodexInto(container, deps);
|
|
153
|
+
return true;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(
|
|
156
|
+
`[codex] inject into ${container}: ${err instanceof Error ? err.message : err} — ` +
|
|
157
|
+
`Codex in this task will fail until fixed (run: docker exec -u root ${container} ` +
|
|
158
|
+
`chown -R node:node /home/node/.codex, or restart the host)`,
|
|
159
|
+
);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Re-copy the freshly (re)logged-in `~/.codex` into every task container that
|
|
166
|
+
* is both DB-active AND confirmed running by docker. No-op when there is no
|
|
167
|
+
* `~/.codex/auth.json` or no active tasks; skipped entirely (with a warning)
|
|
168
|
+
* when docker doesn't answer. Per-container failures are surfaced and don't
|
|
169
|
+
* stop the sweep.
|
|
101
170
|
*/
|
|
102
171
|
export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
|
|
103
172
|
const exists = deps.fileExists ?? existsSync;
|
|
104
173
|
if (!exists(join(ownerCodexDir(), "auth.json"))) return;
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
174
|
+
const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
|
|
175
|
+
if (active.length === 0) return;
|
|
176
|
+
const running = await dockerRunningNames(deps.exec ?? defaultExec);
|
|
177
|
+
if (running === null) {
|
|
178
|
+
console.warn(
|
|
179
|
+
"[codex] reinject skipped — docker didn't answer `docker ps`; will retry on the next trigger",
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const targets = active.filter((c) => running.has(c));
|
|
184
|
+
const stopped = active.length - targets.length;
|
|
185
|
+
if (stopped > 0) {
|
|
186
|
+
console.log(
|
|
187
|
+
`[codex] ${stopped} active task(s) not running — skipped (recovery injects them at docker start)`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (targets.length === 0) return;
|
|
191
|
+
console.log(`[codex] re-copying ~/.codex into ${targets.length} running task(s)`);
|
|
192
|
+
for (const container of targets) {
|
|
109
193
|
try {
|
|
110
194
|
await copyCodexInto(container, deps);
|
|
111
195
|
} catch (err) {
|
|
112
|
-
console.
|
|
196
|
+
console.error(
|
|
113
197
|
`[codex] reinject into ${container} failed: ${err instanceof Error ? err.message : err}`,
|
|
114
198
|
);
|
|
115
199
|
}
|
package/lib/engines.ts
CHANGED
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
chmodSync,
|
|
64
64
|
existsSync,
|
|
65
65
|
mkdirSync,
|
|
66
|
+
readdirSync,
|
|
66
67
|
readFileSync,
|
|
67
68
|
rmSync,
|
|
68
69
|
writeFileSync,
|
|
@@ -88,6 +89,11 @@ export interface EngineCatalogEntry {
|
|
|
88
89
|
apiKeyHint: string | null;
|
|
89
90
|
/** Where to mint a key for the pasted-API-key alternative. */
|
|
90
91
|
apiKeyUrl: string | null;
|
|
92
|
+
/**
|
|
93
|
+
* Human-readable install command the UI shows next to "Install & connect"
|
|
94
|
+
* when the CLI is missing (null = nothing to install, e.g. Cursor).
|
|
95
|
+
*/
|
|
96
|
+
installHint: string | null;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
interface EngineDescriptor {
|
|
@@ -240,18 +246,43 @@ function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
|
|
|
240
246
|
}
|
|
241
247
|
|
|
242
248
|
/**
|
|
243
|
-
* The PATH the owner's
|
|
249
|
+
* The PATH the owner's shell exports. Probes an INTERACTIVE login shell
|
|
250
|
+
* first: nvm/asdf/fnm put their PATH setup in `.zshrc`/`.bashrc`, which a
|
|
251
|
+
* plain login shell (`-lc`) never sources — that gap hid `npm i -g` installs
|
|
252
|
+
* from the probe. Falls back to `-lc` for profiles that misbehave when run
|
|
253
|
+
* interactively without a terminal. Null on failure of both; callers fall
|
|
254
|
+
* back to the inherited PATH.
|
|
255
|
+
*
|
|
256
|
+
* Cached (module-level, 5 min): the CLI-found statuses probe on every
|
|
257
|
+
* /api/engines poll and shelling out each time would be rude. Staleness is
|
|
258
|
+
* harmless — every installer this module runs lands in a candidate dir,
|
|
259
|
+
* which is checked before the probe.
|
|
260
|
+
*/
|
|
261
|
+
async function probeLoginShellPath(): Promise<string | null> {
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
if (loginPathCache && now - loginPathCache.at < LOGIN_PATH_CACHE_MS) {
|
|
264
|
+
return loginPathCache.value;
|
|
265
|
+
}
|
|
266
|
+
const value = (await shellPath("-lic")) ?? (await shellPath("-lc"));
|
|
267
|
+
loginPathCache = { value, at: now };
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let loginPathCache: { value: string | null; at: number } | null = null;
|
|
272
|
+
const LOGIN_PATH_CACHE_MS = 5 * 60_000;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Run `$SHELL <flags> /usr/bin/env` and pull PATH out. `/usr/bin/env` (not
|
|
244
276
|
* `echo $PATH`) so the value is the real colon-joined exported PATH under any
|
|
245
|
-
* shell — fish's `$PATH` is a list and would echo space-joined — and
|
|
246
|
-
* LAST `PATH=` line so profile echo noise can't spoof it.
|
|
247
|
-
* callers fall back to the inherited PATH.
|
|
277
|
+
* shell — fish's `$PATH` is a list and would echo space-joined — and the
|
|
278
|
+
* LAST `PATH=` line wins so profile echo noise can't spoof it.
|
|
248
279
|
*/
|
|
249
|
-
function
|
|
280
|
+
function shellPath(flags: string): Promise<string | null> {
|
|
250
281
|
return new Promise((resolve) => {
|
|
251
282
|
const shell = process.env.SHELL?.trim() || "/bin/zsh";
|
|
252
283
|
nodeExecFile(
|
|
253
284
|
shell,
|
|
254
|
-
[
|
|
285
|
+
[flags, "/usr/bin/env"],
|
|
255
286
|
{ timeout: 8_000 },
|
|
256
287
|
(err, stdout) => {
|
|
257
288
|
if (err) return resolve(null);
|
|
@@ -285,10 +316,22 @@ export function engineCatalog(): EngineCatalogEntry[] {
|
|
|
285
316
|
getKeyUrl: d.getKeyUrl ?? null,
|
|
286
317
|
apiKeyHint: d.apiKeyHint ?? null,
|
|
287
318
|
apiKeyUrl: d.apiKeyUrl ?? null,
|
|
319
|
+
installHint:
|
|
320
|
+
kind === "codex" ? CODEX_INSTALL_HINT : (INSTALLERS[kind] ?? null),
|
|
288
321
|
};
|
|
289
322
|
});
|
|
290
323
|
}
|
|
291
324
|
|
|
325
|
+
/** kind → whether the engine's CLI is resolvable on this host right now. */
|
|
326
|
+
export async function engineCliStatuses(
|
|
327
|
+
seams: Partial<EngineSeams> = {},
|
|
328
|
+
): Promise<Record<EngineKind, boolean>> {
|
|
329
|
+
const s = withDefaults(seams);
|
|
330
|
+
const out = {} as Record<EngineKind, boolean>;
|
|
331
|
+
for (const kind of ORDER) out[kind] = await engineCliFound(kind, s);
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
292
335
|
/** kind → connected, for every engine. */
|
|
293
336
|
export function engineStatuses(
|
|
294
337
|
seams: Partial<EngineSeams> = {},
|
|
@@ -474,6 +517,209 @@ export function disconnectEngine(
|
|
|
474
517
|
}
|
|
475
518
|
}
|
|
476
519
|
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// Install a missing engine CLI (ADR-074).
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Pinned installer one-liners — the same vendor installers the standard task
|
|
526
|
+
* image already trusts (host-agent/images/standard/Dockerfile), shown
|
|
527
|
+
* VERBATIM in the UI before running. Every one lands its binary in a
|
|
528
|
+
* candidateBins dir, so the next resolve finds it with no restart:
|
|
529
|
+
* claude → ~/.local/bin, kimi → ~/.kimi-code/bin, grok → ~/.grok/bin.
|
|
530
|
+
* Codex has no vendor script — resolved at run time (brew, then npm).
|
|
531
|
+
*/
|
|
532
|
+
const INSTALLERS: Partial<Record<EngineKind, string>> = {
|
|
533
|
+
claude: "curl -fsSL https://claude.ai/install.sh | bash",
|
|
534
|
+
kimi: "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
|
|
535
|
+
grok: "curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const CODEX_INSTALL_HINT = "brew install codex — or: npm i -g @openai/codex";
|
|
539
|
+
|
|
540
|
+
/** Installers can compile/download; give them real time. */
|
|
541
|
+
const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
542
|
+
|
|
543
|
+
export interface InstallResult {
|
|
544
|
+
ok: boolean;
|
|
545
|
+
message: string;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Install an engine's CLI with its pinned installer, streaming output to
|
|
550
|
+
* `onLog`. Refuses when there's nothing to install (api-key engines) and
|
|
551
|
+
* short-circuits when the CLI is already resolvable. Success = the installer
|
|
552
|
+
* exits 0 AND the CLI resolves afterwards.
|
|
553
|
+
*/
|
|
554
|
+
export async function installEngineCli(
|
|
555
|
+
kind: EngineKind,
|
|
556
|
+
onLog: (line: string) => void,
|
|
557
|
+
seams: Partial<EngineSeams> = {},
|
|
558
|
+
): Promise<InstallResult> {
|
|
559
|
+
const s = withDefaults(seams);
|
|
560
|
+
const d = DESCRIPTORS[kind];
|
|
561
|
+
if (d.authMode === "api-key") {
|
|
562
|
+
return {
|
|
563
|
+
ok: false,
|
|
564
|
+
message: `${d.label} doesn't use a CLI on the host — paste an API key instead.`,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
if (await engineCliFound(kind, s)) {
|
|
568
|
+
return { ok: true, message: `${d.label} CLI is already installed.` };
|
|
569
|
+
}
|
|
570
|
+
const inv = await resolveInstaller(kind, s);
|
|
571
|
+
if (!inv) {
|
|
572
|
+
return {
|
|
573
|
+
ok: false,
|
|
574
|
+
message:
|
|
575
|
+
"No way to install Codex automatically: install Homebrew or Node.js first, then `brew install codex` or `npm i -g @openai/codex`.",
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
let child: ChildProcess;
|
|
579
|
+
try {
|
|
580
|
+
child = s.spawn(inv.command, inv.args, {
|
|
581
|
+
...s.procEnv,
|
|
582
|
+
...(inv.path ? { PATH: inv.path } : {}),
|
|
583
|
+
});
|
|
584
|
+
} catch (err) {
|
|
585
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
586
|
+
}
|
|
587
|
+
return new Promise((resolve) => {
|
|
588
|
+
let settled = false;
|
|
589
|
+
let timer: NodeJS.Timeout | undefined;
|
|
590
|
+
const done = (r: InstallResult, kill = false): void => {
|
|
591
|
+
if (settled) return;
|
|
592
|
+
settled = true;
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
if (kill) {
|
|
595
|
+
try {
|
|
596
|
+
child.kill();
|
|
597
|
+
} catch {
|
|
598
|
+
/* already gone */
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
resolve(r);
|
|
602
|
+
};
|
|
603
|
+
const relay = createLineRelay(onLog);
|
|
604
|
+
child.stdout?.on("data", relay);
|
|
605
|
+
child.stderr?.on("data", relay);
|
|
606
|
+
child.on("error", (err: NodeJS.ErrnoException) =>
|
|
607
|
+
done({ ok: false, message: err.message }),
|
|
608
|
+
);
|
|
609
|
+
child.on("exit", (code) => {
|
|
610
|
+
void (async () => {
|
|
611
|
+
if (code !== 0) {
|
|
612
|
+
done({
|
|
613
|
+
ok: false,
|
|
614
|
+
message: `Install failed (exit ${code ?? "?"}) — see the log above.`,
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
done(
|
|
619
|
+
(await engineCliFound(kind, s))
|
|
620
|
+
? { ok: true, message: `${d.label} CLI installed.` }
|
|
621
|
+
: {
|
|
622
|
+
ok: false,
|
|
623
|
+
message:
|
|
624
|
+
"The installer finished but the CLI still isn't findable — install it manually, then try Connect.",
|
|
625
|
+
},
|
|
626
|
+
);
|
|
627
|
+
})();
|
|
628
|
+
});
|
|
629
|
+
timer = setTimeout(
|
|
630
|
+
() =>
|
|
631
|
+
done(
|
|
632
|
+
{ ok: false, message: "The installer timed out — see the log above." },
|
|
633
|
+
true,
|
|
634
|
+
),
|
|
635
|
+
INSTALL_TIMEOUT_MS,
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
interface InstallInvocation {
|
|
641
|
+
command: string;
|
|
642
|
+
args: string[];
|
|
643
|
+
/** Child PATH override (npm needs its own dir leading so node resolves). */
|
|
644
|
+
path?: string;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Vendor script via sh for most engines; brew-then-npm for Codex. */
|
|
648
|
+
async function resolveInstaller(
|
|
649
|
+
kind: EngineKind,
|
|
650
|
+
s: EngineSeams,
|
|
651
|
+
): Promise<InstallInvocation | null> {
|
|
652
|
+
if (kind !== "codex") {
|
|
653
|
+
const script = INSTALLERS[kind];
|
|
654
|
+
return script ? { command: "/bin/sh", args: ["-c", script] } : null;
|
|
655
|
+
}
|
|
656
|
+
const brew = s.systemBinDirs.map((dir) => join(dir, "brew")).find(existsSync);
|
|
657
|
+
if (brew) return { command: brew, args: ["install", "codex"] };
|
|
658
|
+
const npm = await findBinNamed("npm", s);
|
|
659
|
+
if (npm) {
|
|
660
|
+
return {
|
|
661
|
+
command: npm,
|
|
662
|
+
args: ["install", "-g", "@openai/codex"],
|
|
663
|
+
path: prependPath([dirname(npm)], s.procEnv.PATH ?? ""),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Find an arbitrary tool the same way engine CLIs are found. */
|
|
670
|
+
async function findBinNamed(
|
|
671
|
+
name: string,
|
|
672
|
+
s: EngineSeams,
|
|
673
|
+
): Promise<string | null> {
|
|
674
|
+
const home = s.ownerHome();
|
|
675
|
+
const dirs = [
|
|
676
|
+
join(home, ".local", "bin"),
|
|
677
|
+
...nodeManagerBinDirs(home),
|
|
678
|
+
...s.systemBinDirs,
|
|
679
|
+
];
|
|
680
|
+
for (const dir of dirs) {
|
|
681
|
+
const file = join(dir, name);
|
|
682
|
+
if (existsSync(file)) return file;
|
|
683
|
+
}
|
|
684
|
+
const login = await s.loginShellPath();
|
|
685
|
+
const dir = login?.split(":").find((p) => p && existsSync(join(p, name)));
|
|
686
|
+
return dir ? join(dir, name) : null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Whether an engine's CLI resolves right now (candidates, then login PATH). */
|
|
690
|
+
async function engineCliFound(
|
|
691
|
+
kind: EngineKind,
|
|
692
|
+
s: EngineSeams,
|
|
693
|
+
): Promise<boolean> {
|
|
694
|
+
if (DESCRIPTORS[kind].authMode === "api-key") return true; // never spawned
|
|
695
|
+
if (candidateBins(kind, s).some(existsSync)) return true;
|
|
696
|
+
const login = await s.loginShellPath();
|
|
697
|
+
return !!login
|
|
698
|
+
?.split(":")
|
|
699
|
+
.some((dir) => dir.length > 0 && existsSync(join(dir, kind)));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Accumulate a child's output and forward each distinct, meaningful,
|
|
704
|
+
* COMPLETE line to `onLog` once — same discipline as the token flow's
|
|
705
|
+
* relay, for plain (non-PTY) installer output with \r progress noise.
|
|
706
|
+
*/
|
|
707
|
+
function createLineRelay(onLog: (line: string) => void): (b: Buffer) => void {
|
|
708
|
+
let raw = "";
|
|
709
|
+
const forwarded = new Set<string>();
|
|
710
|
+
return (b) => {
|
|
711
|
+
raw += b.toString("utf8");
|
|
712
|
+
if (raw.length > 262_144) raw = raw.slice(-131_072);
|
|
713
|
+
const text = sanitizeTerminalOutput(raw);
|
|
714
|
+
for (const line of significantLines(text.slice(0, text.lastIndexOf("\n") + 1))) {
|
|
715
|
+
if (!forwarded.has(line)) {
|
|
716
|
+
forwarded.add(line);
|
|
717
|
+
onLog(line);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
477
723
|
/**
|
|
478
724
|
* Flatten a PTY byte stream into plain text lines. Strips OSC sequences
|
|
479
725
|
* (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
|
|
@@ -571,14 +817,21 @@ function configPath(kind: EngineKind, s: EngineSeams): string {
|
|
|
571
817
|
*/
|
|
572
818
|
function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
|
|
573
819
|
const home = s.ownerHome();
|
|
574
|
-
const common = [
|
|
820
|
+
const common = [
|
|
821
|
+
join(home, ".local", "bin"),
|
|
822
|
+
...nodeManagerBinDirs(home),
|
|
823
|
+
...s.systemBinDirs,
|
|
824
|
+
];
|
|
575
825
|
switch (kind) {
|
|
576
826
|
case "claude":
|
|
577
827
|
// Native installer symlinks ~/.local/bin/claude (preferred); the older
|
|
578
828
|
// "local install" migration keeps a wrapper at ~/.claude/local/claude.
|
|
579
|
-
return [
|
|
580
|
-
|
|
581
|
-
|
|
829
|
+
return [
|
|
830
|
+
join(home, ".local", "bin"),
|
|
831
|
+
join(home, ".claude", "local"),
|
|
832
|
+
...nodeManagerBinDirs(home),
|
|
833
|
+
...s.systemBinDirs,
|
|
834
|
+
].map((d) => join(d, "claude"));
|
|
582
835
|
case "codex":
|
|
583
836
|
return common.map((d) => join(d, "codex"));
|
|
584
837
|
case "kimi":
|
|
@@ -592,6 +845,50 @@ function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
|
|
|
592
845
|
}
|
|
593
846
|
}
|
|
594
847
|
|
|
848
|
+
/**
|
|
849
|
+
* Bin dirs of the common node version managers — `npm i -g` lands CLIs here,
|
|
850
|
+
* and none of these are on a GUI PATH or even in a NON-interactive login
|
|
851
|
+
* shell's PATH (their init lives in .zshrc/.bashrc), which is exactly how an
|
|
852
|
+
* installed `codex` still read "not found". Deterministic dirs beat shell
|
|
853
|
+
* probing: no subprocess, no profile side effects.
|
|
854
|
+
*/
|
|
855
|
+
function nodeManagerBinDirs(home: string): string[] {
|
|
856
|
+
const dirs = [
|
|
857
|
+
join(home, ".asdf", "shims"), // shims exec asdf by absolute path
|
|
858
|
+
join(home, ".volta", "bin"),
|
|
859
|
+
join(home, ".bun", "bin"),
|
|
860
|
+
join(home, ".npm-global", "bin"),
|
|
861
|
+
// fnm's stable default-alias bin — the per-shell multishell paths its
|
|
862
|
+
// shell init exports are ephemeral. macOS, XDG, and legacy layouts.
|
|
863
|
+
join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin"),
|
|
864
|
+
join(home, ".local", "share", "fnm", "aliases", "default", "bin"),
|
|
865
|
+
join(home, ".fnm", "aliases", "default", "bin"),
|
|
866
|
+
];
|
|
867
|
+
// nvm has no stable "current" symlink — enumerate installed versions,
|
|
868
|
+
// newest first (numeric compare: lexicographic would put v9 over v22).
|
|
869
|
+
// A version dir's bin also holds its `node`, so the shebang of whatever
|
|
870
|
+
// we find there resolves against the same dir once it leads PATH.
|
|
871
|
+
try {
|
|
872
|
+
const versions = join(home, ".nvm", "versions", "node");
|
|
873
|
+
dirs.push(
|
|
874
|
+
...readdirSync(versions)
|
|
875
|
+
.sort(compareVersionDesc)
|
|
876
|
+
.map((v) => join(versions, v, "bin")),
|
|
877
|
+
);
|
|
878
|
+
} catch {
|
|
879
|
+
/* no nvm */
|
|
880
|
+
}
|
|
881
|
+
return dirs;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function compareVersionDesc(a: string, b: string): number {
|
|
885
|
+
const parts = (s: string): number[] =>
|
|
886
|
+
s.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
887
|
+
const [a1 = 0, a2 = 0, a3 = 0] = parts(a);
|
|
888
|
+
const [b1 = 0, b2 = 0, b3 = 0] = parts(b);
|
|
889
|
+
return b1 - a1 || b2 - a2 || b3 - a3;
|
|
890
|
+
}
|
|
891
|
+
|
|
595
892
|
interface ResolvedBin {
|
|
596
893
|
/** Command to spawn — absolute when found, else the bare name. */
|
|
597
894
|
bin: string;
|
package/lib/env.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* machine; cloud metadata arrives over the host protocol.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync } from "node:fs";
|
|
6
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import { dirname, resolve } from "node:path";
|
|
8
|
+
import { dirname, resolve, sep } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
|
|
11
11
|
import { z } from "zod";
|
|
@@ -26,6 +26,12 @@ const schema = z.object({
|
|
|
26
26
|
UAI_HOST_KEY_FILE: z.string().min(1).optional(),
|
|
27
27
|
UAI_GH_PAT_FALLBACK: z.string().min(1).optional(),
|
|
28
28
|
UAI_WORKSPACE_ROOT: z.string().min(1).default("~/.uai"),
|
|
29
|
+
// Crash reporting (ADR-071, docs/observability.md). Default-ON for
|
|
30
|
+
// installed hosts via the DSN baked into lib/obs.ts; UAI_SENTRY_DSN
|
|
31
|
+
// overrides it (self-hosted Sentry), UAI_TELEMETRY_DISABLED=1 turns it
|
|
32
|
+
// off entirely — zero telemetry bytes leave the machine.
|
|
33
|
+
UAI_SENTRY_DSN: z.string().min(1).optional(),
|
|
34
|
+
UAI_TELEMETRY_DISABLED: z.string().optional(),
|
|
29
35
|
NODE_ENV: z
|
|
30
36
|
.enum(["development", "production", "test"])
|
|
31
37
|
.default("development"),
|
|
@@ -61,14 +67,27 @@ function findUp(start: string, pred: (dir: string) => boolean): string | null {
|
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
// Monorepo root iff we're running from a checkout: a dir holding BOTH
|
|
64
|
-
// pnpm-workspace.yaml and a host-agent/ child.
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
// pnpm-workspace.yaml and a host-agent/ child. The node_modules guard makes
|
|
71
|
+
// the "null when installed" comment TRUE (review finding: a tarball beneath
|
|
72
|
+
// a foreign pnpm workspace with a sibling host-agent/ resolved uaiHome — and
|
|
73
|
+
// the telemetry disclosure marker — into that workspace instead of ~/.uai).
|
|
74
|
+
// Real path first: pnpm reaches this file through symlinks. Keep in sync
|
|
75
|
+
// with src/load-env.ts, which applies the same rule.
|
|
76
|
+
const realHere = (() => {
|
|
77
|
+
try {
|
|
78
|
+
return realpathSync(here);
|
|
79
|
+
} catch {
|
|
80
|
+
return here;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
const repoRoot = realHere.split(sep).includes("node_modules")
|
|
84
|
+
? null
|
|
85
|
+
: findUp(
|
|
86
|
+
realHere,
|
|
87
|
+
(d) =>
|
|
88
|
+
existsSync(resolve(d, "pnpm-workspace.yaml")) &&
|
|
89
|
+
existsSync(resolve(d, "host-agent")),
|
|
90
|
+
);
|
|
72
91
|
|
|
73
92
|
// UAI_HOME: where .env.local + the data dir live. Explicit UAI_HOME wins; then
|
|
74
93
|
// the parent of an explicit UAI_DATA_DIR; then the repo root (dev checkout);
|