@sunshinelife83/hearth 1.0.0 → 1.1.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.
@@ -15,15 +15,16 @@ const serverConfigSchema = z.object({
15
15
  // OAuth clients; only meaningful for local/LAN-only deployments.
16
16
  issuerMode: z.enum(["derived", "local"]).default("derived"),
17
17
  }).strict().prefault({});
18
- const tlsConfigSchema = z.object({
19
- // Native TLS for relay-free direct exposure: when both are set, `serve`
20
- // terminates HTTPS itself (e.g. behind your own domain + port forward).
21
- // Leave null when running behind localhost or a tunnel that terminates TLS.
22
- certFile: z.string().trim().min(1).nullable().default(null),
23
- keyFile: z.string().trim().min(1).nullable().default(null),
24
- // Directory served (read-only) at /.well-known/acme-challenge/ so certbot
25
- // webroot mode can provision certificates while the server runs.
26
- acmeDir: z.string().trim().min(1).nullable().default(null),
18
+ const tunnelConfigSchema = z.object({
19
+ // Managed per-PC tunnel. "none" serves locally only. "ngrok" lets
20
+ // `hearth ngrok setup` save this PC's static ngrok domain and
21
+ // `hearth serve --ngrok` supervise the agent, so one command is
22
+ // server + URL + tunnel.
23
+ provider: z.enum(["none", "ngrok"]).default("none"),
24
+ // Static ngrok domain for this PC (e.g. xxx.ngrok-free.dev).
25
+ // `hearth ngrok setup` sets this and keeps server.publicBaseUrl in sync.
26
+ // The ngrok authtoken itself lives in ngrok's own config, never here.
27
+ domain: z.string().trim().min(1).nullable().default(null),
27
28
  }).strict().prefault({});
28
29
  const workspaceProfileSchema = z.object({
29
30
  path: z.string().trim().min(1).describe("Absolute path (or ~-prefixed) this profile applies to; longest prefix wins."),
@@ -117,7 +118,7 @@ export const hearthConfigSchema = z.object({
117
118
  fleet: fleetConfigSchema.default({ lanes: {} }),
118
119
  execution: executionConfigSchema,
119
120
  logging: loggingConfigSchema,
120
- tls: tlsConfigSchema.default({ certFile: null, keyFile: null, acmeDir: null }),
121
+ tunnel: tunnelConfigSchema.default({ provider: "none", domain: null }),
121
122
  oauth: oauthConfigSchema,
122
123
  }).strict();
123
124
  export function defaultHearthConfig() {
package/dist/config.js CHANGED
@@ -7,12 +7,16 @@ export function loadConfig(env = process.env) {
7
7
  const host = stored.server.host;
8
8
  const port = stored.server.port;
9
9
  const publicBaseUrl = parsePublicBaseUrl(stored.server.publicBaseUrl ?? localPublicBaseUrl(host, port));
10
+ const tunnelDomain = stored.tunnel.provider === "ngrok" && stored.tunnel.domain
11
+ ? parseTunnelDomain(stored.tunnel.domain)
12
+ : null;
10
13
  const derivedAllowedHosts = [
11
14
  "localhost",
12
15
  "127.0.0.1",
13
16
  "::1",
14
17
  host,
15
18
  new URL(publicBaseUrl).hostname,
19
+ ...(tunnelDomain ? [tunnelDomain] : []),
16
20
  ...stored.server.allowedHosts,
17
21
  ];
18
22
  return {
@@ -68,10 +72,9 @@ export function loadConfig(env = process.env) {
68
72
  ...stored.logging,
69
73
  trustProxy: stored.server.trustProxy,
70
74
  },
71
- tls: {
72
- certFile: stored.tls.certFile,
73
- keyFile: stored.tls.keyFile,
74
- acmeDir: stored.tls.acmeDir,
75
+ tunnel: {
76
+ provider: stored.tunnel.provider,
77
+ domain: stored.tunnel.domain,
75
78
  },
76
79
  };
77
80
  }
@@ -96,6 +99,12 @@ function parseRequiredSecret(value) {
96
99
  }
97
100
  return secret;
98
101
  }
102
+ function parseTunnelDomain(value) {
103
+ const trimmed = value.trim().replace(/^https?:\/\//i, "").split("/")[0].trim();
104
+ if (!trimmed || /\s/.test(trimmed))
105
+ return null;
106
+ return trimmed.toLowerCase();
107
+ }
99
108
  function parsePublicBaseUrl(value) {
100
109
  const parsed = new URL(value);
101
110
  parsed.hash = "";
@@ -202,7 +202,7 @@ const renderers = {
202
202
  <div>Public MCP URL: <code>${esc(mcpUrl)}</code></div>
203
203
  <div class="muted">Local: <code>${esc(s.mcpEndpoint)}</code> · health: <code>${esc(s.healthEndpoint)}</code> · tool mode: <code>${esc(s.toolMode || "")}</code></div></div>
204
204
  <div class="card"><h3>ChatGPT</h3>
205
- <ol><li>Keep <code>hearth serve</code> running; tunnel must proxy the whole origin, not only <code>/mcp</code>.</li>
205
+ <ol><li>Keep <code>hearth serve --ngrok</code> running.</li>
206
206
  <li>Add connector URL <code>${esc(mcpUrl)}</code>.</li>
207
207
  <li>Approve with the Owner password (<code>~/.hearth/auth.json</code>).</li></ol></div>
208
208
  <div class="card"><h3>Claude</h3>
@@ -26,9 +26,9 @@ hearth serve</pre>
26
26
  <div class="card">
27
27
  <h3>Use it</h3>
28
28
  <ol>
29
- <li>Run <code>hearth serve</code> (or <code>hearth mcp</code> for local stdio).</li>
29
+ <li>Run <code>hearth serve --ngrok</code> (or <code>hearth mcp</code> for local stdio).</li>
30
30
  <li>Open the <a href="/dashboard">local dashboard</a> and sign in with your Owner password.</li>
31
- <li>Run <code>hearth connect</code> and paste the public <code>/mcp</code> URL into your MCP client (via your own tunnel for remote clients).</li>
31
+ <li>Run <code>hearth connect</code> and paste the public <code>/mcp</code> URL into your MCP client (served through this PC's ngrok domain when running <code>serve --ngrok</code>).</li>
32
32
  <li>Ask it to open a project, plan a task, delegate to a coding agent, verify, land.</li>
33
33
  </ol>
34
34
  </div>
package/dist/logger.js CHANGED
@@ -61,9 +61,6 @@ export function logEvent(config, level, event, fields = {}) {
61
61
  }
62
62
  export function requestIp(req, trustProxy) {
63
63
  if (trustProxy) {
64
- const cfConnectingIp = firstHeaderValue(req.header("cf-connecting-ip"));
65
- if (cfConnectingIp)
66
- return cfConnectingIp;
67
64
  const forwardedFor = firstHeaderValue(req.header("x-forwarded-for"));
68
65
  if (forwardedFor)
69
66
  return forwardedFor;
package/dist/server.js CHANGED
@@ -16,7 +16,6 @@ import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./a
16
16
  import { registerAgentTools } from "./agent-tools.js";
17
17
  import { registerDashboard, dashboardDirectory } from "./dashboard.js";
18
18
  import { loadMachineIdentity } from "./machine-id.js";
19
- import { expandHomePath } from "./roots.js";
20
19
  import { registerTaskTools } from "./task-tools.js";
21
20
  import { registerContextTools } from "./context/context-tools.js";
22
21
  import { TaskStore } from "./task-store.js";
@@ -153,6 +152,22 @@ function requestLogFields(req, config) {
153
152
  function assetBaseUrl(config) {
154
153
  return `${config.publicBaseUrl.replace(/\/+$/, "")}/mcp-app-assets`;
155
154
  }
155
+ /**
156
+ * ngrok forwards the whole origin, but only AI endpoints may be reachable
157
+ * remotely. Refuse the landing page and dashboard when the request arrived
158
+ * through the configured tunnel domain; localhost stays fully available.
159
+ */
160
+ function denyTunnelHost(config) {
161
+ return (req, res, next) => {
162
+ const domain = config.tunnel.provider === "ngrok" ? config.tunnel.domain?.toLowerCase() : null;
163
+ if (domain && (req.hostname ?? "").toLowerCase() === domain) {
164
+ logEvent(config.logging, "warn", "tunnel_remote_denied", { path: requestPath(req) });
165
+ res.status(404).json({ error: "Not found" });
166
+ return;
167
+ }
168
+ next();
169
+ };
170
+ }
156
171
  function uiManifestUrl() {
157
172
  return new URL("../dist/ui/.vite/manifest.json", import.meta.url);
158
173
  }
@@ -850,16 +865,10 @@ export function createServer(config = loadConfig(), options = {}) {
850
865
  mcp: new URL("/mcp", config.publicBaseUrl).toString(),
851
866
  });
852
867
  });
853
- // ACME HTTP-01 challenge passthrough for relay-free TLS: serve only
854
- // /.well-known/acme-challenge/* from the configured directory so certbot
855
- // webroot mode works while the server runs. Nothing else is exposed here.
856
- if (config.tls.acmeDir) {
857
- const acmeDir = expandHomePath(config.tls.acmeDir);
858
- app.use("/.well-known/acme-challenge", express.static(acmeDir, { fallthrough: false, maxAge: 0 }));
859
- }
860
- app.get("/", (_req, res) => {
868
+ app.get("/", denyTunnelHost(config), (_req, res) => {
861
869
  res.sendFile("landing.html", { root: dashboardDirectory() });
862
870
  });
871
+ app.use("/dashboard", denyTunnelHost(config));
863
872
  registerDashboard(app, {
864
873
  config,
865
874
  workspaces,
@@ -0,0 +1,270 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { accessSync, constants, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { delimiter, join, resolve } from "node:path";
4
+ /**
5
+ * Managed per-PC ngrok tunnel — the sole remote-access path.
6
+ *
7
+ * Each PC runs `ngrok http <port>` for its own free static dev domain
8
+ * (e.g. https://xxx.ngrok-free.dev), which never churns across restarts.
9
+ * Hearth supervises the agent, reads the live public URL from the local
10
+ * agent API (127.0.0.1:4040) instead of parsing logs, and refuses to serve
11
+ * publicly when the live domain drifts from the saved one.
12
+ *
13
+ * The ngrok authtoken is ngrok's own secret: it lives in ngrok's config via
14
+ * `ngrok config add-authtoken` and is never stored in Hearth config.
15
+ */
16
+ export const NGROK_AGENT_API = "http://127.0.0.1:4040/api";
17
+ export function ngrokPaths(stateDir) {
18
+ const dir = join(stateDir, "tunnels", "ngrok");
19
+ return { dir, pidPath: join(dir, "ngrok.pid") };
20
+ }
21
+ export function findNgrok(env = process.env) {
22
+ const pathValue = env.PATH;
23
+ if (!pathValue)
24
+ return undefined;
25
+ const binary = process.platform === "win32" ? "ngrok.exe" : "ngrok";
26
+ for (const directory of pathValue.split(delimiter)) {
27
+ if (!directory)
28
+ continue;
29
+ const candidate = resolve(directory, binary);
30
+ try {
31
+ accessSync(candidate, constants.X_OK);
32
+ return candidate;
33
+ }
34
+ catch {
35
+ // Not executable here; keep scanning.
36
+ }
37
+ }
38
+ return undefined;
39
+ }
40
+ export function ngrokInstallHint() {
41
+ return [
42
+ "ngrok was not found on PATH.",
43
+ "Install it first, then re-run `hearth ngrok setup`:",
44
+ " Linux/macOS: brew install ngrok (or https://ngrok.com/download)",
45
+ " Windows: winget install --id Ngrok.ngrok",
46
+ ].join("\n");
47
+ }
48
+ function runNgrok(binary, args) {
49
+ try {
50
+ const output = execFileSync(binary, args, {
51
+ encoding: "utf8",
52
+ timeout: 30_000,
53
+ maxBuffer: 1024 * 1024,
54
+ });
55
+ return { ok: true, output };
56
+ }
57
+ catch (error) {
58
+ const stderr = error.stderr;
59
+ const stdout = error.stdout;
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ return {
62
+ ok: false,
63
+ output: [typeof stdout === "string" ? stdout : "", typeof stderr === "string" ? stderr : "", message]
64
+ .filter(Boolean)
65
+ .join("\n"),
66
+ };
67
+ }
68
+ }
69
+ export function ngrokVersion(binary) {
70
+ const result = runNgrok(binary, ["version"]);
71
+ if (!result.ok)
72
+ throw new Error(`ngrok version check failed:\n${result.output}`);
73
+ return result.output.trim().split("\n")[0] ?? "unknown";
74
+ }
75
+ /** An authtoken is configured when `ngrok config check` succeeds. */
76
+ export function checkNgrokAuth(binary) {
77
+ return runNgrok(binary, ["config", "check"]);
78
+ }
79
+ export function validateNgrokDomain(value) {
80
+ const trimmed = value?.trim() ?? "";
81
+ if (!trimmed)
82
+ return "Enter the static ngrok domain for this PC, for example xxx.ngrok-free.dev.";
83
+ const bare = trimmed.replace(/^https?:\/\//i, "").split("/")[0] ?? "";
84
+ if (!bare || /\s/.test(bare) || bare.includes(":") || bare.includes("/")) {
85
+ return "Enter a bare domain such as xxx.ngrok-free.dev (no scheme, path, or port).";
86
+ }
87
+ if (/\/mcp\/?$/i.test(trimmed))
88
+ return "Enter the domain only, without /mcp.";
89
+ if (!bare.includes("."))
90
+ return `That does not look like an ngrok domain: ${JSON.stringify(bare)}.`;
91
+ return undefined;
92
+ }
93
+ export function normalizeNgrokDomain(value) {
94
+ return value.trim().replace(/^https?:\/\//i, "").split("/")[0].trim().toLowerCase();
95
+ }
96
+ /** Parse `GET /api/tunnels` into https tunnels. Pure and unit-tested. */
97
+ export function parseAgentTunnels(payload) {
98
+ if (typeof payload !== "object" || payload === null)
99
+ return [];
100
+ const tunnels = payload.tunnels;
101
+ if (!Array.isArray(tunnels))
102
+ return [];
103
+ const result = [];
104
+ for (const entry of tunnels) {
105
+ if (typeof entry !== "object" || entry === null)
106
+ continue;
107
+ const { public_url: publicUrl, proto } = entry;
108
+ if (typeof publicUrl !== "string" || typeof proto !== "string")
109
+ continue;
110
+ if (proto !== "https")
111
+ continue;
112
+ result.push({ publicUrl, proto });
113
+ }
114
+ return result;
115
+ }
116
+ async function fetchAgentTunnels() {
117
+ const response = await fetch(`${NGROK_AGENT_API}/tunnels`);
118
+ if (!response.ok)
119
+ throw new Error(`ngrok agent API returned ${response.status}`);
120
+ return parseAgentTunnels(await response.json());
121
+ }
122
+ /** Wait for an https tunnel whose host equals the saved domain. Throws on timeout or drift. */
123
+ export async function waitForAgentDomain(domain, options = {}) {
124
+ const timeoutMs = options.timeoutMs ?? 45_000;
125
+ const pollMs = options.pollMs ?? 1_000;
126
+ const deadline = Date.now() + timeoutMs;
127
+ let lastSeen;
128
+ for (;;) {
129
+ let tunnels = [];
130
+ try {
131
+ tunnels = await fetchAgentTunnels();
132
+ }
133
+ catch {
134
+ // Agent not up yet; keep polling until the deadline.
135
+ }
136
+ const match = tunnels.find((tunnel) => {
137
+ try {
138
+ return new URL(tunnel.publicUrl).hostname.toLowerCase() === domain.toLowerCase();
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ });
144
+ if (match)
145
+ return match.publicUrl;
146
+ if (tunnels.length > 0) {
147
+ lastSeen = tunnels.map((tunnel) => tunnel.publicUrl).join(", ");
148
+ }
149
+ if (Date.now() >= deadline) {
150
+ throw new Error(lastSeen
151
+ ? `ngrok is serving ${lastSeen} instead of the saved domain ${domain}. Fix the domain (hearth ngrok setup) — refusing to serve a drifting URL.`
152
+ : `Timed out waiting for ngrok to serve ${domain}. Is the agent running with this domain and a valid authtoken?`);
153
+ }
154
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, pollMs));
155
+ }
156
+ }
157
+ export function startNgrokChild(options) {
158
+ mkdirSync(join(options.pidPath, ".."), { recursive: true, mode: 0o700 });
159
+ const child = spawn(options.binary, ["http", String(options.port), "--log=stdout", "--log-format=json"], {
160
+ stdio: ["ignore", "pipe", "pipe"],
161
+ windowsHide: true,
162
+ });
163
+ if (child.pid === undefined) {
164
+ throw new Error("ngrok child exited before a pid was assigned.");
165
+ }
166
+ const pid = child.pid;
167
+ try {
168
+ writeFileSync(options.pidPath, `${pid}\n`, { mode: 0o600 });
169
+ }
170
+ catch {
171
+ // Pidfile is advisory; the child reference is authoritative.
172
+ }
173
+ const forward = (chunk) => {
174
+ for (const line of String(chunk).split("\n")) {
175
+ const trimmed = line.trim();
176
+ if (!trimmed)
177
+ continue;
178
+ try {
179
+ const logged = JSON.parse(trimmed);
180
+ if (typeof logged.msg === "string") {
181
+ options.onLog(`${String(logged.lvl ?? "info")}: ${logged.msg}${logged.err ? ` (${String(logged.err)})` : ""}`);
182
+ continue;
183
+ }
184
+ }
185
+ catch {
186
+ // Not JSON; fall through to raw output.
187
+ }
188
+ options.onLog(trimmed);
189
+ }
190
+ };
191
+ child.stdout?.on("data", forward);
192
+ child.stderr?.on("data", forward);
193
+ return {
194
+ process: child,
195
+ pid,
196
+ stop: () => new Promise((resolveStop) => {
197
+ let settled = false;
198
+ const finish = () => {
199
+ if (!settled) {
200
+ settled = true;
201
+ resolveStop();
202
+ }
203
+ };
204
+ child.once("exit", finish);
205
+ child.once("error", finish);
206
+ try {
207
+ child.kill("SIGTERM");
208
+ }
209
+ catch {
210
+ finish();
211
+ }
212
+ setTimeout(() => {
213
+ try {
214
+ child.kill("SIGKILL");
215
+ }
216
+ catch {
217
+ // Already gone.
218
+ }
219
+ finish();
220
+ }, 10_000).unref?.();
221
+ }),
222
+ };
223
+ }
224
+ /** Best-effort liveness check for a serve-managed ngrok child. */
225
+ export function isNgrokChildAlive(pidPath) {
226
+ let pid;
227
+ try {
228
+ const parsed = Number.parseInt(readFileSync(pidPath, "utf8").trim(), 10);
229
+ if (Number.isInteger(parsed) && parsed > 0)
230
+ pid = parsed;
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ if (pid === undefined)
236
+ return undefined;
237
+ try {
238
+ process.kill(pid, 0);
239
+ return pid;
240
+ }
241
+ catch {
242
+ return undefined;
243
+ }
244
+ }
245
+ /** Serve-time fail-fast validation. Returns blocking problem strings. */
246
+ export function validateManagedNgrok(state) {
247
+ const problems = [];
248
+ if (!state.domain) {
249
+ problems.push("`serve --ngrok` needs a saved domain. Run `hearth ngrok setup` first.");
250
+ return problems;
251
+ }
252
+ let publicHost = "";
253
+ try {
254
+ publicHost = new URL(state.publicBaseUrl).hostname.toLowerCase();
255
+ }
256
+ catch {
257
+ problems.push(`server.publicBaseUrl ${JSON.stringify(state.publicBaseUrl)} is not a valid URL.`);
258
+ }
259
+ if (publicHost && publicHost !== state.domain.toLowerCase()) {
260
+ problems.push(`tunnel.domain ${state.domain} does not match publicBaseUrl host ${publicHost}. ` +
261
+ "Re-run `hearth ngrok setup` to resync.");
262
+ }
263
+ if (!state.trustProxy) {
264
+ problems.push("Managed ngrok needs server.trustProxy=true. Re-run `hearth ngrok setup`.");
265
+ }
266
+ if (!state.binary) {
267
+ problems.push(ngrokInstallHint());
268
+ }
269
+ return problems;
270
+ }
@@ -185,9 +185,36 @@ function parseJsoncConfig(source, filePath) {
185
185
  return hearthConfigSchema.parse(value);
186
186
  }
187
187
  catch (error) {
188
- throw fileError("read", filePath, error);
188
+ throw fileError("read", filePath, withLegacyTunnelHint(value, error));
189
189
  }
190
190
  }
191
+ /**
192
+ * One-time breaking migration aid: pre-ngrok configs carry a `tls` section
193
+ * and cloudflared-era `tunnel` keys that strict validation rejects. Detect
194
+ * them and tell the owner exactly how to migrate instead of dumping Zod.
195
+ */
196
+ function withLegacyTunnelHint(value, error) {
197
+ if (!(error instanceof z.ZodError))
198
+ return error;
199
+ if (typeof value !== "object" || value === null)
200
+ return error;
201
+ const record = value;
202
+ const tunnel = record.tunnel;
203
+ const stale = [];
204
+ if ("tls" in record)
205
+ stale.push("`tls`");
206
+ if (tunnel !== null && typeof tunnel === "object" && tunnel !== undefined
207
+ && (tunnel.provider === "cloudflared" || "hostname" in tunnel || "tunnelId" in tunnel)) {
208
+ stale.push("cloudflared-era `tunnel` keys");
209
+ }
210
+ if (stale.length === 0)
211
+ return error;
212
+ const reason = error instanceof Error ? error.message : String(error);
213
+ return new Error(`${reason}\nYour config predates ngrok-only Hearth (stale ${stale.join(" and ")}). ` +
214
+ "To migrate: delete the `tls` section from your config.jsonc, re-run " +
215
+ "`hearth ngrok setup --domain <your-static-domain>` to recreate `tunnel`, " +
216
+ "and restart serve (auth.json is untouched).");
217
+ }
191
218
  function serializeConfig(config) {
192
219
  return `${JSON.stringify(config, null, 2)}\n`;
193
220
  }
@@ -30,6 +30,13 @@ Run `hearth init` to create both files. `hearth config set publicBaseUrl
30
30
  "allowedHosts": [],
31
31
  "trustProxy": false,
32
32
  },
33
+ "tunnel": {
34
+ // Managed per-PC ngrok tunnel. `hearth ngrok setup` writes this section,
35
+ // keeps publicBaseUrl in sync, and enables trustProxy. The ngrok
36
+ // authtoken lives in ngrok's own config, never here.
37
+ "provider": "none",
38
+ "domain": null,
39
+ },
33
40
  "workspaces": {
34
41
  "allowedRoots": ["~/personal", "~/work"],
35
42
  "worktreeRoot": "~/.hearth/worktrees",
package/docs/gotchas.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  This page collects the setup issues users are most likely to hit.
4
4
 
5
+ ## npm Install Warnings (deprecated, funding, allow-scripts)
6
+
7
+ These are noise, with one exception:
8
+
9
+ - `deprecated prebuild-install` / `node-domexception`: transitive dependencies
10
+ of `better-sqlite3` and the fetch stack. Harmless; they come from upstream
11
+ packages, not Hearth.
12
+ - `looking for funding`: informational. Ignore it.
13
+ - `allow-scripts ... not yet covered`: npm 11+ gates install scripts. Hearth
14
+ needs its own postinstall plus the `better-sqlite3` and `node-pty` native
15
+ builds. If `hearth doctor` later reports `SQLite native dependency` as
16
+ anything but `ok`, reinstall with the scripts pre-approved:
17
+
18
+ ```bash
19
+ npm install -g --allow-scripts="@sunshinelife83/hearth,better-sqlite3,node-pty" @sunshinelife83/hearth
20
+ ```
21
+
22
+ `./install.sh` already passes these flags on the npm path.
23
+
5
24
  ## `hearth` Command Not Found
6
25
 
7
26
  Use `npx`:
@@ -50,45 +69,66 @@ Release starts run a native dependency check before launching.
50
69
  Use the origin for setup:
51
70
 
52
71
  ```text
53
- https://your-tunnel-host.example.com
72
+ https://xxx.ngrok-free.dev
54
73
  ```
55
74
 
56
75
  Use the MCP endpoint in the client:
57
76
 
58
77
  ```text
59
- https://your-tunnel-host.example.com/mcp
78
+ https://xxx.ngrok-free.dev/mcp
60
79
  ```
61
80
 
62
81
  If you saved the wrong value:
63
82
 
64
83
  ```bash
65
- npx @sunshinelife83/hearth config set publicBaseUrl https://your-tunnel-host.example.com
84
+ npx @sunshinelife83/hearth config set publicBaseUrl https://xxx.ngrok-free.dev
66
85
  ```
67
86
 
68
87
  ## Reverse Proxy `/mcp` Returns 404
69
- Proxy the whole Hearth server from the root of your tunnel or reverse proxy.
70
-
71
- Do not mount only `/mcp`: some proxies strip a configured mount path before
72
- proxying to the local service, so a public `/mcp` request can otherwise arrive
73
- at Hearth as `/`. Hearth also needs OAuth routes outside `/mcp`, so serving
74
- the whole local origin is the correct setup.
88
+ ngrok forwards the whole origin, so this only bites manual setups: never
89
+ mount only `/mcp` in front of Hearth. Hearth needs its OAuth routes outside
90
+ `/mcp`, and a path-scoped mount can strip `/mcp` before the request arrives
91
+ (arriving as `/` and failing).
75
92
 
76
93
  ## Tunnel URL Changed
77
94
 
78
- Temporary tunnels often change URLs between runs.
79
-
80
- Update the configured URL:
95
+ It should not: the managed ngrok domain is static. If the served domain and
96
+ the saved one disagree, something replaced your agent or domain:
81
97
 
82
98
  ```bash
83
- npx @sunshinelife83/hearth config set publicBaseUrl https://new-tunnel.example.com
99
+ npx @sunshinelife83/hearth ngrok status
100
+ npx @sunshinelife83/hearth doctor
84
101
  ```
85
102
 
86
- For a stable URL:
103
+ If you intentionally moved to a new domain, resync and restart:
87
104
 
88
105
  ```bash
89
- npx @sunshinelife83/hearth config set publicBaseUrl https://hearth.example.com
106
+ npx @sunshinelife83/hearth ngrok setup --domain https://new-domain.ngrok-free.dev
107
+ npx @sunshinelife83/hearth serve --ngrok
90
108
  ```
91
109
 
110
+ ## Managed ngrok Problems
111
+
112
+ Run `hearth ngrok status` and `hearth doctor` first; most answers are there.
113
+
114
+ - **`ngrok was not found`**: install the binary (see `hearth ngrok setup`
115
+ output), then re-run setup.
116
+ - **No authtoken**: run `ngrok config add-authtoken <your-token>` once, then
117
+ re-run setup.
118
+ - **Agent serving a different domain than saved**: another agent (or account)
119
+ is running. `serve --ngrok` refuses to start in this state — stop the other
120
+ agent, then restart. Never silence this check: serving under a drifting URL
121
+ invalidates OAuth sessions.
122
+ - **Hostname unreachable**: the ngrok agent is down. Restart
123
+ `hearth serve --ngrok` and check `hearth ngrok status`.
124
+ - **Hostname resolves but Hearth rejects with 403**: the tunnel domain and
125
+ `server.publicBaseUrl` disagree. Re-run setup for the right domain.
126
+ - **Owner approval page shows an ngrok warning first**: expected on the free
127
+ tier — click through the interstitial once, then approve normally. API
128
+ calls are unaffected.
129
+ - **`/dashboard` unreachable remotely**: intentional. Remote dashboard and
130
+ landing requests 404 by Host; the dashboard stays localhost-only.
131
+
92
132
  ## Host Header Or 403 Problems
93
133
 
94
134
  Hearth derives allowed hosts from the configured public URL.
@@ -99,8 +139,8 @@ Run:
99
139
  npx @sunshinelife83/hearth doctor
100
140
  ```
101
141
 
102
- Confirm the public URL hostname appears in allowed hosts. If you changed tunnel
103
- URLs, update `publicBaseUrl`.
142
+ Confirm the public URL hostname appears in allowed hosts. If you replaced the
143
+ managed domain, resync with `hearth ngrok setup`.
104
144
 
105
145
  For intentional local debugging only, set `server.allowedHosts` to `["*"]` in
106
146
  `~/.hearth/config.jsonc`.
package/docs/security.md CHANGED
@@ -57,7 +57,7 @@ discover OAuth metadata and connect to the correct resource.
57
57
  The value should be the origin only:
58
58
 
59
59
  ```text
60
- https://your-tunnel-host.example.com
60
+ https://xxx.ngrok-free.dev
61
61
  ```
62
62
 
63
63
  Do not include `/mcp` in `server.publicBaseUrl`.
@@ -82,16 +82,38 @@ Future direction: a per-device keypair whose private key never leaves the PC
82
82
  `hearth id`, `/healthz`, the dashboard, and approvals, so audit entries can
83
83
  bind to an unclonable key.
84
84
 
85
+ ## Managed Tunnel (ngrok)
86
+
87
+ `hearth ngrok setup` binds this PC to its static ngrok domain. Trust
88
+ implications:
89
+
90
+ - **Only AI endpoints are reachable remotely.** Hearth refuses the landing
91
+ page and dashboard for tunnel-domain Hosts (404); `/mcp`, OAuth,
92
+ discovery, and health serve normally. The dashboard remains a
93
+ localhost-only surface behind the Owner password.
94
+ - **The authtoken is ngrok's secret, not Hearth's.** It lives in ngrok's own
95
+ config (`ngrok config add-authtoken`); Hearth only saves the public domain.
96
+ Anyone holding your authtoken can tunnel as you: guard it, and rotate it
97
+ from the ngrok dashboard if exposed.
98
+ - **Rate limiting sees real IPs.** Setup enables `server.trustProxy` so
99
+ `x-forwarded-for` feeds rate-limit keys; without it every client would
100
+ share the tunnel's localhost key.
101
+ - **ngrok sees TLS plaintext** between its edge and your origin by design
102
+ (that is how the tunnel works). Hearth's OAuth Owner approval still
103
+ gates the MCP endpoint.
104
+ - **Free-tier interstitial.** ngrok shows a browser warning page on HTML
105
+ traffic; API calls pass through untouched.
106
+
85
107
  ## Tunnels
86
108
 
87
- Hearth does not manage tunnels. Your tunnel or reverse proxy should point to:
109
+ ngrok is the only supported remote path (`hearth serve --ngrok`). The agent
110
+ forwards to:
88
111
 
89
112
  ```text
90
113
  http://127.0.0.1:7176
91
114
  ```
92
115
 
93
- Prefer adding an identity-aware proxy in front of public tunnels. Hearth
94
- OAuth still protects the MCP endpoint, but the tunnel URL should not be
116
+ Hearth OAuth still protects the MCP endpoint, but the tunnel URL should not be
95
117
  treated as a secret.
96
118
 
97
119
  ## Shell Access