@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.
- package/README.md +24 -24
- package/dist/cli.js +303 -102
- package/dist/config-schema.js +11 -10
- package/dist/config.js +13 -4
- package/dist/dashboard/app.js +1 -1
- package/dist/dashboard/landing.html +2 -2
- package/dist/logger.js +0 -3
- package/dist/server.js +18 -9
- package/dist/tunnel-ngrok.js +270 -0
- package/dist/user-config.js +28 -1
- package/docs/configuration.md +7 -0
- package/docs/gotchas.md +57 -17
- package/docs/security.md +26 -4
- package/docs/setup.md +52 -23
- package/package.json +1 -1
- package/schema/v1/hearth.schema.json +10 -27
package/dist/config-schema.js
CHANGED
|
@@ -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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 = "";
|
package/dist/dashboard/app.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/user-config.js
CHANGED
|
@@ -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
|
}
|
package/docs/configuration.md
CHANGED
|
@@ -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://
|
|
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://
|
|
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://
|
|
84
|
+
npx @sunshinelife83/hearth config set publicBaseUrl https://xxx.ngrok-free.dev
|
|
66
85
|
```
|
|
67
86
|
|
|
68
87
|
## Reverse Proxy `/mcp` Returns 404
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
99
|
+
npx @sunshinelife83/hearth ngrok status
|
|
100
|
+
npx @sunshinelife83/hearth doctor
|
|
84
101
|
```
|
|
85
102
|
|
|
86
|
-
|
|
103
|
+
If you intentionally moved to a new domain, resync and restart:
|
|
87
104
|
|
|
88
105
|
```bash
|
|
89
|
-
npx @sunshinelife83/hearth
|
|
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
|
|
103
|
-
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
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
|