@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/src/cli.ts CHANGED
@@ -27,6 +27,10 @@ import { dirname } from "node:path";
27
27
 
28
28
  import { ulid } from "ulid";
29
29
 
30
+ import {
31
+ persistTelemetryDisclosure,
32
+ telemetryNoticeIfActive,
33
+ } from "../lib/obs";
30
34
  import { envLocalPath, serviceLogPath, uaiHome, uiPortFilePath } from "./paths";
31
35
  import { CloudResponse, StatusResponse, TasksResponse } from "./ui/types";
32
36
  import type { InstallContext, Installer } from "../scripts/install/types";
@@ -322,6 +326,7 @@ async function cmdSetup(rest: string[]): Promise<void> {
322
326
  return;
323
327
  }
324
328
  console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
329
+ printTelemetryNotice();
325
330
  console.log("");
326
331
  console.log("Start the host:");
327
332
  console.log(cyan(" uai-host install && uai-host start") + dim(" (first time)"));
@@ -342,6 +347,7 @@ async function cmdPair(token: string | undefined): Promise<void> {
342
347
  upsertEnvLocal("UAI_HOST_TOKEN", token);
343
348
  console.log(green("paired") + dim(` — wrote UAI_HOST_TOKEN to ${envLocalPath()}`));
344
349
  console.log(dim("apply it: uai-host restart (or start the service)"));
350
+ printTelemetryNotice();
345
351
  }
346
352
 
