@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/README.md
CHANGED
|
@@ -81,7 +81,7 @@ hearth init
|
|
|
81
81
|
Non-interactive (scripts, second machine):
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
hearth init --yes --use both --roots ~/personal,~/work --public-url https://
|
|
84
|
+
hearth init --yes --use both --roots ~/personal,~/work --public-url https://xxx.ngrok-free.dev
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
Or one line from a checkout (checks Node, packs, installs, then runs setup
|
|
@@ -103,7 +103,8 @@ During setup, Hearth asks for:
|
|
|
103
103
|
- which Coding Agents Hearth may use
|
|
104
104
|
|
|
105
105
|
If you select ChatGPT, setup also asks which local project folders it may open
|
|
106
|
-
and for your public HTTPS base URL
|
|
106
|
+
and for your public HTTPS base URL — your static ngrok domain (see
|
|
107
|
+
[Managed Tunnel](#managed-tunnel) below).
|
|
107
108
|
A Coding Agents-only setup asks
|
|
108
109
|
neither question: local commands use the current Git project, or the current
|
|
109
110
|
directory outside a repository.
|
|
@@ -111,11 +112,11 @@ directory outside a repository.
|
|
|
111
112
|
Use the public origin without `/mcp` during setup:
|
|
112
113
|
|
|
113
114
|
```text
|
|
114
|
-
https://
|
|
115
|
+
https://xxx.ngrok-free.dev
|
|
115
116
|
```
|
|
116
117
|
|
|
117
118
|
You will configure your MCP client with the public `/mcp` URL after setup.
|
|
118
|
-
Run `hearth serve` when using ChatGPT. For Coding Agents, setup prints a
|
|
119
|
+
Run `hearth serve --ngrok` when using ChatGPT. For Coding Agents, setup prints a
|
|
119
120
|
`skills` command and lets the Skills CLI handle installation.
|
|
120
121
|
|
|
121
122
|
When the client connects, Hearth opens an Owner password approval page. Enter
|
|
@@ -140,10 +141,10 @@ The default local endpoint is:
|
|
|
140
141
|
http://127.0.0.1:7176/mcp
|
|
141
142
|
```
|
|
142
143
|
|
|
143
|
-
Most users should connect through
|
|
144
|
+
Most users should connect through the managed ngrok tunnel:
|
|
144
145
|
|
|
145
146
|
```text
|
|
146
|
-
https://
|
|
147
|
+
https://xxx.ngrok-free.dev/mcp
|
|
147
148
|
```
|
|
148
149
|
|
|
149
150
|
ChatGPT, Claude, and generic MCP clients all use the same `/mcp` endpoint.
|
|
@@ -191,24 +192,24 @@ The dashboard performs no privileged execution of its own: it shows the same
|
|
|
191
192
|
policy-gated state the MCP surface sees, and agents/tasks are still driven
|
|
192
193
|
from your MCP client.
|
|
193
194
|
|
|
194
|
-
##
|
|
195
|
+
## Managed Tunnel
|
|
195
196
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
Hearth exposes this PC through ngrok — the only remote-access path. Each PC
|
|
198
|
+
gets its stable ngrok domain, and `hearth serve --ngrok` is server, URL, and
|
|
199
|
+
tunnel in one command:
|
|
199
200
|
|
|
200
201
|
```bash
|
|
201
|
-
|
|
202
|
-
hearth
|
|
202
|
+
ngrok config add-authtoken <your-token> # once; token from dashboard.ngrok.com
|
|
203
|
+
hearth ngrok setup --domain xxx.ngrok-free.dev
|
|
204
|
+
hearth serve --ngrok
|
|
205
|
+
hearth ngrok status # health: binary, auth, live domain match
|
|
206
|
+
hearth id # stable per-PC identity (hearth-xxxx...)
|
|
203
207
|
```
|
|
204
208
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
serves the ACME challenge path. Without an inbound route + domain, traffic
|
|
210
|
-
needs *some* relay — run your own (e.g. on your VPS), never one you don't
|
|
211
|
-
control.
|
|
209
|
+
Only AI endpoints (`/mcp`, OAuth, discovery, health) are reachable through
|
|
210
|
+
the tunnel: the landing page and dashboard 404 remotely by Host and stay
|
|
211
|
+
localhost-only. `hearth doctor` checks the binary, auth, domain match, and
|
|
212
|
+
child liveness.
|
|
212
213
|
|
|
213
214
|
## Mental Model
|
|
214
215
|
|
|
@@ -220,11 +221,10 @@ connected client like a trusted coding partner with access to your machine.
|
|
|
220
221
|
|
|
221
222
|
For a normal ChatGPT coding session:
|
|
222
223
|
|
|
223
|
-
1.
|
|
224
|
-
2.
|
|
225
|
-
3.
|
|
226
|
-
4.
|
|
227
|
-
5. Ask ChatGPT to open a project inside one of your allowed roots.
|
|
224
|
+
1. Run `hearth serve --ngrok`.
|
|
225
|
+
2. Connect the MCP client to your public `/mcp` URL.
|
|
226
|
+
3. Approve the connection with the Owner password.
|
|
227
|
+
4. Ask ChatGPT to open a project inside one of your allowed roots.
|
|
228
228
|
|
|
229
229
|
## Platform Support
|
|
230
230
|
|
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { generateOwnerToken, loadHearthFiles, setHearthConfigValue, setHearthCon
|
|
|
19
19
|
import { expandHomePath } from "./roots.js";
|
|
20
20
|
import { readReviewRef } from "./review-checkpoints.js";
|
|
21
21
|
import { shutdownHttpServer } from "./server-shutdown.js";
|
|
22
|
+
import { NGROK_AGENT_API, checkNgrokAuth, findNgrok, isNgrokChildAlive, ngrokInstallHint, ngrokPaths, ngrokVersion, normalizeNgrokDomain, parseAgentTunnels, startNgrokChild, validateManagedNgrok, validateNgrokDomain, waitForAgentDomain, } from "./tunnel-ngrok.js";
|
|
22
23
|
const require = createRequire(import.meta.url);
|
|
23
24
|
// Keep in sync with "engines.node" in package.json and the documented range.
|
|
24
25
|
const SUPPORTED_NODE_RANGE = ">=22.19 <27";
|
|
@@ -29,7 +30,7 @@ async function main(argv) {
|
|
|
29
30
|
switch (command) {
|
|
30
31
|
case "serve":
|
|
31
32
|
await ensureConfigured();
|
|
32
|
-
await serve();
|
|
33
|
+
await serve(args);
|
|
33
34
|
return;
|
|
34
35
|
case "mcp":
|
|
35
36
|
await runMcp();
|
|
@@ -49,15 +50,15 @@ async function main(argv) {
|
|
|
49
50
|
case "agents":
|
|
50
51
|
await runAgentsCommand(args);
|
|
51
52
|
return;
|
|
52
|
-
case "expose":
|
|
53
|
-
await runExpose();
|
|
54
|
-
return;
|
|
55
53
|
case "id":
|
|
56
54
|
runMachineId();
|
|
57
55
|
return;
|
|
58
56
|
case "connect":
|
|
59
57
|
await runConnect(args);
|
|
60
58
|
return;
|
|
59
|
+
case "ngrok":
|
|
60
|
+
await runNgrokCli(args);
|
|
61
|
+
return;
|
|
61
62
|
case "show-changes":
|
|
62
63
|
await runShowChanges(args);
|
|
63
64
|
return;
|
|
@@ -79,8 +80,8 @@ function normalizeCommand(command) {
|
|
|
79
80
|
|| command === "config"
|
|
80
81
|
|| command === "agents"
|
|
81
82
|
|| command === "show-changes"
|
|
82
|
-
|| command === "expose"
|
|
83
83
|
|| command === "connect"
|
|
84
|
+
|| command === "ngrok"
|
|
84
85
|
|| command === "id")
|
|
85
86
|
return command;
|
|
86
87
|
if (command === "help" || command === "--help" || command === "-h")
|
|
@@ -264,22 +265,22 @@ async function runInit({ force, yes, roots, publicUrl, use, providers }) {
|
|
|
264
265
|
}
|
|
265
266
|
else if (nonInteractive) {
|
|
266
267
|
if (!files.config.server.publicBaseUrl) {
|
|
267
|
-
throw new Error("hearth init --yes for ChatGPT requires --public-url https://
|
|
268
|
+
throw new Error("hearth init --yes for ChatGPT requires --public-url https://xxx.ngrok-free.dev (origin only, without /mcp).");
|
|
268
269
|
}
|
|
269
270
|
publicBaseUrl = normalizePublicBaseUrl(files.config.server.publicBaseUrl);
|
|
270
271
|
}
|
|
271
272
|
else {
|
|
272
273
|
prompts.note([
|
|
273
|
-
`Point
|
|
274
|
-
"
|
|
274
|
+
`Point ngrok at http://127.0.0.1:${port} (see \`hearth ngrok setup\`),`,
|
|
275
|
+
"then paste its public URL below.",
|
|
275
276
|
"",
|
|
276
|
-
"Example: https://your-
|
|
277
|
+
"Example: https://your-domain.ngrok-free.dev",
|
|
277
278
|
].join("\n"), "Connect ChatGPT");
|
|
278
279
|
publicBaseUrl = normalizePublicBaseUrl(await textPrompt({
|
|
279
280
|
message: files.config.server.publicBaseUrl
|
|
280
281
|
? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.server.publicBaseUrl}`
|
|
281
282
|
: "What public URL will ChatGPT connect to?",
|
|
282
|
-
placeholder: files.config.server.publicBaseUrl ?? "https://
|
|
283
|
+
placeholder: files.config.server.publicBaseUrl ?? "https://xxx.ngrok-free.dev",
|
|
283
284
|
defaultValue: files.config.server.publicBaseUrl ?? "",
|
|
284
285
|
validate: validateRequiredPublicBaseUrl,
|
|
285
286
|
}));
|
|
@@ -442,7 +443,7 @@ async function runMcp() {
|
|
|
442
443
|
const { runStdioServer } = await import("./stdio-server.js");
|
|
443
444
|
await runStdioServer(loadConfig());
|
|
444
445
|
}
|
|
445
|
-
function logServeBanner(config, localAgentProviders,
|
|
446
|
+
function logServeBanner(config, localAgentProviders, liveDomain) {
|
|
446
447
|
let machineId = "unknown";
|
|
447
448
|
try {
|
|
448
449
|
const { loadMachineIdentity } = require("./machine-id.js");
|
|
@@ -452,24 +453,28 @@ function logServeBanner(config, localAgentProviders, scheme) {
|
|
|
452
453
|
// Banner must never fail because identity storage is unavailable.
|
|
453
454
|
}
|
|
454
455
|
const publicMcpUrl = new URL("/mcp", config.publicBaseUrl).toString();
|
|
455
|
-
console.log(`hearth listening on
|
|
456
|
+
console.log(`hearth listening on http://${config.host}:${config.port}/mcp`);
|
|
456
457
|
console.log(`public MCP URL: ${publicMcpUrl}`);
|
|
458
|
+
if (liveDomain) {
|
|
459
|
+
console.log(`tunnel: ngrok ${liveDomain} live (managed — server, URL, and tunnel in one command)`);
|
|
460
|
+
}
|
|
461
|
+
else if (config.tunnel.provider === "ngrok" && config.tunnel.domain) {
|
|
462
|
+
console.log(`tunnel: ngrok ${config.tunnel.domain} saved (pass --ngrok to serve through it)`);
|
|
463
|
+
}
|
|
457
464
|
console.log(`machine: ${machineId} (hearth id, diagnostic label only — not a security boundary)`);
|
|
458
|
-
console.log(`dashboard:
|
|
465
|
+
console.log(`dashboard: http://${config.host}:${config.port}/dashboard`);
|
|
459
466
|
console.log(`public base url: ${config.publicBaseUrl}`);
|
|
460
467
|
console.log(`allowed roots: ${config.allowedRoots.join(", ")}`);
|
|
461
468
|
console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`);
|
|
462
469
|
if (config.allowedHosts.includes("*")) {
|
|
463
470
|
console.warn("warning: Host header allowlist is disabled because server.allowedHosts contains '*'");
|
|
464
471
|
}
|
|
465
|
-
if (scheme === "https")
|
|
466
|
-
console.log("tls: native termination from tls.certFile/tls.keyFile");
|
|
467
472
|
console.log("auth: Owner password approval required");
|
|
468
473
|
console.log(`logging: ${config.logging.level} ${config.logging.format}`);
|
|
469
474
|
console.log(`subagent providers: ${formatLocalAgentProviderStatusSummary(localAgentProviders)}`);
|
|
470
475
|
console.log("next: run `hearth connect` for ChatGPT / Claude / generic MCP steps");
|
|
471
476
|
}
|
|
472
|
-
async function serve() {
|
|
477
|
+
async function serve(argv = []) {
|
|
473
478
|
const sqliteStatus = checkSqliteNative();
|
|
474
479
|
if (sqliteStatus !== "ok") {
|
|
475
480
|
throw new Error([
|
|
@@ -482,33 +487,31 @@ async function serve() {
|
|
|
482
487
|
}
|
|
483
488
|
const { createServer } = await import("./server.js");
|
|
484
489
|
const config = loadConfig();
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
throw new Error("tls.certFile and tls.keyFile must be set together (or both left null).");
|
|
489
|
-
}
|
|
490
|
-
let httpServer;
|
|
491
|
-
if (tlsOn) {
|
|
492
|
-
const { readFileSync } = await import("node:fs");
|
|
493
|
-
const { createServer: createHttpsServer } = await import("node:https");
|
|
494
|
-
const { expandHomePath } = await import("./roots.js");
|
|
495
|
-
httpServer = createHttpsServer({
|
|
496
|
-
key: readFileSync(expandHomePath(config.tls.keyFile)),
|
|
497
|
-
cert: readFileSync(expandHomePath(config.tls.certFile)),
|
|
498
|
-
}, app).listen(config.port, config.host, () => {
|
|
499
|
-
logServeBanner(config, localAgentProviders, "https");
|
|
500
|
-
});
|
|
490
|
+
const useNgrok = argv.includes("--ngrok");
|
|
491
|
+
if (useNgrok && config.tunnel.provider !== "ngrok") {
|
|
492
|
+
throw new Error("`serve --ngrok` needs a saved ngrok domain. Run `hearth ngrok setup` first.");
|
|
501
493
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
logServeBanner(config, localAgentProviders, "http");
|
|
505
|
-
});
|
|
494
|
+
if (!useNgrok && config.tunnel.provider === "ngrok" && config.tunnel.domain) {
|
|
495
|
+
console.log(`tunnel: ngrok domain ${config.tunnel.domain} saved but --ngrok not passed; serving locally only.`);
|
|
506
496
|
}
|
|
497
|
+
const managedNgrok = useNgrok ? await startManagedNgrok(config) : undefined;
|
|
498
|
+
const { app, close, localAgentProviders } = createServer(config);
|
|
499
|
+
const httpServer = app.listen(config.port, config.host, () => {
|
|
500
|
+
logServeBanner(config, localAgentProviders, managedNgrok?.domain ?? null);
|
|
501
|
+
});
|
|
507
502
|
let shuttingDown = false;
|
|
508
503
|
const shutdown = async () => {
|
|
509
504
|
if (shuttingDown)
|
|
510
505
|
return;
|
|
511
506
|
shuttingDown = true;
|
|
507
|
+
if (managedNgrok) {
|
|
508
|
+
try {
|
|
509
|
+
await managedNgrok.stop();
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
console.error(`ngrok shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
512
515
|
await shutdownHttpServer(httpServer, close);
|
|
513
516
|
process.exit(0);
|
|
514
517
|
};
|
|
@@ -561,7 +564,9 @@ async function runDoctor({ fix }) {
|
|
|
561
564
|
console.log(`Subagents: ${config.subagents.enabled ? "enabled" : "disabled"}`);
|
|
562
565
|
console.log(`Subagent providers: ${formatLocalAgentProviderStatusSummary(providers)}`);
|
|
563
566
|
console.log(`Tool mode: ${config.toolMode}`);
|
|
567
|
+
console.log(`Tunnel: ${describeNgrokConfig(config.tunnel)}`);
|
|
564
568
|
const warnings = [];
|
|
569
|
+
warnings.push(...checkNgrokConfig({ ...config, trustProxy: config.oauth.trustProxy }));
|
|
565
570
|
if (config.allowedHosts.includes("*"))
|
|
566
571
|
warnings.push("server.allowedHosts contains '*': Host header checks are disabled (local debugging only).");
|
|
567
572
|
if (config.allowedRoots.length === 0)
|
|
@@ -604,13 +609,16 @@ async function runConnect(args) {
|
|
|
604
609
|
const lines = [
|
|
605
610
|
`machine: ${identity.id} (${identity.hostname})`,
|
|
606
611
|
`public MCP URL (use this in remote clients): ${publicMcpUrl}`,
|
|
612
|
+
config.tunnel.provider === "ngrok" && config.tunnel.domain
|
|
613
|
+
? `tunnel: managed ngrok (${config.tunnel.domain}) — \`hearth serve --ngrok\` starts everything`
|
|
614
|
+
: "tunnel: none managed (`hearth ngrok setup` provisions this PC's static domain)",
|
|
607
615
|
`local MCP URL: ${localMcpUrl}`,
|
|
608
616
|
`dashboard: http://${config.host}:${config.port}/dashboard`,
|
|
609
617
|
`tool mode: ${config.toolMode} (ChatGPT works with either; Claude Desktop prefers tools.mode claude)`,
|
|
610
618
|
"",
|
|
611
619
|
];
|
|
612
620
|
if (wanted === "all" || wanted === "chatgpt") {
|
|
613
|
-
lines.push("ChatGPT:", ` 1. hearth serve (keep running
|
|
621
|
+
lines.push("ChatGPT:", ` 1. hearth serve --ngrok (keep running; it serves this PC's static domain).`, ` 2. Add connector URL: ${publicMcpUrl}`, " 3. Approve with the Owner password from ~/.hearth/auth.json (hearth init prints it).", "");
|
|
614
622
|
}
|
|
615
623
|
if (wanted === "all" || wanted === "claude") {
|
|
616
624
|
lines.push("Claude (Desktop / Code with remote MCP):", ` 1. Ensure oauth.allowedRedirectHosts includes claude.ai (current: ${config.oauth.allowedRedirectHosts.join(", ")}).`, ` 2. Add MCP server URL: ${publicMcpUrl} and approve with the Owner password.`, " 3. For local-only use without OAuth: hearth token create claude-local, then `hearth mcp` as a stdio server with that bearer.", "");
|
|
@@ -622,6 +630,259 @@ async function runConnect(args) {
|
|
|
622
630
|
lines.push("The machine id is a diagnostic label, not a security boundary: copying stateDir copies the identity, so it cannot prove which PC answered or prevent cloning.");
|
|
623
631
|
console.log(lines.join("\n"));
|
|
624
632
|
}
|
|
633
|
+
function printNgrokHelp() {
|
|
634
|
+
console.log([
|
|
635
|
+
"Hearth ngrok (managed per-PC tunnel — the only remote-access path)",
|
|
636
|
+
"",
|
|
637
|
+
"Usage:",
|
|
638
|
+
" hearth ngrok setup [--domain <static-domain>] [--yes]",
|
|
639
|
+
" hearth ngrok status [--json]",
|
|
640
|
+
" hearth serve --ngrok Start the server with the tunnel (plain serve stays local-only)",
|
|
641
|
+
"",
|
|
642
|
+
"setup saves this PC's static ngrok domain (e.g. xxx.ngrok-free.dev),",
|
|
643
|
+
"syncs server.publicBaseUrl, and enables server.trustProxy. Your ngrok",
|
|
644
|
+
"authtoken stays in ngrok's own config: run `ngrok config add-authtoken`",
|
|
645
|
+
"once if setup reports missing auth.",
|
|
646
|
+
].join("\n"));
|
|
647
|
+
}
|
|
648
|
+
async function runNgrokCli(args) {
|
|
649
|
+
const [subcommand, ...rest] = args;
|
|
650
|
+
if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
|
651
|
+
printNgrokHelp();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (subcommand === "status") {
|
|
655
|
+
await runNgrokStatus(rest.includes("--json"));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (subcommand === "setup") {
|
|
659
|
+
await runNgrokSetup(rest);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
throw new Error(`Unknown ngrok command: ${subcommand}. Usage: hearth ngrok <setup|status>`);
|
|
663
|
+
}
|
|
664
|
+
function describeNgrokConfig(tunnel) {
|
|
665
|
+
if (tunnel.provider !== "ngrok")
|
|
666
|
+
return "none (local-only until `hearth ngrok setup`)";
|
|
667
|
+
if (!tunnel.domain)
|
|
668
|
+
return "ngrok (incomplete — re-run `hearth ngrok setup`)";
|
|
669
|
+
return `ngrok ${tunnel.domain}`;
|
|
670
|
+
}
|
|
671
|
+
function checkNgrokConfig(config) {
|
|
672
|
+
const warnings = [];
|
|
673
|
+
if (config.tunnel.provider !== "ngrok")
|
|
674
|
+
return warnings;
|
|
675
|
+
const binary = findNgrok();
|
|
676
|
+
if (!binary) {
|
|
677
|
+
warnings.push("tunnel.provider is ngrok but no ngrok binary is on PATH.");
|
|
678
|
+
return warnings;
|
|
679
|
+
}
|
|
680
|
+
if (!config.tunnel.domain) {
|
|
681
|
+
warnings.push("Managed ngrok tunnel is incomplete (domain missing). Re-run `hearth ngrok setup`.");
|
|
682
|
+
return warnings;
|
|
683
|
+
}
|
|
684
|
+
let publicHost = "";
|
|
685
|
+
try {
|
|
686
|
+
publicHost = new URL(config.publicBaseUrl).hostname.toLowerCase();
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
// loadConfig already guarantees a parseable publicBaseUrl.
|
|
690
|
+
}
|
|
691
|
+
if (publicHost !== config.tunnel.domain.toLowerCase()) {
|
|
692
|
+
warnings.push(`tunnel.domain ${config.tunnel.domain} does not match publicBaseUrl host ${publicHost}. Re-run \`hearth ngrok setup\`.`);
|
|
693
|
+
}
|
|
694
|
+
if (!config.trustProxy) {
|
|
695
|
+
warnings.push("Managed ngrok needs server.trustProxy=true so rate limits see real client IPs. Re-run `hearth ngrok setup`.");
|
|
696
|
+
}
|
|
697
|
+
const childPid = isNgrokChildAlive(ngrokPaths(config.stateDir).pidPath);
|
|
698
|
+
if (childPid) {
|
|
699
|
+
warnings.push(`A serve-managed ngrok child (pid ${childPid}) looks alive while serve may not be running; restart serve --ngrok to reconcile.`);
|
|
700
|
+
}
|
|
701
|
+
return warnings;
|
|
702
|
+
}
|
|
703
|
+
async function runNgrokStatus(json) {
|
|
704
|
+
const files = loadHearthFiles();
|
|
705
|
+
if (!files.configExists || (!files.authExists && !process.env.HEARTH_OAUTH_OWNER_TOKEN)) {
|
|
706
|
+
throw new Error("Hearth is not configured. Run `hearth init` first, then `hearth ngrok setup`.");
|
|
707
|
+
}
|
|
708
|
+
const config = loadConfig();
|
|
709
|
+
const tunnel = config.tunnel;
|
|
710
|
+
if (tunnel.provider !== "ngrok" || !tunnel.domain) {
|
|
711
|
+
if (json) {
|
|
712
|
+
console.log(JSON.stringify({ provider: tunnel.provider, configured: false }));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
console.log("No managed ngrok tunnel. Run `hearth ngrok setup --domain <static-domain>` on this PC.");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const binary = findNgrok();
|
|
719
|
+
let version = "unknown";
|
|
720
|
+
if (binary) {
|
|
721
|
+
try {
|
|
722
|
+
version = ngrokVersion(binary);
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
// Version is advisory; the auth and agent checks below are authoritative.
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const auth = binary ? checkNgrokAuth(binary) : { ok: false, output: "ngrok not on PATH" };
|
|
729
|
+
const childPid = isNgrokChildAlive(ngrokPaths(config.stateDir).pidPath);
|
|
730
|
+
let agentUrl;
|
|
731
|
+
let agentError;
|
|
732
|
+
try {
|
|
733
|
+
const response = await fetch(`${NGROK_AGENT_API}/tunnels`);
|
|
734
|
+
if (!response.ok)
|
|
735
|
+
throw new Error(`agent API returned ${response.status}`);
|
|
736
|
+
const live = parseAgentTunnels(await response.json());
|
|
737
|
+
agentUrl = live.map((tunnel) => tunnel.publicUrl).join(", ") || undefined;
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
agentError = error instanceof Error ? error.message : String(error);
|
|
741
|
+
}
|
|
742
|
+
if (json) {
|
|
743
|
+
console.log(JSON.stringify({
|
|
744
|
+
provider: "ngrok",
|
|
745
|
+
configured: true,
|
|
746
|
+
domain: tunnel.domain,
|
|
747
|
+
publicMcpUrl: new URL("/mcp", config.publicBaseUrl).toString(),
|
|
748
|
+
ngrok: binary ? { binary, version, authOk: auth.ok } : null,
|
|
749
|
+
childPid: childPid ?? null,
|
|
750
|
+
agentUrl: agentUrl ?? null,
|
|
751
|
+
agentError: agentError ?? null,
|
|
752
|
+
}));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
console.log([
|
|
756
|
+
`tunnel: ngrok ${tunnel.domain}`,
|
|
757
|
+
`public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`,
|
|
758
|
+
`ngrok: ${binary ? `${binary} (${version}), auth ${auth.ok ? "ok" : "MISSING — run `ngrok config add-authtoken`"}` : "NOT FOUND on PATH"}`,
|
|
759
|
+
childPid ? `serve-managed child: running (pid ${childPid})` : "serve-managed child: not running (start with `hearth serve --ngrok`)",
|
|
760
|
+
agentUrl ? `agent serving: ${agentUrl}` : `agent: not reachable (${agentError ?? "is ngrok running?"})`,
|
|
761
|
+
].join("\n"));
|
|
762
|
+
}
|
|
763
|
+
function parseNgrokSetupArgs(args) {
|
|
764
|
+
const options = { yes: false };
|
|
765
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
766
|
+
const arg = args[index];
|
|
767
|
+
if (arg === "--yes" || arg === "-y")
|
|
768
|
+
options.yes = true;
|
|
769
|
+
else if (arg === "--domain")
|
|
770
|
+
options.domain = args[++index];
|
|
771
|
+
else if (arg.startsWith("--domain="))
|
|
772
|
+
options.domain = arg.slice("--domain=".length);
|
|
773
|
+
else
|
|
774
|
+
throw new Error(`Unknown ngrok setup option: ${arg}. Usage: hearth ngrok setup [--domain <static-domain>] [--yes]`);
|
|
775
|
+
}
|
|
776
|
+
return options;
|
|
777
|
+
}
|
|
778
|
+
async function runNgrokSetup(args) {
|
|
779
|
+
const options = parseNgrokSetupArgs(args);
|
|
780
|
+
const files = loadHearthFiles();
|
|
781
|
+
if (!files.configExists || (!files.authExists && !process.env.HEARTH_OAUTH_OWNER_TOKEN)) {
|
|
782
|
+
throw new Error("Hearth is not configured. Run `hearth init` first, then `hearth ngrok setup`.");
|
|
783
|
+
}
|
|
784
|
+
const config = loadConfig();
|
|
785
|
+
const binary = findNgrok();
|
|
786
|
+
if (!binary)
|
|
787
|
+
throw new Error(ngrokInstallHint());
|
|
788
|
+
try {
|
|
789
|
+
ngrokVersion(binary);
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
// Version is advisory; the auth check below is authoritative.
|
|
793
|
+
}
|
|
794
|
+
const auth = checkNgrokAuth(binary);
|
|
795
|
+
if (!auth.ok) {
|
|
796
|
+
throw new Error([
|
|
797
|
+
"ngrok has no authtoken configured.",
|
|
798
|
+
"Run `ngrok config add-authtoken <your-token>` once (token from https://dashboard.ngrok.com/get-started/your-authtoken), then re-run `hearth ngrok setup`.",
|
|
799
|
+
].join("\n"));
|
|
800
|
+
}
|
|
801
|
+
let domain = options.domain?.trim();
|
|
802
|
+
if (domain === undefined) {
|
|
803
|
+
if (options.yes || !input.isTTY || !output.isTTY) {
|
|
804
|
+
domain = config.tunnel.domain ?? undefined;
|
|
805
|
+
if (!domain)
|
|
806
|
+
throw new Error("Non-interactive setup needs --domain <static-domain>.");
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
prompts.note("Hearth binds this PC to its static ngrok domain (free accounts get one stable xxx.ngrok-free.dev). Only AI endpoints are reachable through it; the dashboard stays localhost-only.", "Managed ngrok");
|
|
810
|
+
domain = await textPrompt({
|
|
811
|
+
message: config.tunnel.domain
|
|
812
|
+
? `Which static ngrok domain should this PC serve? Press Enter to keep ${config.tunnel.domain}`
|
|
813
|
+
: "Which static ngrok domain should this PC serve?",
|
|
814
|
+
placeholder: "xxx.ngrok-free.dev",
|
|
815
|
+
defaultValue: config.tunnel.domain ?? "",
|
|
816
|
+
validate: validateNgrokDomain,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const domainError = validateNgrokDomain(domain);
|
|
821
|
+
if (domainError)
|
|
822
|
+
throw new Error(domainError);
|
|
823
|
+
const normalizedDomain = normalizeNgrokDomain(domain);
|
|
824
|
+
setHearthConfigValues([
|
|
825
|
+
{ path: ["tunnel", "provider"], value: "ngrok" },
|
|
826
|
+
{ path: ["tunnel", "domain"], value: normalizedDomain },
|
|
827
|
+
{ path: ["server", "publicBaseUrl"], value: `https://${normalizedDomain}` },
|
|
828
|
+
{ path: ["server", "trustProxy"], value: true },
|
|
829
|
+
]);
|
|
830
|
+
const lines = [
|
|
831
|
+
`Domain: ${normalizedDomain}`,
|
|
832
|
+
`Public MCP URL: https://${normalizedDomain}/mcp`,
|
|
833
|
+
"server.trustProxy was enabled so rate limits see real client IPs via x-forwarded-for.",
|
|
834
|
+
"Note: ngrok free shows a browser interstitial page on HTML traffic; API calls are unaffected. The Owner approval page needs one click-through.",
|
|
835
|
+
];
|
|
836
|
+
if (options.yes || !input.isTTY || !output.isTTY) {
|
|
837
|
+
console.log(["Hearth ngrok is ready", ...lines].join("\n"));
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
prompts.note(lines.join("\n"), "Hearth ngrok is ready");
|
|
841
|
+
prompts.outro("Run `hearth serve --ngrok` — server and tunnel start together. `hearth ngrok status` checks health.");
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Validate managed-ngrok config and start the supervised agent.
|
|
846
|
+
* Fail-fast: an ngrok setup that cannot serve the saved domain must stop
|
|
847
|
+
* `serve`, never leave Hearth reachable locally but dark publicly.
|
|
848
|
+
*/
|
|
849
|
+
async function startManagedNgrok(config) {
|
|
850
|
+
const binary = findNgrok();
|
|
851
|
+
const problems = validateManagedNgrok({
|
|
852
|
+
domain: config.tunnel.domain,
|
|
853
|
+
publicBaseUrl: config.publicBaseUrl,
|
|
854
|
+
trustProxy: config.oauth.trustProxy,
|
|
855
|
+
binary,
|
|
856
|
+
});
|
|
857
|
+
if (problems.length > 0) {
|
|
858
|
+
throw new Error(["Managed ngrok is misconfigured:", ...problems.map((problem) => ` - ${problem}`)].join("\n"));
|
|
859
|
+
}
|
|
860
|
+
if (binary && !checkNgrokAuth(binary).ok) {
|
|
861
|
+
throw new Error("ngrok has no authtoken configured. Run `ngrok config add-authtoken <your-token>`, then restart serve.");
|
|
862
|
+
}
|
|
863
|
+
const paths = ngrokPaths(config.stateDir);
|
|
864
|
+
console.log(`tunnel: starting managed ngrok for ${config.tunnel.domain} ...`);
|
|
865
|
+
const supervised = startNgrokChild({
|
|
866
|
+
binary: binary,
|
|
867
|
+
port: config.port,
|
|
868
|
+
pidPath: paths.pidPath,
|
|
869
|
+
onLog: (line) => console.error(`[ngrok] ${line}`),
|
|
870
|
+
});
|
|
871
|
+
supervised.process.once("exit", (code) => {
|
|
872
|
+
if (code !== 0 && code !== null) {
|
|
873
|
+
console.error(`[ngrok] agent exited with code ${code}; public URL is dark while serve keeps running locally. Restart serve --ngrok or run \`hearth ngrok status\`.`);
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
try {
|
|
877
|
+
await waitForAgentDomain(config.tunnel.domain);
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
await supervised.stop().catch(() => undefined);
|
|
881
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
882
|
+
}
|
|
883
|
+
console.log(`tunnel: ngrok serving https://${config.tunnel.domain} (static domain confirmed live)`);
|
|
884
|
+
return { stop: supervised.stop, domain: config.tunnel.domain };
|
|
885
|
+
}
|
|
625
886
|
function runConfigCommand(args) {
|
|
626
887
|
const [subcommand, key, ...rest] = args;
|
|
627
888
|
const files = loadHearthFiles();
|
|
@@ -649,76 +910,17 @@ function runMachineId() {
|
|
|
649
910
|
console.log(JSON.stringify(identity, null, 2));
|
|
650
911
|
});
|
|
651
912
|
}
|
|
652
|
-
/**
|
|
653
|
-
* Relay-free exposure report: tells the owner exactly what stands between
|
|
654
|
-
* this PC and a direct public URL, without routing through any tunnel
|
|
655
|
-
* service. Honest by design: a public URL needs (1) an inbound route to this
|
|
656
|
-
* machine and (2) a domain with a valid certificate. Hearth automates
|
|
657
|
-
* everything after those two owner-provided prerequisites.
|
|
658
|
-
*/
|
|
659
|
-
async function runExpose() {
|
|
660
|
-
const config = loadConfig();
|
|
661
|
-
const { loadMachineIdentity } = await import("./machine-id.js");
|
|
662
|
-
const identity = loadMachineIdentity(config.stateDir);
|
|
663
|
-
const { execFileSync } = await import("node:child_process");
|
|
664
|
-
const { existsSync } = await import("node:fs");
|
|
665
|
-
const { expandHomePath } = await import("./roots.js");
|
|
666
|
-
let publicIp;
|
|
667
|
-
try {
|
|
668
|
-
const out = execFileSync("dig", ["+short", "+time=5", "myip.opendns.com", "@resolver1.opendns.com"], {
|
|
669
|
-
encoding: "utf8",
|
|
670
|
-
timeout: 10_000,
|
|
671
|
-
}).trim().split("\n").pop()?.trim();
|
|
672
|
-
if (out && /^[0-9a-fA-F.:]+$/.test(out))
|
|
673
|
-
publicIp = out;
|
|
674
|
-
}
|
|
675
|
-
catch {
|
|
676
|
-
// dig absent or blocked: report without a public IP guess.
|
|
677
|
-
}
|
|
678
|
-
const certSet = Boolean(config.tls.certFile && config.tls.keyFile);
|
|
679
|
-
const certPresent = certSet
|
|
680
|
-
&& existsSync(expandHomePath(config.tls.certFile))
|
|
681
|
-
&& existsSync(expandHomePath(config.tls.keyFile));
|
|
682
|
-
const publicUrl = new URL(config.publicBaseUrl);
|
|
683
|
-
const directHttps = publicUrl.protocol === "https:" && certSet;
|
|
684
|
-
const lines = [
|
|
685
|
-
`machine: ${identity.id} (${identity.hostname}, ${identity.platform}/${identity.arch})`,
|
|
686
|
-
`bind: ${config.host}:${config.port} (hearth serve)`,
|
|
687
|
-
`public base url: ${config.publicBaseUrl}`,
|
|
688
|
-
`public MCP URL (keep one URL per PC — reusing it elsewhere splits approvals and audit): ${new URL("/mcp", config.publicBaseUrl).toString()}`,
|
|
689
|
-
`public IP seen from here: ${publicIp ?? "unknown (dig unavailable or blocked)"}`,
|
|
690
|
-
`native TLS: ${!certSet ? "off (tls.certFile/tls.keyFile unset)" : certPresent ? "cert + key present" : "CONFIGURED BUT FILES MISSING"}`,
|
|
691
|
-
`ACME webroot: ${config.tls.acmeDir ?? "unset"}`,
|
|
692
|
-
"",
|
|
693
|
-
"OAuth tokens are bound to this machine's resource URL and Owner password.",
|
|
694
|
-
"Run `hearth connect` for ChatGPT / Claude / generic MCP steps.",
|
|
695
|
-
"",
|
|
696
|
-
directHttps && certPresent
|
|
697
|
-
? "status: DIRECT — publicBaseUrl is https and TLS material is present. Forward TCP 443 to this machine, point your domain's A/AAAA record at the public IP, restart serve."
|
|
698
|
-
: "status: NOT directly reachable — to drop the relay you need:",
|
|
699
|
-
...(!directHttps || !certPresent ? [
|
|
700
|
-
" 1. A domain whose A/AAAA record points at this machine (public IP above).",
|
|
701
|
-
" 2. TCP 443 (and TCP 80 for issuance) forwarded to this machine.",
|
|
702
|
-
" 3. Certificates, e.g.: certbot certonly --webroot -w <dir> -d <domain>",
|
|
703
|
-
" with tls.acmeDir set to <dir> so Hearth serves the HTTP-01 challenge.",
|
|
704
|
-
" 4. tls.certFile/tls.keyFile set to the issued files,",
|
|
705
|
-
" hearth config set publicBaseUrl https://<domain>, then restart serve.",
|
|
706
|
-
] : []),
|
|
707
|
-
"",
|
|
708
|
-
"Without an inbound route + domain, some relay must carry the traffic;",
|
|
709
|
-
"that relay can be your own VPS — never a third party you don't control.",
|
|
710
|
-
];
|
|
711
|
-
console.log(lines.join("\n"));
|
|
712
|
-
}
|
|
713
913
|
function printHelp() {
|
|
714
914
|
console.log([
|
|
715
915
|
"Hearth",
|
|
716
916
|
"",
|
|
717
917
|
"Usage:",
|
|
718
918
|
" hearth Run first-time setup if needed, then start the server",
|
|
719
|
-
" hearth serve
|
|
919
|
+
" hearth serve [--ngrok] Start the server (add --ngrok to also start the managed tunnel)",
|
|
720
920
|
" hearth init [--force] [--yes --use chatgpt|coding-agents|both --roots <csv> --public-url <url> --providers <csv>]",
|
|
721
921
|
" hearth doctor [--fix] Show config, runtime, and native dependency status",
|
|
922
|
+
" hearth ngrok setup [--domain <static-domain>] [--yes]",
|
|
923
|
+
" hearth ngrok status [--json] Managed per-PC ngrok tunnel",
|
|
722
924
|
" hearth connect [chatgpt|claude|generic] Print copy-paste MCP connection steps for this PC",
|
|
723
925
|
" hearth config get Print persisted config",
|
|
724
926
|
" hearth config set publicBaseUrl <url|null> (origin only, without /mcp)",
|
|
@@ -728,7 +930,6 @@ function printHelp() {
|
|
|
728
930
|
" hearth agents continue <id> [--model <model>] [--effort <level>] <prompt>",
|
|
729
931
|
" hearth agents show <id>",
|
|
730
932
|
" hearth agents daemon <status|stop|logs>",
|
|
731
|
-
" hearth expose Relay-free exposure report for this PC (own domain + TLS)",
|
|
732
933
|
" hearth id Print this machine's stable Hearth identity",
|
|
733
934
|
" hearth -v, --version Print the installed version",
|
|
734
935
|
"",
|
|
@@ -739,7 +940,7 @@ function printHelp() {
|
|
|
739
940
|
" hearth connect",
|
|
740
941
|
"",
|
|
741
942
|
"For temporary tunnels:",
|
|
742
|
-
" hearth config set publicBaseUrl https://
|
|
943
|
+
" hearth config set publicBaseUrl https://xxx.ngrok-free.dev",
|
|
743
944
|
" hearth serve",
|
|
744
945
|
].join("\n"));
|
|
745
946
|
}
|
|
@@ -1029,7 +1230,7 @@ function validatePublicBaseUrl(value) {
|
|
|
1029
1230
|
: "Use an http or https URL.";
|
|
1030
1231
|
}
|
|
1031
1232
|
catch {
|
|
1032
|
-
return "Enter a valid URL, for example https://
|
|
1233
|
+
return "Enter a valid URL, for example https://xxx.ngrok-free.dev.";
|
|
1033
1234
|
}
|
|
1034
1235
|
}
|
|
1035
1236
|
function assertSupportedNode() {
|