@runuai/host 0.8.16 → 0.8.18
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/agents/transport.ts +23 -2
- package/lib/engines.ts +1 -1
- package/lib/env.ts +29 -10
- package/lib/obs.ts +514 -0
- package/lib/orchestrator.ts +5 -1
- package/package.json +2 -1
- package/src/cli.ts +33 -1
- package/src/load-env.ts +54 -11
- package/src/main.ts +13 -0
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/agents/transport.ts
CHANGED
|
@@ -66,6 +66,25 @@ export interface AgentTransportOptions {
|
|
|
66
66
|
/** Runner heartbeat is 5 s; older than this = not attachable. */
|
|
67
67
|
const ATTACH_HEARTBEAT_FRESH_MS = 20_000;
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* ONE live outbox consumer per (task, agent) in this host process. A session
|
|
71
|
+
* dropped from the orchestrator's map without close()/detach() (e.g. the
|
|
72
|
+
* error-event path — a pipes-era "process is dead" assumption) leaves its
|
|
73
|
+
* DurableProcess polling FOREVER: its liveness check only trips when the
|
|
74
|
+
* RUNNER dies, and the runner is alive. The next channelEnsure then attaches
|
|
75
|
+
* a second tail to the same outbox, and every agent turn is emitted once per
|
|
76
|
+
* leaked tail — live incident 2026-07-20: one agent's messages posted ×5 to
|
|
77
|
+
* the feed, multiplicity growing by one per errored turn. Enforcing the
|
|
78
|
+
* invariant here covers every replacement path, including future ones.
|
|
79
|
+
*/
|
|
80
|
+
const liveTails = new Map<string, DurableProcess>();
|
|
81
|
+
|
|
82
|
+
function claimTail(key: string, proc: DurableProcess): DurableProcess {
|
|
83
|
+
liveTails.get(key)?.detach();
|
|
84
|
+
liveTails.set(key, proc);
|
|
85
|
+
return proc;
|
|
86
|
+
}
|
|
87
|
+
|
|
69
88
|
function durableEnabled(): boolean {
|
|
70
89
|
return process.env.UAI_DURABLE_SESSIONS !== "0";
|
|
71
90
|
}
|
|
@@ -117,6 +136,8 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
117
136
|
.run();
|
|
118
137
|
};
|
|
119
138
|
|
|
139
|
+
const tailKey = `${opts.taskId}:${opts.agentId}`;
|
|
140
|
+
|
|
120
141
|
// ---- Attach: a previous host process left this agent's runner alive. ----
|
|
121
142
|
if (opts.allowAttach && row && row.status === "running" && heartbeatFresh(row.sessionDir)) {
|
|
122
143
|
const proc = new DurableProcess({
|
|
@@ -127,7 +148,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
127
148
|
debugLabel: opts.debugLabel,
|
|
128
149
|
});
|
|
129
150
|
proc.onExit(markClosed);
|
|
130
|
-
return proc;
|
|
151
|
+
return claimTail(tailKey, proc);
|
|
131
152
|
}
|
|
132
153
|
|
|
133
154
|
// ---- Spawn a fresh runner, asking any predecessor to stop. --------------
|
|
@@ -231,7 +252,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
231
252
|
.run();
|
|
232
253
|
|
|
233
254
|
proc.onExit(markClosed);
|
|
234
|
-
return proc;
|
|
255
|
+
return claimTail(tailKey, proc);
|
|
235
256
|
}
|
|
236
257
|
|
|
237
258
|
function heartbeatFresh(sessionDir: string): boolean {
|
package/lib/engines.ts
CHANGED
|
@@ -518,7 +518,7 @@ export function disconnectEngine(
|
|
|
518
518
|
}
|
|
519
519
|
|
|
520
520
|
// ---------------------------------------------------------------------------
|
|
521
|
-
// Install a missing engine CLI (ADR-
|
|
521
|
+
// Install a missing engine CLI (ADR-074).
|
|
522
522
|
// ---------------------------------------------------------------------------
|
|
523
523
|
|
|
524
524
|
/**
|
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);
|
package/lib/obs.ts
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agent crash reporting (ADR-071). Default-ON for installed hosts,
|
|
3
|
+
* with disclosure and a first-class opt-out — an installed product whose
|
|
4
|
+
* crash reporting requires hand-editing .env.local reports nothing from
|
|
5
|
+
* exactly the machines we need visibility into. The contract:
|
|
6
|
+
*
|
|
7
|
+
* - UAI_TELEMETRY_DISABLED=1 → nothing initializes, zero bytes leave the
|
|
8
|
+
* machine. The one switch, documented everywhere the DSN default is.
|
|
9
|
+
* - UAI_SENTRY_DSN=… → operator override (self-hosters pointing at
|
|
10
|
+
* their own Sentry).
|
|
11
|
+
* - otherwise → DEFAULT_HOST_DSN below. DSNs are public by
|
|
12
|
+
* design (every browser bundle ships one); this is errors-only crash
|
|
13
|
+
* reporting, content-scrubbed, console breadcrumbs disabled.
|
|
14
|
+
*
|
|
15
|
+
* Every start with the default DSN logs a disclosure line naming the
|
|
16
|
+
* opt-out, and the default only ACTIVATES after the persisted disclosure
|
|
17
|
+
* marker exists (see the gate below).
|
|
18
|
+
*
|
|
19
|
+
* Captures uncaught exceptions / unhandled rejections (then the process
|
|
20
|
+
* exits as it does today; launchd/systemd restarts it), plus
|
|
21
|
+
* bridge-lifecycle breadcrumbs for context. Console breadcrumbs are
|
|
22
|
+
* disabled: host console output carries third-party process text
|
|
23
|
+
* (git/docker stderr, agent lifecycle lines) no scrubber can bound.
|
|
24
|
+
*
|
|
25
|
+
* Errors only — tracesSampleRate 0. The WSS heartbeat shares this event loop
|
|
26
|
+
* (agent-notes: never block it); we add no per-event work.
|
|
27
|
+
*
|
|
28
|
+
* The scrub rules MIRROR lib/obs/scrub.ts on the cloud side, duplicated by
|
|
29
|
+
* hand because the cloud may not import host internals and vice versa
|
|
30
|
+
* (eslint import guard). Every behavior here — key lists, string patterns,
|
|
31
|
+
* depth cap ("[max-depth]", never raw pass-through), hostile-proxy fencing —
|
|
32
|
+
* must match the cloud scrubber; obs.test.ts holds the parity regressions
|
|
33
|
+
* and docs/observability.md holds the shared table. The hooks are exported
|
|
34
|
+
* so tests exercise exactly what Sentry runs.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
|
|
41
|
+
import * as Sentry from "@sentry/node";
|
|
42
|
+
|
|
43
|
+
import { env } from "./env";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The uai-host Sentry project's DSN, baked into the shipped package (npm +
|
|
47
|
+
* desktop-vendored copy). Public by design — a DSN can only ingest events,
|
|
48
|
+
* never read them. Activation is still gated: persisted disclosure marker,
|
|
49
|
+
* never in repo checkouts/dev/test, UAI_TELEMETRY_DISABLED=1 kills it,
|
|
50
|
+
* UAI_SENTRY_DSN overrides it.
|
|
51
|
+
*/
|
|
52
|
+
export const DEFAULT_HOST_DSN =
|
|
53
|
+
"https://f1b34b8b7df6f7ee519500fd06faa872@o4511765849178112.ingest.us.sentry.io/4511768446369792";
|
|
54
|
+
|
|
55
|
+
export type HostDsnSource = "disabled" | "explicit" | "default" | "none";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pure resolution of the telemetry contract above — unit-tested. The baked
|
|
59
|
+
* DEFAULT applies only to INSTALLED hosts: a repo checkout
|
|
60
|
+
* (UAI_AGENT_FROM_REPO) or an explicit dev/test NODE_ENV never
|
|
61
|
+
* default-activates — the consent claim is "installed product telemetry",
|
|
62
|
+
* not "developing the repo phones home". An explicit UAI_SENTRY_DSN is
|
|
63
|
+
* always honored.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveHostDsn(env: {
|
|
66
|
+
UAI_TELEMETRY_DISABLED?: string;
|
|
67
|
+
UAI_SENTRY_DSN?: string;
|
|
68
|
+
NODE_ENV?: string;
|
|
69
|
+
UAI_AGENT_FROM_REPO?: string;
|
|
70
|
+
UAI_LAUNCHED_FROM_REPO?: string;
|
|
71
|
+
}): { dsn: string | null; source: HostDsnSource } {
|
|
72
|
+
if (env.UAI_TELEMETRY_DISABLED === "1") {
|
|
73
|
+
return { dsn: null, source: "disabled" };
|
|
74
|
+
}
|
|
75
|
+
if (env.UAI_SENTRY_DSN) {
|
|
76
|
+
return { dsn: env.UAI_SENTRY_DSN, source: "explicit" };
|
|
77
|
+
}
|
|
78
|
+
// UAI_LAUNCHED_FROM_REPO is stamped by src/load-env.ts from its own
|
|
79
|
+
// filesystem position (review finding: real dev launches — root
|
|
80
|
+
// `pnpm host-agent`, desktop non-packaged — set none of the explicit
|
|
81
|
+
// flags). The explicit flags remain as overrides/belt.
|
|
82
|
+
const repoOrDev =
|
|
83
|
+
env.NODE_ENV === "development" ||
|
|
84
|
+
env.NODE_ENV === "test" ||
|
|
85
|
+
env.UAI_AGENT_FROM_REPO === "1" ||
|
|
86
|
+
env.UAI_AGENT_FROM_REPO === "true" ||
|
|
87
|
+
env.UAI_LAUNCHED_FROM_REPO === "1";
|
|
88
|
+
if (DEFAULT_HOST_DSN && !repoOrDev) {
|
|
89
|
+
return { dsn: DEFAULT_HOST_DSN, source: "default" };
|
|
90
|
+
}
|
|
91
|
+
return { dsn: null, source: "none" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The disclosure text, single-sourced: the service start log line and the
|
|
96
|
+
* `uai-host install` / `pair` / `setup` / `start` / `restart` stdout all
|
|
97
|
+
* print exactly this. Says what is TRUE, no more: crash reports carry a
|
|
98
|
+
* pseudonymous hostId tag ("anonymous" would overclaim), OS/runtime
|
|
99
|
+
* versions ride along, and error text is scrubbed for secret shapes but
|
|
100
|
+
* pattern scrubbing cannot recognize arbitrary prose — the residual-risk
|
|
101
|
+
* stance documented in docs/observability.md, mirrored here instead of a
|
|
102
|
+
* "never" the implementation can't fully guarantee.
|
|
103
|
+
*/
|
|
104
|
+
export const TELEMETRY_NOTICE =
|
|
105
|
+
"crash reporting is ON by default for installed hosts. Sent on a crash " +
|
|
106
|
+
"only: the error and stack trace (paths + secret-shaped strings " +
|
|
107
|
+
"redacted), recent bridge-connection breadcrumbs, OS/runtime versions, " +
|
|
108
|
+
"agent version, and a pseudonymous host id — no sessions, no usage " +
|
|
109
|
+
"pings, no request data, and telemetry is designed to exclude chat, " +
|
|
110
|
+
"prompts, env values, and repo content (error text is scrubbed, but " +
|
|
111
|
+
"redaction is pattern-based, not perfect). Disable any time: " +
|
|
112
|
+
"UAI_TELEMETRY_DISABLED=1 in the host's .env.local.";
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Persisted disclosure gate (review): the baked DEFAULT only activates
|
|
116
|
+
* after the notice has actually been SHOWN once on this machine — a service
|
|
117
|
+
* silently upgraded via reboot/KeepAlive never sees a terminal, so it stays
|
|
118
|
+
* off until the next interactive `uai-host` command (or the desktop
|
|
119
|
+
* first-run dialog, which writes the same marker) discloses and persists.
|
|
120
|
+
*/
|
|
121
|
+
export function telemetryDisclosureMarkerPath(home: string = env.uaiHome): string {
|
|
122
|
+
return join(home, ".telemetry-disclosed");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function hasSeenTelemetryDisclosure(home: string = env.uaiHome): boolean {
|
|
126
|
+
try {
|
|
127
|
+
return existsSync(telemetryDisclosureMarkerPath(home));
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Best-effort persist; called by the surfaces that PRINT the notice. */
|
|
134
|
+
export function persistTelemetryDisclosure(home: string = env.uaiHome): void {
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(home, { recursive: true });
|
|
137
|
+
writeFileSync(
|
|
138
|
+
telemetryDisclosureMarkerPath(home),
|
|
139
|
+
`${new Date().toISOString()}\n`,
|
|
140
|
+
);
|
|
141
|
+
} catch {
|
|
142
|
+
// Worst case: the notice prints again next time.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Pure gate — unit-tested: the default requires a persisted disclosure. */
|
|
147
|
+
export function defaultActivationAllowed(
|
|
148
|
+
source: HostDsnSource,
|
|
149
|
+
disclosureSeen: boolean,
|
|
150
|
+
): boolean {
|
|
151
|
+
return source !== "default" || disclosureSeen;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The notice CLI commands print, or null when the default isn't active. */
|
|
155
|
+
export function telemetryNoticeIfActive(): string | null {
|
|
156
|
+
return resolveHostDsn(process.env).source === "default"
|
|
157
|
+
? TELEMETRY_NOTICE
|
|
158
|
+
: null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let initialized = false;
|
|
162
|
+
|
|
163
|
+
const SUBSTRING_KEY =
|
|
164
|
+
/(token|secret|password|passwd|credential|authorization|cookie|apikey|api[-_]key|private[-_]?key)/i;
|
|
165
|
+
const EXACT_KEY =
|
|
166
|
+
/^(prompt|initialprompt|globalcontext|defaultprompt|content|text|body|env|http\.query|url\.query|query|query_string|querystring|search|http\.fragment|url\.fragment|fragment|hash)$/i;
|
|
167
|
+
|
|
168
|
+
function isSensitiveKey(key: string): boolean {
|
|
169
|
+
return EXACT_KEY.test(key) || SUBSTRING_KEY.test(key);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const URL_CREDENTIALS_RE = /(\/\/)[^/\s@:]+:[^/\s@]+@/g;
|
|
173
|
+
// ?query OR #fragment tails (fragments carry OAuth implicit tokens) —
|
|
174
|
+
// mirrors lib/obs/scrub.ts.
|
|
175
|
+
const URL_TAIL_RE = /(https?:\/\/[^\s"'<>?#]+)([?#])[^\s"'<>]*/g;
|
|
176
|
+
const RELATIVE_URL_TAIL_RE =
|
|
177
|
+
/(^|[\s"'`(=])(\/[A-Za-z0-9_\-./~%[\]]*)([?#])[^\s"'<>]*/g;
|
|
178
|
+
const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
|
|
179
|
+
const TOKEN_RES: RegExp[] = [
|
|
180
|
+
/\b(?:sk|rk)-[A-Za-z0-9_-]{16,}\b/g,
|
|
181
|
+
/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{8,}\b/g,
|
|
182
|
+
/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
|
|
183
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
184
|
+
/\bxox[a-z]-[A-Za-z0-9-]{10,}\b/g,
|
|
185
|
+
/\bwhsec_[A-Za-z0-9]{8,}\b/g,
|
|
186
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*\b/g,
|
|
187
|
+
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
// Identity/repo-metadata rules beyond the cloud mirror — host strings carry
|
|
191
|
+
// operator PATHS: the home dir embeds the username, and task worktree paths
|
|
192
|
+
// embed project slugs (repo-identifying — "no repo data" must hold for
|
|
193
|
+
// stack frames too).
|
|
194
|
+
/**
|
|
195
|
+
* Separator-agnostic, case-insensitive home matcher (exported for injected-
|
|
196
|
+
* home tests): Sentry normalizes Windows ESM frame paths to FORWARD slashes,
|
|
197
|
+
* so an exact `C:\Users\Alice` match left `C:/Users/Alice` unredacted
|
|
198
|
+
* (review probe), and Windows paths are case-insensitive.
|
|
199
|
+
*/
|
|
200
|
+
export function buildHomePathRe(home: string): RegExp {
|
|
201
|
+
const pattern = home
|
|
202
|
+
.split(/[\\/]+/)
|
|
203
|
+
.filter((seg) => seg !== "")
|
|
204
|
+
.map((seg) => seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
205
|
+
.join("[\\\\/]+");
|
|
206
|
+
// A POSIX home is rooted — the leading separator must be part of the
|
|
207
|
+
// match, or "/home/op" replaces as "/~". Windows homes ("C:\…") aren't.
|
|
208
|
+
const lead = /^[\\/]/.test(home) ? "[\\\\/]+" : "";
|
|
209
|
+
return new RegExp(lead + pattern, "gi");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const HOME_RE = buildHomePathRe(homedir());
|
|
213
|
+
// MCP gateway routes are BEARER-IN-PATH: /t/<token>/<connection-slug>
|
|
214
|
+
// (mcp-gateway.ts). A crash near the gateway put both verbatim into event
|
|
215
|
+
// strings (review receiver) — normalize the whole segment pair.
|
|
216
|
+
const MCP_GATEWAY_PATH_RE = /(\/t\/)[^/\s"'?#]+(\/[^/\s"'?#]+)?/g;
|
|
217
|
+
// Both separator styles: hosts run on Windows too, and a POSIX-only rule
|
|
218
|
+
// left repo slugs intact in win32 paths (review probe).
|
|
219
|
+
const TASK_WORKSPACE_SLUG_RE =
|
|
220
|
+
/([/\\]tasks[/\\][A-Za-z0-9_-]+[/\\]workspace[/\\])[^/\\\s"']+/g;
|
|
221
|
+
|
|
222
|
+
export function scrubHostString(value: string): string {
|
|
223
|
+
let out = value;
|
|
224
|
+
out = out.replace(URL_CREDENTIALS_RE, "$1[redacted]@");
|
|
225
|
+
out = out.replace(URL_TAIL_RE, "$1$2[redacted]");
|
|
226
|
+
out = out.replace(RELATIVE_URL_TAIL_RE, "$1$2$3[redacted]");
|
|
227
|
+
for (const re of TOKEN_RES) out = out.replace(re, "[redacted]");
|
|
228
|
+
out = out.replace(EMAIL_RE, "[email]");
|
|
229
|
+
out = out.replace(HOME_RE, "~");
|
|
230
|
+
out = out.replace(TASK_WORKSPACE_SLUG_RE, "$1[project]");
|
|
231
|
+
out = out.replace(MCP_GATEWAY_PATH_RE, "$1[redacted]");
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** URL field sanitizer: drop ?query/#fragment wholesale, then string-scrub. */
|
|
236
|
+
export function scrubHostUrl(url: string): string {
|
|
237
|
+
const q = url.indexOf("?");
|
|
238
|
+
const h = url.indexOf("#");
|
|
239
|
+
const cut = q === -1 ? h : h === -1 ? q : Math.min(q, h);
|
|
240
|
+
return scrubHostString(
|
|
241
|
+
cut === -1 ? url : `${url.slice(0, cut)}${url[cut]}[redacted]`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Mirror of the cloud scrubValue: depth-capped, hostile-proxy-fenced. */
|
|
246
|
+
export function scrubHostValue(value: unknown, depth = 0): unknown {
|
|
247
|
+
if (typeof value === "string") return scrubHostString(value);
|
|
248
|
+
if (value === null || typeof value !== "object") return value;
|
|
249
|
+
if (depth > 8) return "[max-depth]";
|
|
250
|
+
try {
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
return value.map((v) => {
|
|
253
|
+
try {
|
|
254
|
+
return scrubHostValue(v, depth + 1);
|
|
255
|
+
} catch {
|
|
256
|
+
return "[unreadable]";
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const out: Record<string, unknown> = {};
|
|
261
|
+
for (const k of Object.keys(value as Record<string, unknown>)) {
|
|
262
|
+
if (isSensitiveKey(k)) {
|
|
263
|
+
out[k] = "[scrubbed]";
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
out[k] = scrubHostValue((value as Record<string, unknown>)[k], depth + 1);
|
|
268
|
+
} catch {
|
|
269
|
+
out[k] = "[unreadable]";
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
} catch {
|
|
274
|
+
return "[unreadable]";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface HostScrubbableBreadcrumb {
|
|
279
|
+
category?: string;
|
|
280
|
+
message?: string;
|
|
281
|
+
data?: Record<string, unknown>;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
interface HostScrubbableFrame {
|
|
285
|
+
filename?: string;
|
|
286
|
+
abs_path?: string;
|
|
287
|
+
module?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interface HostScrubbableEvent {
|
|
291
|
+
message?: string;
|
|
292
|
+
server_name?: string;
|
|
293
|
+
transaction?: string;
|
|
294
|
+
request?: unknown;
|
|
295
|
+
exception?: {
|
|
296
|
+
values?: Array<{
|
|
297
|
+
type?: string;
|
|
298
|
+
value?: string;
|
|
299
|
+
stacktrace?: { frames?: HostScrubbableFrame[] };
|
|
300
|
+
}>;
|
|
301
|
+
};
|
|
302
|
+
extra?: Record<string, unknown>;
|
|
303
|
+
contexts?: Record<string, Record<string, unknown> | undefined>;
|
|
304
|
+
breadcrumbs?: HostScrubbableBreadcrumb[];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The exact beforeSend hook Sentry runs — exported for regression tests. */
|
|
308
|
+
export function scrubHostEvent<T extends HostScrubbableEvent>(event: T): T {
|
|
309
|
+
// The SDK stamps os.hostname() ("Diogos-Mac-Studio.local") as server_name
|
|
310
|
+
// — identifying, and exactly what the pseudonymous hostId tag replaces.
|
|
311
|
+
delete event.server_name;
|
|
312
|
+
// MINIMAL CONTRACT (review): request context and transaction names are
|
|
313
|
+
// DROPPED wholesale, not sanitized. The MCP gateway is bearer-in-path
|
|
314
|
+
// (/t/<token>/<slug>), and per-field scrubbing left the token verbatim in
|
|
315
|
+
// both `transaction` and `request.url`, plus mcp-session-id in headers —
|
|
316
|
+
// the disclosed payload is the error, not the route that raised it.
|
|
317
|
+
delete event.request;
|
|
318
|
+
delete event.transaction;
|
|
319
|
+
if (typeof event.message === "string") {
|
|
320
|
+
event.message = scrubHostString(event.message);
|
|
321
|
+
}
|
|
322
|
+
if (event.exception?.values) {
|
|
323
|
+
for (const ex of event.exception.values) {
|
|
324
|
+
if (typeof ex.value === "string") ex.value = scrubHostString(ex.value);
|
|
325
|
+
// Stack frame paths ride the operator's home dir (username) and task
|
|
326
|
+
// worktree slugs (repo names) — string-scrub them frame by frame.
|
|
327
|
+
for (const frame of ex.stacktrace?.frames ?? []) {
|
|
328
|
+
if (typeof frame.filename === "string") {
|
|
329
|
+
frame.filename = scrubHostString(frame.filename);
|
|
330
|
+
}
|
|
331
|
+
if (typeof frame.abs_path === "string") {
|
|
332
|
+
frame.abs_path = scrubHostString(frame.abs_path);
|
|
333
|
+
}
|
|
334
|
+
if (typeof frame.module === "string") {
|
|
335
|
+
frame.module = scrubHostString(frame.module);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (event.extra) {
|
|
341
|
+
event.extra = scrubHostValue(event.extra) as Record<string, unknown>;
|
|
342
|
+
}
|
|
343
|
+
if (event.contexts) {
|
|
344
|
+
// Allow-list, not scrub: the SDK's environment-derived system context
|
|
345
|
+
// (device, app, culture, cloud resource) exceeds the disclosed "error +
|
|
346
|
+
// stack trace + host id + version". Keep only what crash triage needs.
|
|
347
|
+
const pruned: NonNullable<HostScrubbableEvent["contexts"]> = {};
|
|
348
|
+
const os = event.contexts.os;
|
|
349
|
+
if (os) pruned.os = { name: os.name, version: os.version };
|
|
350
|
+
const runtime = event.contexts.runtime;
|
|
351
|
+
if (runtime) {
|
|
352
|
+
pruned.runtime = { name: runtime.name, version: runtime.version };
|
|
353
|
+
}
|
|
354
|
+
if (event.contexts.trace) {
|
|
355
|
+
pruned.trace = scrubHostValue(event.contexts.trace) as Record<
|
|
356
|
+
string,
|
|
357
|
+
unknown
|
|
358
|
+
>;
|
|
359
|
+
}
|
|
360
|
+
event.contexts = pruned as T["contexts"];
|
|
361
|
+
}
|
|
362
|
+
if (Array.isArray(event.breadcrumbs)) {
|
|
363
|
+
// Bridge-only is ENFORCED here too, not just at beforeBreadcrumb: any
|
|
364
|
+
// crumb already sitting on the event (added before init hooks, or by a
|
|
365
|
+
// future integration) is allow-listed away (review probe: a native
|
|
366
|
+
// `http` crumb carrying a private repo URL shipped unchanged).
|
|
367
|
+
event.breadcrumbs = event.breadcrumbs
|
|
368
|
+
.filter((crumb) => crumb?.category === "bridge")
|
|
369
|
+
.map((crumb) => scrubHostBreadcrumb(crumb))
|
|
370
|
+
.filter((crumb): crumb is NonNullable<typeof crumb> => crumb !== null);
|
|
371
|
+
}
|
|
372
|
+
return event;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The exact beforeBreadcrumb hook Sentry runs — exported for tests.
|
|
377
|
+
* FAIL-CLOSED allow-list (review): only the curated `bridge` category
|
|
378
|
+
* exists in this telemetry; anything else — whatever integration or code
|
|
379
|
+
* path produced it — is dropped, then the survivor is scrubbed.
|
|
380
|
+
*/
|
|
381
|
+
export function scrubHostBreadcrumb<T extends HostScrubbableBreadcrumb>(
|
|
382
|
+
crumb: T,
|
|
383
|
+
): T | null {
|
|
384
|
+
if (crumb.category !== "bridge") return null;
|
|
385
|
+
if (typeof crumb.message === "string") {
|
|
386
|
+
crumb.message = scrubHostString(crumb.message);
|
|
387
|
+
}
|
|
388
|
+
if (crumb.data) {
|
|
389
|
+
crumb.data = scrubHostValue(crumb.data) as Record<string, unknown>;
|
|
390
|
+
}
|
|
391
|
+
return crumb;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The exact Sentry.init options — exported pure for tests, because "errors
|
|
396
|
+
* only" is a checkable claim, not a vibe: release-health sessions send an
|
|
397
|
+
* envelope AT INIT (review probe caught it) and client reports are another
|
|
398
|
+
* non-crash egress; both are off, so nothing leaves until a crash.
|
|
399
|
+
*/
|
|
400
|
+
export function buildHostInitOptions(args: {
|
|
401
|
+
dsn: string;
|
|
402
|
+
version?: string;
|
|
403
|
+
}): Sentry.NodeOptions {
|
|
404
|
+
return {
|
|
405
|
+
dsn: args.dsn,
|
|
406
|
+
// A service install has no NODE_ENV — that's the PRODUCTION case for a
|
|
407
|
+
// host (review finding: installs were labelled "development"). Explicit
|
|
408
|
+
// dev/test envs still label themselves; SENTRY_ENVIRONMENT overrides.
|
|
409
|
+
environment:
|
|
410
|
+
process.env.SENTRY_ENVIRONMENT ||
|
|
411
|
+
(process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test"
|
|
412
|
+
? process.env.NODE_ENV
|
|
413
|
+
: "production"),
|
|
414
|
+
release: args.version,
|
|
415
|
+
tracesSampleRate: 0,
|
|
416
|
+
sendDefaultPii: false,
|
|
417
|
+
sendClientReports: false,
|
|
418
|
+
// With tracing off the SDK still injects sentry-trace/baggage headers
|
|
419
|
+
// (public key, release, environment) into ORDINARY outbound HTTP —
|
|
420
|
+
// metadata third parties the host talks to shouldn't receive.
|
|
421
|
+
tracePropagationTargets: [],
|
|
422
|
+
// "strict" preserves today's semantics: an unhandled rejection still
|
|
423
|
+
// takes the process down (service manager restarts) — Sentry's default
|
|
424
|
+
// "warn" mode would silently keep a possibly-wedged host alive.
|
|
425
|
+
// Console breadcrumbs are DROPPED (header comment) — host console
|
|
426
|
+
// carries unboundable third-party output. ProcessSession is DROPPED —
|
|
427
|
+
// it is the init-time session envelope that falsified "errors only".
|
|
428
|
+
// Modules is DROPPED — the full dependency inventory is data the
|
|
429
|
+
// disclosure doesn't cover and debugging doesn't need. ContextLines is
|
|
430
|
+
// DROPPED — the disclosure promises "error and stack trace", and
|
|
431
|
+
// source-line excerpts exceed that.
|
|
432
|
+
// Http is REMOVED ENTIRELY, not reconfigured. Every retained slice of it
|
|
433
|
+
// broke the minimal contract in review probes: default incoming-session
|
|
434
|
+
// tracking sent a `sessions` envelope on a plain GET; outbound
|
|
435
|
+
// breadcrumbs shipped completed request URLs; and — unfixable by hooks —
|
|
436
|
+
// inbound `sentry-trace`/`baggage` TRACE CONTINUATION copies
|
|
437
|
+
// attacker-controlled public_key/release/environment/user_segment/
|
|
438
|
+
// transaction into the ENVELOPE HEADER, which beforeSend never sees.
|
|
439
|
+
// With request/transaction context already discarded, http
|
|
440
|
+
// instrumentation carries no payload value for crash-only reporting.
|
|
441
|
+
// NodeFetch goes with it for the same minimal contract: it adds fetch
|
|
442
|
+
// breadcrumbs (outbound request data) and is the undici half of trace
|
|
443
|
+
// propagation. No http-layer instrumentation remains on either module.
|
|
444
|
+
integrations: (defaults) => [
|
|
445
|
+
...defaults.filter(
|
|
446
|
+
(i) =>
|
|
447
|
+
i.name !== "Console" &&
|
|
448
|
+
i.name !== "ProcessSession" &&
|
|
449
|
+
i.name !== "Modules" &&
|
|
450
|
+
i.name !== "ContextLines" &&
|
|
451
|
+
i.name !== "Http" &&
|
|
452
|
+
i.name !== "NodeFetch",
|
|
453
|
+
),
|
|
454
|
+
Sentry.onUnhandledRejectionIntegration({ mode: "strict" }),
|
|
455
|
+
],
|
|
456
|
+
beforeSend: (event) => scrubHostEvent(event),
|
|
457
|
+
beforeBreadcrumb: (crumb) => scrubHostBreadcrumb(crumb),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function initHostObs(opts: { version?: string } = {}): void {
|
|
462
|
+
if (initialized) return;
|
|
463
|
+
const { dsn, source } = resolveHostDsn(process.env);
|
|
464
|
+
if (!dsn) return;
|
|
465
|
+
if (!defaultActivationAllowed(source, hasSeenTelemetryDisclosure())) {
|
|
466
|
+
console.log(
|
|
467
|
+
"[host-agent] crash reporting is pending disclosure — it stays OFF until the notice has been shown (`uai-host install`/`pair`/`setup`/`start`/`restart`, or the desktop first run)",
|
|
468
|
+
);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
Sentry.init(buildHostInitOptions({ dsn, version: opts.version }));
|
|
473
|
+
initialized = true;
|
|
474
|
+
if (source === "default") {
|
|
475
|
+
// The disclosure half of default-on: every start says what leaves the
|
|
476
|
+
// machine and how to turn it off (same text the install/pair CLIs print).
|
|
477
|
+
console.log(`[host-agent] ${TELEMETRY_NOTICE}`);
|
|
478
|
+
} else {
|
|
479
|
+
console.log("[host-agent] sentry crash reporting enabled (UAI_SENTRY_DSN)");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function hostObsEnabled(): boolean {
|
|
484
|
+
return initialized;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Tag every future event with this host's id (safe to call repeatedly). */
|
|
488
|
+
export function setHostObsTag(hostId: string): void {
|
|
489
|
+
if (!initialized) return;
|
|
490
|
+
Sentry.setTag("hostId", hostId);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Bridge-lifecycle context for the next crash. No-op when disabled. The
|
|
495
|
+
* category is NARROWED to the allow-listed literal — the hooks drop
|
|
496
|
+
* everything else, so a wider signature would only invite silent losses.
|
|
497
|
+
*/
|
|
498
|
+
export function addHostBreadcrumb(
|
|
499
|
+
category: "bridge",
|
|
500
|
+
message: string,
|
|
501
|
+
data?: Record<string, unknown>,
|
|
502
|
+
): void {
|
|
503
|
+
if (!initialized) return;
|
|
504
|
+
Sentry.addBreadcrumb({ category, message, data, level: "info" });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Explicit capture for caught-but-fatal paths. No-op when disabled. */
|
|
508
|
+
export function captureHostError(
|
|
509
|
+
err: unknown,
|
|
510
|
+
extra?: Record<string, unknown>,
|
|
511
|
+
): void {
|
|
512
|
+
if (!initialized) return;
|
|
513
|
+
Sentry.captureException(err, extra ? { extra } : undefined);
|
|
514
|
+
}
|
package/lib/orchestrator.ts
CHANGED
|
@@ -688,7 +688,11 @@ class Orchestrator {
|
|
|
688
688
|
// becomes a zombie: the map still holds the dead session and every
|
|
689
689
|
// later deliver "succeeds" into a closed pipe (found live 2026-07-08
|
|
690
690
|
// after a double SIGKILL). Bounded by the respawn budget, checked in
|
|
691
|
-
// reconcileSessions.
|
|
691
|
+
// reconcileSessions. NOTE (durable sessions): an errored TURN does
|
|
692
|
+
// not mean a dead RUNNER — the dropped session's outbox tail would
|
|
693
|
+
// poll forever and duplicate every later turn (×5 live 2026-07-20).
|
|
694
|
+
// createAgentTransport's one-tail-per-agent registry detaches the
|
|
695
|
+
// stale consumer when the respawn attaches.
|
|
692
696
|
channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
|
|
693
697
|
channel.respawnLastAt.set(agentId, Date.now());
|
|
694
698
|
channel.sessions.delete(agentId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runuai/host",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.18",
|
|
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>",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"uai-host": "tsx src/cli.ts"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
+
"@sentry/node": "^10.66.0",
|
|
69
70
|
"better-sqlite3": "^11.3.0",
|
|
70
71
|
"dotenv": "^16.4.5",
|
|
71
72
|
"drizzle-orm": "^0.36.0",
|
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)
|
|
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
|
-
|
|
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 =
|
|
32
|
-
|
|
33
|
-
(
|
|
34
|
-
|
|
35
|
-
|
|
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 (!
|
|
78
|
+
if (!existsSync(path)) continue;
|
|
45
79
|
try {
|
|
46
|
-
proc.loadEnvFile
|
|
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
|
@@ -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
|
|
@@ -268,6 +274,8 @@ function connect(): void {
|
|
|
268
274
|
reconnectAttempt = 0;
|
|
269
275
|
markConnected();
|
|
270
276
|
console.log(`[host-agent] connected as ${hostId}`);
|
|
277
|
+
setHostObsTag(hostId);
|
|
278
|
+
addHostBreadcrumb("bridge", "connected");
|
|
271
279
|
|
|
272
280
|
unsubscribe = hostEvents.subscribe((event) => {
|
|
273
281
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
@@ -446,6 +454,11 @@ function connect(): void {
|
|
|
446
454
|
console.warn(
|
|
447
455
|
`[host-agent] disconnected${ready ? "" : " before auth"}; reconnecting in ${Math.round(delay)}ms`,
|
|
448
456
|
);
|
|
457
|
+
addHostBreadcrumb("bridge", "disconnected", {
|
|
458
|
+
code,
|
|
459
|
+
authed: ready,
|
|
460
|
+
retryInMs: Math.round(delay),
|
|
461
|
+
});
|
|
449
462
|
setTimeout(connect, delay);
|
|
450
463
|
});
|
|
451
464
|
}
|