@moondeck/station 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +115 -8
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
  326. package/bin/moondeck-station.mjs +0 -3
package/lib/obs.ts ADDED
@@ -0,0 +1,512 @@
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 { STATION } from "../src/station-identity";
44
+ import { env } from "./env";
45
+ import { redactActiveConnectionSecrets } from "./connection-secret-redaction";
46
+ import { isDevelopmentLaunch } from "./launch-mode";
47
+
48
+ /**
49
+ * The uai-host Sentry project's DSN, baked into the shipped package (npm +
50
+ * desktop-vendored copy). Public by design — a DSN can only ingest events,
51
+ * never read them. Activation is still gated: persisted disclosure marker,
52
+ * never in repo checkouts/dev/test, UAI_TELEMETRY_DISABLED=1 kills it,
53
+ * UAI_SENTRY_DSN overrides it.
54
+ */
55
+ export const DEFAULT_HOST_DSN =
56
+ "https://f1b34b8b7df6f7ee519500fd06faa872@o4511765849178112.ingest.us.sentry.io/4511768446369792";
57
+
58
+ export type HostDsnSource = "disabled" | "explicit" | "default" | "none";
59
+
60
+ /**
61
+ * Pure resolution of the telemetry contract above — unit-tested. The baked
62
+ * DEFAULT applies only to INSTALLED hosts: a repo checkout
63
+ * (UAI_AGENT_FROM_REPO) or an explicit dev/test NODE_ENV never
64
+ * default-activates — the consent claim is "installed product telemetry",
65
+ * not "developing the repo phones home". An explicit UAI_SENTRY_DSN is
66
+ * always honored.
67
+ */
68
+ export function resolveHostDsn(env: {
69
+ UAI_TELEMETRY_DISABLED?: string;
70
+ UAI_SENTRY_DSN?: string;
71
+ NODE_ENV?: string;
72
+ UAI_AGENT_FROM_REPO?: string;
73
+ UAI_LAUNCHED_FROM_REPO?: string;
74
+ }): { dsn: string | null; source: HostDsnSource } {
75
+ if (env.UAI_TELEMETRY_DISABLED === "1") {
76
+ return { dsn: null, source: "disabled" };
77
+ }
78
+ if (env.UAI_SENTRY_DSN) {
79
+ return { dsn: env.UAI_SENTRY_DSN, source: "explicit" };
80
+ }
81
+ // UAI_LAUNCHED_FROM_REPO is stamped by src/load-env.ts from its own
82
+ // filesystem position (review finding: real dev launches — root
83
+ // `pnpm host-agent`, desktop non-packaged — set none of the explicit
84
+ // flags). The explicit flags remain as overrides/belt.
85
+ const repoOrDev = isDevelopmentLaunch(env);
86
+ if (DEFAULT_HOST_DSN && !repoOrDev) {
87
+ return { dsn: DEFAULT_HOST_DSN, source: "default" };
88
+ }
89
+ return { dsn: null, source: "none" };
90
+ }
91
+
92
+ /**
93
+ * The disclosure text, single-sourced: the service start log line and the
94
+ * `uai-host install` / `pair` / `setup` / `start` / `restart` stdout all
95
+ * print exactly this. Says what is TRUE, no more: crash reports carry a
96
+ * pseudonymous hostId tag ("anonymous" would overclaim), OS/runtime
97
+ * versions ride along, and error text is scrubbed for secret shapes but
98
+ * pattern scrubbing cannot recognize arbitrary prose — the residual-risk
99
+ * stance documented in docs/observability.md, mirrored here instead of a
100
+ * "never" the implementation can't fully guarantee.
101
+ */
102
+ export const TELEMETRY_NOTICE =
103
+ "crash reporting is ON by default for installed hosts. Sent on a crash " +
104
+ "only: the error and stack trace (paths + secret-shaped strings " +
105
+ "redacted), recent bridge-connection breadcrumbs, OS/runtime versions, " +
106
+ "agent version, and a pseudonymous host id — no sessions, no usage " +
107
+ "pings, no request data, and telemetry is designed to exclude chat, " +
108
+ "prompts, env values, and repo content (error text is scrubbed, but " +
109
+ "redaction is pattern-based, not perfect). Disable any time: " +
110
+ "UAI_TELEMETRY_DISABLED=1 in the host's .env.local.";
111
+
112
+ /**
113
+ * Persisted disclosure gate (review): the baked DEFAULT only activates
114
+ * after the notice has actually been SHOWN once on this machine — a service
115
+ * silently upgraded via reboot/KeepAlive never sees a terminal, so it stays
116
+ * off until the next interactive `uai-host` command (or the desktop
117
+ * first-run dialog, which writes the same marker) discloses and persists.
118
+ */
119
+ export function telemetryDisclosureMarkerPath(home: string = env.uaiHome): string {
120
+ return join(home, ".telemetry-disclosed");
121
+ }
122
+
123
+ export function hasSeenTelemetryDisclosure(home: string = env.uaiHome): boolean {
124
+ try {
125
+ return existsSync(telemetryDisclosureMarkerPath(home));
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /** Best-effort persist; called by the surfaces that PRINT the notice. */
132
+ export function persistTelemetryDisclosure(home: string = env.uaiHome): void {
133
+ try {
134
+ mkdirSync(home, { recursive: true });
135
+ writeFileSync(
136
+ telemetryDisclosureMarkerPath(home),
137
+ `${new Date().toISOString()}\n`,
138
+ );
139
+ } catch {
140
+ // Worst case: the notice prints again next time.
141
+ }
142
+ }
143
+
144
+ /** Pure gate — unit-tested: the default requires a persisted disclosure. */
145
+ export function defaultActivationAllowed(
146
+ source: HostDsnSource,
147
+ disclosureSeen: boolean,
148
+ ): boolean {
149
+ return source !== "default" || disclosureSeen;
150
+ }
151
+
152
+ /** The notice CLI commands print, or null when the default isn't active. */
153
+ export function telemetryNoticeIfActive(): string | null {
154
+ return resolveHostDsn(process.env).source === "default"
155
+ ? TELEMETRY_NOTICE
156
+ : null;
157
+ }
158
+
159
+ let initialized = false;
160
+
161
+ const SUBSTRING_KEY =
162
+ /(token|secret|password|passwd|credential|authorization|cookie|apikey|api[-_]key|private[-_]?key)/i;
163
+ const EXACT_KEY =
164
+ /^(prompt|initialprompt|globalcontext|defaultprompt|content|text|body|env|http\.query|url\.query|query|query_string|querystring|search|http\.fragment|url\.fragment|fragment|hash)$/i;
165
+
166
+ function isSensitiveKey(key: string): boolean {
167
+ return EXACT_KEY.test(key) || SUBSTRING_KEY.test(key);
168
+ }
169
+
170
+ const URL_CREDENTIALS_RE = /(\/\/)[^/\s@:]+:[^/\s@]+@/g;
171
+ // ?query OR #fragment tails (fragments carry OAuth implicit tokens) —
172
+ // mirrors lib/obs/scrub.ts.
173
+ const URL_TAIL_RE = /(https?:\/\/[^\s"'<>?#]+)([?#])[^\s"'<>]*/g;
174
+ const RELATIVE_URL_TAIL_RE =
175
+ /(^|[\s"'`(=])(\/[A-Za-z0-9_\-./~%[\]]*)([?#])[^\s"'<>]*/g;
176
+ const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
177
+ const TOKEN_RES: RegExp[] = [
178
+ /\b(?:sk|rk)-[A-Za-z0-9_-]{16,}\b/g,
179
+ /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{8,}\b/g,
180
+ /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
181
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
182
+ /\bxox[a-z]-[A-Za-z0-9-]{10,}\b/g,
183
+ /\bwhsec_[A-Za-z0-9]{8,}\b/g,
184
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*\b/g,
185
+ /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
186
+ ];
187
+
188
+ // Identity/repo-metadata rules beyond the cloud mirror — host strings carry
189
+ // operator PATHS: the home dir embeds the username, and task worktree paths
190
+ // embed project slugs (repo-identifying — "no repo data" must hold for
191
+ // stack frames too).
192
+ /**
193
+ * Separator-agnostic, case-insensitive home matcher (exported for injected-
194
+ * home tests): Sentry normalizes Windows ESM frame paths to FORWARD slashes,
195
+ * so an exact `C:\Users\Alice` match left `C:/Users/Alice` unredacted
196
+ * (review probe), and Windows paths are case-insensitive.
197
+ */
198
+ export function buildHomePathRe(home: string): RegExp {
199
+ const pattern = home
200
+ .split(/[\\/]+/)
201
+ .filter((seg) => seg !== "")
202
+ .map((seg) => seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
203
+ .join("[\\\\/]+");
204
+ // A POSIX home is rooted — the leading separator must be part of the
205
+ // match, or "/home/op" replaces as "/~". Windows homes ("C:\…") aren't.
206
+ const lead = /^[\\/]/.test(home) ? "[\\\\/]+" : "";
207
+ return new RegExp(lead + pattern, "gi");
208
+ }
209
+
210
+ const HOME_RE = buildHomePathRe(homedir());
211
+ // MCP gateway routes are BEARER-IN-PATH: /t/<token>/<connection-slug>
212
+ // (mcp-gateway.ts). A crash near the gateway put both verbatim into event
213
+ // strings (review receiver) — normalize the whole segment pair.
214
+ const MCP_GATEWAY_PATH_RE = /(\/t\/)[^/\s"'?#]+(\/[^/\s"'?#]+)?/g;
215
+ // Both separator styles: hosts run on Windows too, and a POSIX-only rule
216
+ // left repo slugs intact in win32 paths (review probe).
217
+ const TASK_WORKSPACE_SLUG_RE =
218
+ /([/\\]tasks[/\\][A-Za-z0-9_-]+[/\\]workspace[/\\])[^/\\\s"']+/g;
219
+
220
+ export function scrubHostString(value: string): string {
221
+ let out = redactActiveConnectionSecrets(value);
222
+ out = out.replace(URL_CREDENTIALS_RE, "$1[redacted]@");
223
+ out = out.replace(URL_TAIL_RE, "$1$2[redacted]");
224
+ out = out.replace(RELATIVE_URL_TAIL_RE, "$1$2$3[redacted]");
225
+ for (const re of TOKEN_RES) out = out.replace(re, "[redacted]");
226
+ out = out.replace(EMAIL_RE, "[email]");
227
+ out = out.replace(HOME_RE, "~");
228
+ out = out.replace(TASK_WORKSPACE_SLUG_RE, "$1[project]");
229
+ out = out.replace(MCP_GATEWAY_PATH_RE, "$1[redacted]");
230
+ return out;
231
+ }
232
+
233
+ /** URL field sanitizer: drop ?query/#fragment wholesale, then string-scrub. */
234
+ export function scrubHostUrl(url: string): string {
235
+ const q = url.indexOf("?");
236
+ const h = url.indexOf("#");
237
+ const cut = q === -1 ? h : h === -1 ? q : Math.min(q, h);
238
+ return scrubHostString(
239
+ cut === -1 ? url : `${url.slice(0, cut)}${url[cut]}[redacted]`,
240
+ );
241
+ }
242
+
243
+ /** Mirror of the cloud scrubValue: depth-capped, hostile-proxy-fenced. */
244
+ export function scrubHostValue(value: unknown, depth = 0): unknown {
245
+ if (typeof value === "string") return scrubHostString(value);
246
+ if (value === null || typeof value !== "object") return value;
247
+ if (depth > 8) return "[max-depth]";
248
+ try {
249
+ if (Array.isArray(value)) {
250
+ return value.map((v) => {
251
+ try {
252
+ return scrubHostValue(v, depth + 1);
253
+ } catch {
254
+ return "[unreadable]";
255
+ }
256
+ });
257
+ }
258
+ const out: Record<string, unknown> = {};
259
+ for (const k of Object.keys(value as Record<string, unknown>)) {
260
+ if (isSensitiveKey(k)) {
261
+ out[k] = "[scrubbed]";
262
+ continue;
263
+ }
264
+ try {
265
+ out[k] = scrubHostValue((value as Record<string, unknown>)[k], depth + 1);
266
+ } catch {
267
+ out[k] = "[unreadable]";
268
+ }
269
+ }
270
+ return out;
271
+ } catch {
272
+ return "[unreadable]";
273
+ }
274
+ }
275
+
276
+ interface HostScrubbableBreadcrumb {
277
+ category?: string;
278
+ message?: string;
279
+ data?: Record<string, unknown>;
280
+ }
281
+
282
+ interface HostScrubbableFrame {
283
+ filename?: string;
284
+ abs_path?: string;
285
+ module?: string;
286
+ }
287
+
288
+ interface HostScrubbableEvent {
289
+ message?: string;
290
+ server_name?: string;
291
+ transaction?: string;
292
+ request?: unknown;
293
+ exception?: {
294
+ values?: Array<{
295
+ type?: string;
296
+ value?: string;
297
+ stacktrace?: { frames?: HostScrubbableFrame[] };
298
+ }>;
299
+ };
300
+ extra?: Record<string, unknown>;
301
+ contexts?: Record<string, Record<string, unknown> | undefined>;
302
+ breadcrumbs?: HostScrubbableBreadcrumb[];
303
+ }
304
+
305
+ /** The exact beforeSend hook Sentry runs — exported for regression tests. */
306
+ export function scrubHostEvent<T extends HostScrubbableEvent>(event: T): T {
307
+ // The SDK stamps os.hostname() ("Diogos-Mac-Studio.local") as server_name
308
+ // — identifying, and exactly what the pseudonymous hostId tag replaces.
309
+ delete event.server_name;
310
+ // MINIMAL CONTRACT (review): request context and transaction names are
311
+ // DROPPED wholesale, not sanitized. The MCP gateway is bearer-in-path
312
+ // (/t/<token>/<slug>), and per-field scrubbing left the token verbatim in
313
+ // both `transaction` and `request.url`, plus mcp-session-id in headers —
314
+ // the disclosed payload is the error, not the route that raised it.
315
+ delete event.request;
316
+ delete event.transaction;
317
+ if (typeof event.message === "string") {
318
+ event.message = scrubHostString(event.message);
319
+ }
320
+ if (event.exception?.values) {
321
+ for (const ex of event.exception.values) {
322
+ if (typeof ex.value === "string") ex.value = scrubHostString(ex.value);
323
+ // Stack frame paths ride the operator's home dir (username) and task
324
+ // worktree slugs (repo names) — string-scrub them frame by frame.
325
+ for (const frame of ex.stacktrace?.frames ?? []) {
326
+ if (typeof frame.filename === "string") {
327
+ frame.filename = scrubHostString(frame.filename);
328
+ }
329
+ if (typeof frame.abs_path === "string") {
330
+ frame.abs_path = scrubHostString(frame.abs_path);
331
+ }
332
+ if (typeof frame.module === "string") {
333
+ frame.module = scrubHostString(frame.module);
334
+ }
335
+ }
336
+ }
337
+ }
338
+ if (event.extra) {
339
+ event.extra = scrubHostValue(event.extra) as Record<string, unknown>;
340
+ }
341
+ if (event.contexts) {
342
+ // Allow-list, not scrub: the SDK's environment-derived system context
343
+ // (device, app, culture, cloud resource) exceeds the disclosed "error +
344
+ // stack trace + host id + version". Keep only what crash triage needs.
345
+ const pruned: NonNullable<HostScrubbableEvent["contexts"]> = {};
346
+ const os = event.contexts.os;
347
+ if (os) pruned.os = { name: os.name, version: os.version };
348
+ const runtime = event.contexts.runtime;
349
+ if (runtime) {
350
+ pruned.runtime = { name: runtime.name, version: runtime.version };
351
+ }
352
+ if (event.contexts.trace) {
353
+ pruned.trace = scrubHostValue(event.contexts.trace) as Record<
354
+ string,
355
+ unknown
356
+ >;
357
+ }
358
+ event.contexts = pruned as T["contexts"];
359
+ }
360
+ if (Array.isArray(event.breadcrumbs)) {
361
+ // Bridge-only is ENFORCED here too, not just at beforeBreadcrumb: any
362
+ // crumb already sitting on the event (added before init hooks, or by a
363
+ // future integration) is allow-listed away (review probe: a native
364
+ // `http` crumb carrying a private repo URL shipped unchanged).
365
+ event.breadcrumbs = event.breadcrumbs
366
+ .filter((crumb) => crumb?.category === "bridge")
367
+ .map((crumb) => scrubHostBreadcrumb(crumb))
368
+ .filter((crumb): crumb is NonNullable<typeof crumb> => crumb !== null);
369
+ }
370
+ return event;
371
+ }
372
+
373
+ /**
374
+ * The exact beforeBreadcrumb hook Sentry runs — exported for tests.
375
+ * FAIL-CLOSED allow-list (review): only the curated `bridge` category
376
+ * exists in this telemetry; anything else — whatever integration or code
377
+ * path produced it — is dropped, then the survivor is scrubbed.
378
+ */
379
+ export function scrubHostBreadcrumb<T extends HostScrubbableBreadcrumb>(
380
+ crumb: T,
381
+ ): T | null {
382
+ if (crumb.category !== "bridge") return null;
383
+ if (typeof crumb.message === "string") {
384
+ crumb.message = scrubHostString(crumb.message);
385
+ }
386
+ if (crumb.data) {
387
+ crumb.data = scrubHostValue(crumb.data) as Record<string, unknown>;
388
+ }
389
+ return crumb;
390
+ }
391
+
392
+ /**
393
+ * The exact Sentry.init options — exported pure for tests, because "errors
394
+ * only" is a checkable claim, not a vibe: release-health sessions send an
395
+ * envelope AT INIT (review probe caught it) and client reports are another
396
+ * non-crash egress; both are off, so nothing leaves until a crash.
397
+ */
398
+ export function buildHostInitOptions(args: {
399
+ dsn: string;
400
+ version?: string;
401
+ }): Sentry.NodeOptions {
402
+ return {
403
+ dsn: args.dsn,
404
+ // A service install has no NODE_ENV — that's the PRODUCTION case for a
405
+ // host (review finding: installs were labelled "development"). Explicit
406
+ // dev/test envs still label themselves; SENTRY_ENVIRONMENT overrides.
407
+ environment:
408
+ process.env.SENTRY_ENVIRONMENT ||
409
+ (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test"
410
+ ? process.env.NODE_ENV
411
+ : "production"),
412
+ release: args.version,
413
+ tracesSampleRate: 0,
414
+ sendDefaultPii: false,
415
+ sendClientReports: false,
416
+ // With tracing off the SDK still injects sentry-trace/baggage headers
417
+ // (public key, release, environment) into ORDINARY outbound HTTP —
418
+ // metadata third parties the host talks to shouldn't receive.
419
+ tracePropagationTargets: [],
420
+ // "strict" preserves today's semantics: an unhandled rejection still
421
+ // takes the process down (service manager restarts) — Sentry's default
422
+ // "warn" mode would silently keep a possibly-wedged host alive.
423
+ // Console breadcrumbs are DROPPED (header comment) — host console
424
+ // carries unboundable third-party output. ProcessSession is DROPPED —
425
+ // it is the init-time session envelope that falsified "errors only".
426
+ // Modules is DROPPED — the full dependency inventory is data the
427
+ // disclosure doesn't cover and debugging doesn't need. ContextLines is
428
+ // DROPPED — the disclosure promises "error and stack trace", and
429
+ // source-line excerpts exceed that.
430
+ // Http is REMOVED ENTIRELY, not reconfigured. Every retained slice of it
431
+ // broke the minimal contract in review probes: default incoming-session
432
+ // tracking sent a `sessions` envelope on a plain GET; outbound
433
+ // breadcrumbs shipped completed request URLs; and — unfixable by hooks —
434
+ // inbound `sentry-trace`/`baggage` TRACE CONTINUATION copies
435
+ // attacker-controlled public_key/release/environment/user_segment/
436
+ // transaction into the ENVELOPE HEADER, which beforeSend never sees.
437
+ // With request/transaction context already discarded, http
438
+ // instrumentation carries no payload value for crash-only reporting.
439
+ // NodeFetch goes with it for the same minimal contract: it adds fetch
440
+ // breadcrumbs (outbound request data) and is the undici half of trace
441
+ // propagation. No http-layer instrumentation remains on either module.
442
+ integrations: (defaults) => [
443
+ ...defaults.filter(
444
+ (i) =>
445
+ i.name !== "Console" &&
446
+ i.name !== "ProcessSession" &&
447
+ i.name !== "Modules" &&
448
+ i.name !== "ContextLines" &&
449
+ i.name !== "Http" &&
450
+ i.name !== "NodeFetch",
451
+ ),
452
+ Sentry.onUnhandledRejectionIntegration({ mode: "strict" }),
453
+ ],
454
+ beforeSend: (event) => scrubHostEvent(event),
455
+ beforeBreadcrumb: (crumb) => scrubHostBreadcrumb(crumb),
456
+ };
457
+ }
458
+
459
+ export function initHostObs(opts: { version?: string } = {}): void {
460
+ if (initialized) return;
461
+ const { dsn, source } = resolveHostDsn(process.env);
462
+ if (!dsn) return;
463
+ if (!defaultActivationAllowed(source, hasSeenTelemetryDisclosure())) {
464
+ console.log(
465
+ `[host-agent] crash reporting is pending disclosure — it stays OFF until the notice has been shown (\`${STATION.command} install\`/\`pair\`/\`setup\`/\`start\`/\`restart\`)`,
466
+ );
467
+ return;
468
+ }
469
+
470
+ Sentry.init(buildHostInitOptions({ dsn, version: opts.version }));
471
+ initialized = true;
472
+ if (source === "default") {
473
+ // The disclosure half of default-on: every start says what leaves the
474
+ // machine and how to turn it off (same text the install/pair CLIs print).
475
+ console.log(`[host-agent] ${TELEMETRY_NOTICE}`);
476
+ } else {
477
+ console.log("[host-agent] sentry crash reporting enabled (UAI_SENTRY_DSN)");
478
+ }
479
+ }
480
+
481
+ export function hostObsEnabled(): boolean {
482
+ return initialized;
483
+ }
484
+
485
+ /** Tag every future event with this host's id (safe to call repeatedly). */
486
+ export function setHostObsTag(hostId: string): void {
487
+ if (!initialized) return;
488
+ Sentry.setTag("hostId", hostId);
489
+ }
490
+
491
+ /**
492
+ * Bridge-lifecycle context for the next crash. No-op when disabled. The
493
+ * category is NARROWED to the allow-listed literal — the hooks drop
494
+ * everything else, so a wider signature would only invite silent losses.
495
+ */
496
+ export function addHostBreadcrumb(
497
+ category: "bridge",
498
+ message: string,
499
+ data?: Record<string, unknown>,
500
+ ): void {
501
+ if (!initialized) return;
502
+ Sentry.addBreadcrumb({ category, message, data, level: "info" });
503
+ }
504
+
505
+ /** Explicit capture for caught-but-fatal paths. No-op when disabled. */
506
+ export function captureHostError(
507
+ err: unknown,
508
+ extra?: Record<string, unknown>,
509
+ ): void {
510
+ if (!initialized) return;
511
+ Sentry.captureException(err, extra ? { extra } : undefined);
512
+ }