@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/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
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
writeAgentCli,
|
|
52
52
|
} from "./agent-cli";
|
|
53
53
|
import { setupBrowserTesting } from "./browser-testing";
|
|
54
|
+
import { injectCodexIntoContainer } from "./codex-auth";
|
|
54
55
|
import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
|
|
55
56
|
import { env } from "./env";
|
|
56
57
|
import type {
|
|
@@ -1392,11 +1393,26 @@ export function getOrchestrator(): Orchestrator {
|
|
|
1392
1393
|
if (!globalForOrchestrator.__uaiRecoverRan) {
|
|
1393
1394
|
globalForOrchestrator.__uaiRecoverRan = true;
|
|
1394
1395
|
// Fire-and-forget — the orchestrator is usable while recovery runs.
|
|
1395
|
-
|
|
1396
|
+
// The promise is kept so boot steps that must NOT race recovery (the
|
|
1397
|
+
// Codex credential reinject) can sequence behind it via
|
|
1398
|
+
// recoveryComplete(). recoverRunningTasks never rejects.
|
|
1399
|
+
recoveryPromise = recoverRunningTasks();
|
|
1400
|
+
void recoveryPromise;
|
|
1396
1401
|
}
|
|
1397
1402
|
return globalForOrchestrator.__uaiOrchestrator;
|
|
1398
1403
|
}
|
|
1399
1404
|
|
|
1405
|
+
let recoveryPromise: Promise<void> = Promise.resolve();
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Resolves once boot-time recovery has finished (starting the orchestrator —
|
|
1409
|
+
* and with it recovery — if that hasn't happened yet). Never rejects.
|
|
1410
|
+
*/
|
|
1411
|
+
export function recoveryComplete(): Promise<void> {
|
|
1412
|
+
getOrchestrator();
|
|
1413
|
+
return recoveryPromise;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1400
1416
|
// ---------------------------------------------------------------------------
|
|
1401
1417
|
// Boot-time recovery
|
|
1402
1418
|
//
|
|
@@ -1497,7 +1513,8 @@ async function dockerExec(
|
|
|
1497
1513
|
return res.status === 0;
|
|
1498
1514
|
}
|
|
1499
1515
|
|
|
1500
|
-
|
|
1516
|
+
/** Exported for tests; production entry is the getOrchestrator() boot guard. */
|
|
1517
|
+
export async function recoverRunningTasks(): Promise<void> {
|
|
1501
1518
|
try {
|
|
1502
1519
|
const db = getDb();
|
|
1503
1520
|
const rows = db
|
|
@@ -1601,6 +1618,12 @@ async function recoverOneTask(
|
|
|
1601
1618
|
});
|
|
1602
1619
|
return;
|
|
1603
1620
|
}
|
|
1621
|
+
// Correctly-owned Codex creds BEFORE uai-init / any agent respawn: the boot
|
|
1622
|
+
// reinject sweep only targets containers already running, so a container
|
|
1623
|
+
// recovered here would otherwise keep whatever it held when it exited —
|
|
1624
|
+
// possibly host-uid-owned 0600 files Codex can't read. Failure is surfaced
|
|
1625
|
+
// by the inject itself and must not abort the task's recovery.
|
|
1626
|
+
await injectCodexIntoContainer(containerName);
|
|
1604
1627
|
// uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
|
|
1605
1628
|
// repo. dockerCli's 30s default would SIGKILL it mid-install.
|
|
1606
1629
|
if (
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runuai/host",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.17",
|
|
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/scripts/agent/task-up.sh
CHANGED
|
@@ -462,17 +462,25 @@ fi
|
|
|
462
462
|
# Copy Codex credentials/config into a task-private /home/node/.codex.
|
|
463
463
|
# Do not mount the host ~/.codex writable: Codex stores live SQLite state
|
|
464
464
|
# there, and concurrent host/container access can corrupt it.
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
465
|
+
# Failures here are REAL errors (the container is running at task-up):
|
|
466
|
+
# docker cp preserves the host uid/gid, so a skipped chown leaves 0600
|
|
467
|
+
# files owned by e.g. macOS 501:20 that the container's node (1000:1000)
|
|
468
|
+
# cannot read — Codex then dies at startup.
|
|
469
|
+
if docker exec -u root "$app_container" \
|
|
470
|
+
mkdir -p /home/node/.codex >/dev/null; then
|
|
471
|
+
for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
|
|
472
|
+
if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
|
|
473
|
+
docker cp "$UAI_OWNER_HOME/.codex/$codex_item" \
|
|
474
|
+
"$app_container":/home/node/.codex/ >/dev/null \
|
|
475
|
+
|| log "ERROR: docker cp of .codex/$codex_item failed; Codex in this task may not authenticate"
|
|
476
|
+
fi
|
|
477
|
+
done
|
|
478
|
+
docker exec -u root "$app_container" \
|
|
479
|
+
chown -R node:node /home/node/.codex >/dev/null \
|
|
480
|
+
|| log "ERROR: chown of /home/node/.codex failed — 0600 credentials remain unreadable by node. Fix: docker exec -u root $app_container chown -R node:node /home/node/.codex"
|
|
481
|
+
else
|
|
482
|
+
log "ERROR: mkdir /home/node/.codex failed in $app_container; Codex credentials were not injected"
|
|
483
|
+
fi
|
|
476
484
|
|
|
477
485
|
# Copy Kimi Code subscription/config into a task-private /home/node/.kimi-code.
|
|
478
486
|
# The Linux `kimi` binary is baked into the image; here we copy ONLY the
|