347
353
  function upsertEnvLocal(key: string, value: string): void {
@@ -361,15 +367,41 @@ function upsertEnvLocal(key: string, value: string): void {
361
367
  // --- install dispatch -------------------------------------------------------
362
368
 
363
369
  async function cmdInstall(dryRun: boolean): Promise<void> {
370
+ // Disclosure BEFORE activation (review): install starts the service, and
371
+ // the notice must precede the first telemetry-armed run.
372
+ if (!dryRun) printTelemetryNotice();
364
373
  const installer = await loadInstaller();
365
374
  await installer.install(installContext(dryRun));
366
- if (!dryRun) console.log(green("installed") + dim(" — start it: uai-host start"));
375
+ if (!dryRun) {
376
+ console.log(green("installed") + dim(" — start it: uai-host start"));
377
+ }
378
+ }
379
+
380
+ /**
381
+ * ADR-071 default-on disclosure at the surfaces an operator actually SEES:
382
+ * the service's own start line only reaches host.out.log/journald, so the
383
+ * interactive commands (install/pair/setup/start/restart) print it — and
384
+ * PERSIST the shown-once marker that gates the baked default (a silently
385
+ * upgraded service stays off until one of these runs).
386
+ */
387
+ function printTelemetryNotice(): void {
388
+ const notice = telemetryNoticeIfActive();
389
+ if (notice) {
390
+ console.log(yellow("telemetry: ") + notice);
391
+ persistTelemetryDisclosure();
392
+ }
367
393
  }
368
394
 
369
395
  async function cmdInstaller(
370
396
  action: "uninstall" | "start" | "stop" | "restart",
371
397
  dryRun: boolean,
372
398
  ): Promise<void> {
399
+ // Upgrade path (review finding): operators of EXISTING installs meet
400
+ // default-on telemetry through start/restart — disclose BEFORE the
401
+ // service action activates anything, and persist the gate marker.
402
+ if (!dryRun && (action === "start" || action === "restart")) {
403
+ printTelemetryNotice();
404
+ }
373
405
  const installer = await loadInstaller();
374
406
  await installer[action](dryRun);
375
407
  if (!dryRun && action === "uninstall") console.log(green("uninstalled"));
package/src/load-env.ts CHANGED
@@ -3,16 +3,37 @@
3
3
  * read environment variables.
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, join, resolve } from "node:path";
8
+ import { dirname, join, resolve, sep } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
+ import { config as dotenvConfig } from "dotenv";
12
+
11
13
  const proc = process as unknown as {
12
14
  loadEnvFile?: (path: string) => void;
13
15
  };
14
16
 
15
- const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/src
17
+ // Real path, not the import path: pnpm workspace links reach this file via
18
+ // symlinks — a repo checkout must resolve to the checkout, and an installed
19
+ // tarball to its true node_modules location.
20
+ const here = (() => {
21
+ const raw = dirname(fileURLToPath(import.meta.url)); // <pkg>/src
22
+ try {
23
+ return realpathSync(raw);
24
+ } catch {
25
+ return raw;
26
+ }
27
+ })();
28
+
29
+ // An INSTALLED copy always lives under a node_modules directory. Without
30
+ // this guard, a tarball installed beneath some user's pnpm workspace that
31
+ // happens to contain a sibling host-agent/ dir was mistaken for this source
32
+ // checkout — suppressing telemetry AND selecting the wrong UAI_HOME
33
+ // (review finding). Packaged desktop resources contain no node_modules
34
+ // segment and no workspace above them; the repo checkout contains neither
35
+ // problem.
36
+ const installedCopy = here.split(sep).includes("node_modules");
16
37
 
17
38
  // Resolve the same UAI_HOME as lib/env.ts, but independently: this module runs
18
39
  // FIRST (before lib/env is imported) so it can't depend on it. .env.local is
@@ -28,22 +49,44 @@ function findUp(start: string, pred: (dir: string) => boolean): string | null {
28
49
  }
29
50
  }
30
51
 
31
- const repoRoot = findUp(
32
- here,
33
- (d) =>
34
- existsSync(resolve(d, "pnpm-workspace.yaml")) &&
35
- existsSync(resolve(d, "host-agent")),
36
- );
52
+ const repoRoot = installedCopy
53
+ ? null
54
+ : findUp(
55
+ here,
56
+ (d) =>
57
+ existsSync(resolve(d, "pnpm-workspace.yaml")) &&
58
+ existsSync(resolve(d, "host-agent")),
59
+ );
37
60
 
38
61
  const uaiHome = process.env.UAI_HOME
39
62
  ? resolve(process.env.UAI_HOME.replace(/^~(?=\/|$)/, homedir()))
40
63
  : (repoRoot ?? resolve(homedir(), ".uai"));
41
64
 
65
+ // ADR-071: expose the repo-checkout detection computed above to the
66
+ // telemetry gate (lib/obs resolveHostDsn). `pnpm host-agent` from a fresh
67
+ // checkout and host-desktop's non-packaged dev launch set neither NODE_ENV
68
+ // nor UAI_AGENT_FROM_REPO — this marker is the signal tied to the REAL
69
+ // launch path, so dev runs never default-activate crash reporting. Packaged
70
+ // installs (npm tarball, desktop resources) have no workspace file above
71
+ // them and stay unmarked.
72
+ if (repoRoot && !process.env.UAI_LAUNCHED_FROM_REPO) {
73
+ process.env.UAI_LAUNCHED_FROM_REPO = "1";
74
+ }
75
+
42
76
  for (const file of [".env.local", ".env"]) {
43
77
  const path = join(uaiHome, file);
44
- if (!proc.loadEnvFile || !existsSync(path)) continue;
78
+ if (!existsSync(path)) continue;
45
79
  try {
46
- proc.loadEnvFile(path);
80
+ if (proc.loadEnvFile) {
81
+ proc.loadEnvFile(path);
82
+ } else {
83
+ // process.loadEnvFile landed in Node 20.12; the package supports
84
+ // >=20. Silently skipping .env.local on 20.0–20.11 would ignore
85
+ // UAI_TELEMETRY_DISABLED — the telemetry kill switch MUST load on
86
+ // every supported Node, so fall back to dotenv (already a dep).
87
+ // Neither loader overrides pre-set process.env — same semantics.
88
+ dotenvConfig({ path });
89
+ }
47
90
  } catch (err) {
48
91
  console.warn(
49
92
  `[host-agent] could not load ${file}: ${(err as Error).message}`,
package/src/main.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  markReconnecting,
24
24
  } from "../lib/cloud-state";
25
25
  import { getHostTask } from "../lib/runtime-state";
26
- import { getOrchestrator } from "../lib/orchestrator";
26
+ import { getOrchestrator, recoveryComplete } from "../lib/orchestrator";
27
27
  import {
28
28
  connectedUserIds,
29
29
  isTransientGithubError,
@@ -45,6 +45,7 @@ import {
45
45
  } from "../lib/host-env";
46
46
  import { handleMcpOp } from "../lib/mcp-connections";
47
47
  import { startMcpGateway } from "../lib/mcp-gateway";
48
+ import { addHostBreadcrumb, initHostObs, setHostObsTag } from "../lib/obs";
48
49
  import {
49
50
  packageVersion,
50
51
  serviceLogPath,
@@ -89,6 +90,11 @@ import {
89
90
  FilesOpInput,
90
91
  } from "./protocol";
91
92
 
93
+ // Crash reporting (ADR-071) — default-ON for installed hosts via the baked
94
+ // DSN (lib/obs.ts); repo checkouts and dev/test never default-activate.
95
+ // UAI_TELEMETRY_DISABLED=1 turns it off; UAI_SENTRY_DSN overrides.
96
+ initHostObs({ version: packageVersion() });
97
+
92
98
  const PING_INTERVAL_MS = 15_000;
93
99
  const DEAD_AFTER_MS = 45_000;
94
100
  // Reconnect backoff, capped low (~15s) on purpose: cloud deploys drop the
@@ -159,7 +165,13 @@ void ensureStandardImage();
159
165
  // token → `codex login`) otherwise reaches only NEW tasks — re-copy into
160
166
  // running tasks on start (covers the desktop "Connect Codex", which restarts
161
167
  // the host) and watch ~/.codex for future logins (a terminal `codex login`).
162
- void reinjectCodexRunningTasks();
168
+ // Sequenced AFTER boot recovery: reinjecting while recovery was still
169
+ // `docker start`ing exited containers raced — `docker cp` into a stopped
170
+ // container succeeds but the ownership-fix exec can't run, stranding 0600
171
+ // files owned by the host uid (macOS 501) that the container's node (1000)
172
+ // can't read, and Codex died at startup. The post-recovery sweep also
173
+ // self-heals such containers: it re-copies and chowns every running task.
174
+ void recoveryComplete().then(() => reinjectCodexRunningTasks());
163
175
  watchCodexAuth();
164
176
  connect();
165
177
  // Local browser UI (ADR-028) — same single process, alongside the WSS client.
@@ -262,6 +274,8 @@ function connect(): void {
262
274
  reconnectAttempt = 0;
263
275
  markConnected();
264
276
  console.log(`[host-agent] connected as ${hostId}`);
277
+ setHostObsTag(hostId);
278
+ addHostBreadcrumb("bridge", "connected");
265
279
 
266
280
  unsubscribe = hostEvents.subscribe((event) => {
267
281
  if (socket.readyState !== WebSocket.OPEN) return;
@@ -440,6 +454,11 @@ function connect(): void {
440
454
  console.warn(
441
455
  `[host-agent] disconnected${ready ? "" : " before auth"}; reconnecting in ${Math.round(delay)}ms`,
442
456
  );
457
+ addHostBreadcrumb("bridge", "disconnected", {
458
+ code,
459
+ authed: ready,
460
+ retryInMs: Math.round(delay),
461
+ });
443
462
  setTimeout(connect, delay);
444
463
  });
445
464
  }
package/src/ui/server.ts CHANGED
@@ -30,6 +30,8 @@ import { dockerCli } from "../../lib/docker-exec";
30
30
  import {
31
31
  connectEngine,
32
32
  disconnectEngine,
33
+ engineCliStatuses,
34
+ installEngineCli,
33
35
  engineCatalog,
34
36
  engineStatuses,
35
37
  isEngineKind,
@@ -151,6 +153,8 @@ async function handle(
151
153
  switch (path) {
152
154
  case "/api/engines/connect":
153
155
  return await handleEngineConnect(req, res, opts);
156
+ case "/api/engines/install":
157
+ return await handleEngineInstall(req, res);
154
158
  case "/api/engines/disconnect":
155
159
  return await handleEngineDisconnect(req, res, opts);
156
160
  case "/api/tasks/stop":
@@ -181,7 +185,7 @@ async function handle(
181
185
  case "/api/users":
182
186
  return sendJson(res, UsersResponse, usersBody(opts));
183
187
  case "/api/engines":
184
- return sendJson(res, EnginesResponse, enginesBody());
188
+ return sendJson(res, EnginesResponse, await enginesBody());
185
189
  }
186
190
  if (path.startsWith("/api/")) {
187
191
  return sendError(res, 404, `no such endpoint: ${path}`);
@@ -194,8 +198,47 @@ async function handle(
194
198
 
195
199
  // --- engines ----------------------------------------------------------------
196
200
 
197
- function enginesBody(): EnginesResponse {
198
- return { catalog: engineCatalog(), statuses: engineStatuses() };
201
+ async function enginesBody(): Promise<EnginesResponse> {
202
+ return {
203
+ catalog: engineCatalog(),
204
+ statuses: engineStatuses(),
205
+ cli: await engineCliStatuses(),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * POST /api/engines/install `{kind}` — run the engine's pinned installer,
211
+ * streaming NDJSON like connect: `{"line":…}` frames, then `{done,ok,message}`.
212
+ * No re-advertise or image rebuild here: installing a CLI connects nothing;
213
+ * that happens on the connect that follows.
214
+ */
215
+ async function handleEngineInstall(
216
+ req: IncomingMessage,
217
+ res: ServerResponse,
218
+ ): Promise<void> {
219
+ const body = await readJsonBody(req);
220
+ const kind = body?.kind;
221
+ if (!isEngineKind(kind)) {
222
+ return sendError(res, 400, "unknown or missing engine kind");
223
+ }
224
+ res.writeHead(200, {
225
+ "content-type": "application/x-ndjson; charset=utf-8",
226
+ "cache-control": "no-store",
227
+ });
228
+ const emit = (obj: unknown): void => {
229
+ res.write(`${JSON.stringify(obj)}\n`);
230
+ };
231
+ let result: { ok: boolean; message: string };
232
+ try {
233
+ result = await installEngineCli(kind, (line) => emit({ line }));
234
+ } catch (err) {
235
+ result = {
236
+ ok: false,
237
+ message: err instanceof Error ? err.message : "install failed",
238
+ };
239
+ }
240
+ emit({ done: true, ok: result.ok, message: result.message });
241
+ res.end();
199
242
  }
200
243
 
201
244
  /**
package/src/ui/types.ts CHANGED
@@ -84,12 +84,15 @@ export const EngineCatalogEntry = z.object({
84
84
  // Pasted-API-key alternative to the login/token flow (null = not offered).
85
85
  apiKeyHint: z.string().nullable(),
86
86
  apiKeyUrl: z.string().nullable(),
87
+ // Install command shown when the CLI is missing (null = nothing to install).
88
+ installHint: z.string().nullable(),
87
89
  });
88
90
  export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
89
91
 
90
92
  export const EnginesResponse = z.object({
91
93
  catalog: z.array(EngineCatalogEntry),
92
94
  statuses: z.record(z.boolean()), // kind → connected
95
+ cli: z.record(z.boolean()), // kind → CLI resolvable on this host
93
96
  });
94
97
  export type EnginesResponse = z.infer<typeof EnginesResponse>;
95
98
 
package/ui/app.js CHANGED
@@ -483,6 +483,11 @@ function commandForm(e) {
483
483
  log.className = "log";
484
484
  log.hidden = true;
485
485
 
486
+ const pushLine = (line) => {
487
+ log.textContent += (log.textContent ? "\n" : "") + line;
488
+ log.scrollTop = log.scrollHeight;
489
+ };
490
+
486
491
  const actions = document.createElement("div");
487
492
  actions.className = "setup-actions";
488
493
  const connect = document.createElement("button");
@@ -499,10 +504,7 @@ function commandForm(e) {
499
504
  : "Your browser will open to sign in…";
500
505
  log.hidden = false;
501
506
  log.textContent = "";
502
- const result = await runConnect({ kind: e.kind }, (line) => {
503
- log.textContent += (log.textContent ? "\n" : "") + line;
504
- log.scrollTop = log.scrollHeight;
505
- });
507
+ const result = await runConnect({ kind: e.kind }, pushLine);
506
508
  if (result.ok) {
507
509
  await poll();
508
510
  closeModal();
@@ -514,6 +516,47 @@ function commandForm(e) {
514
516
  }
515
517
  });
516
518
  actions.append(connect);
519
+
520
+ // CLI missing → offer the pinned installer first, chaining into connect on
521
+ // success. The command is shown verbatim; its output streams into the log.
522
+ const cliMissing =
523
+ !!latestEngines?.cli && latestEngines.cli[e.kind] === false && !!e.installHint;
524
+ if (cliMissing) {
525
+ connect.hidden = true;
526
+ const installNote = document.createElement("p");
527
+ installNote.className = "setup-note";
528
+ installNote.textContent = `The ${e.label} CLI isn't installed on this Mac. Install it with:`;
529
+ const installCmd = document.createElement("pre");
530
+ installCmd.className = "log";
531
+ installCmd.textContent = e.installHint;
532
+ const install = document.createElement("button");
533
+ install.className = "btn";
534
+ install.type = "button";
535
+ install.textContent = "Install & connect";
536
+ install.addEventListener("click", async () => {
537
+ install.disabled = true;
538
+ install.textContent = "Installing…";
539
+ status.className = "setup-status";
540
+ status.textContent = "Running the installer…";
541
+ log.hidden = false;
542
+ log.textContent = "";
543
+ const result = await runInstall({ kind: e.kind }, pushLine);
544
+ if (result.ok) {
545
+ await poll();
546
+ install.hidden = true;
547
+ connect.hidden = false;
548
+ connect.click();
549
+ } else {
550
+ install.disabled = false;
551
+ install.textContent = "Try again";
552
+ status.className = "setup-status err";
553
+ status.textContent = result.message || "Install failed.";
554
+ }
555
+ });
556
+ actions.prepend(install);
557
+ form.append(installNote, installCmd);
558
+ }
559
+
517
560
  form.append(actions, status, log);
518
561
 
519
562
  // Manual paste fallback for when the browser flow isn't possible: Claude
@@ -590,14 +633,24 @@ function commandForm(e) {
590
633
  return form;
591
634
  }
592
635
 
636
+ /** POST /api/engines/connect and consume the NDJSON stream. */
637
+ function runConnect(body, onLine) {
638
+ return runStream("/api/engines/connect", body, onLine);
639
+ }
640
+
641
+ /** POST /api/engines/install and consume the NDJSON stream. */
642
+ function runInstall(body, onLine) {
643
+ return runStream("/api/engines/install", body, onLine);
644
+ }
645
+
593
646
  /**
594
- * POST /api/engines/connect and consume the NDJSON stream: `{line}` frames feed
595
- * onLine; the final `{done,ok,message}` frame is the result.
647
+ * POST an NDJSON-streaming endpoint: `{line}` frames feed onLine; the final
648
+ * `{done,ok,message}` frame is the result.
596
649
  */
597
- async function runConnect(body, onLine) {
650
+ async function runStream(path, body, onLine) {
598
651
  let res;
599
652
  try {
600
- res = await fetch("/api/engines/connect", {
653
+ res = await fetch(path, {
601
654
  method: "POST",
602
655
  headers: { "content-type": "application/json" },
603
656
  body: JSON.stringify(body),
@@ -606,7 +659,7 @@ async function runConnect(body, onLine) {
606
659
  return { ok: false, message: String(err) };
607
660
  }
608
661
  if (!res.ok || !res.body) {
609
- return { ok: false, message: `connect failed (${res.status})` };
662
+ return { ok: false, message: `request failed (${res.status})` };
610
663
  }
611
664
  const reader = res.body.getReader();
612
665
  const dec = new TextDecoder();