@huynle/hub 0.9.1 → 0.9.2
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/CHANGELOG.md +13 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +6 -6
- package/packages/cli/package.json +1 -1
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/webhooks.ts +5 -2
- package/src/central/config.ts +24 -2
- package/src/central/http.ts +1 -0
- package/src/central/push-target.ts +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.2
|
|
4
|
+
|
|
5
|
+
Webhooks can reach a named receiver on your own network without opening the hub to every private address. No
|
|
6
|
+
migration is needed.
|
|
7
|
+
|
|
8
|
+
### For operators
|
|
9
|
+
- **`agents.webhooks.allowPrivateHosts`** in `hub.json`: a list of exact hostnames whose `https://` webhook URLs may
|
|
10
|
+
resolve to private addresses (a LAN-hosted app behind split DNS, such as a Brain on the same cluster). Everything
|
|
11
|
+
else keeps the public-host rule: no `http://`, no IPs, no other hosts or subdomains, and the check still runs before
|
|
12
|
+
every send, and webhooks to a listed host must use port 443. Use it instead of `allowPrivate`, which opens private
|
|
13
|
+
targets for every webhook on the hub. In Kubernetes, also allow egress to the receiver's private address.
|
|
14
|
+
[docs/launches.md → Where webhooks may go](docs/launches.md#where-webhooks-may-go)
|
|
15
|
+
|
|
3
16
|
## 0.9.1
|
|
4
17
|
|
|
5
18
|
**+ launch node on Windows.** `hubctl` installs `hub node` as a per-user logon task (Task Scheduler: no admin, no
|
package/README.md
CHANGED
|
@@ -1995,6 +1995,7 @@ in the file).
|
|
|
1995
1995
|
| `agents.never` | permission patterns (`action[:glob]`) no agent may ever approve, for every user, checked before any grant; see [Agent actions and grants](#agent-actions-and-grants) |
|
|
1996
1996
|
| `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60) and `lostAfterMinutes?` (0–1440 or false, default 5: a running launch the same node instance stops reporting, with its session gone, ends lost; 0/false: never); see [docs/launches.md](docs/launches.md#placement-and-caps) |
|
|
1997
1997
|
| `agents.webhooks.allowPrivate` | `true` lets webhooks target `http://`, loopback and private hosts (self-hosted receivers); see [docs/launches.md](docs/launches.md#where-webhooks-may-go) |
|
|
1998
|
+
| `agents.webhooks.allowPrivateHosts` | exact hostnames (at most 50) whose `https://` webhook URLs may resolve to private addresses, e.g. a LAN-hosted app behind split DNS; nothing else is opened; see [docs/launches.md](docs/launches.md#where-webhooks-may-go) |
|
|
1998
1999
|
|
|
1999
2000
|
Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
|
|
2000
2001
|
defaults. Speech: phone settings > hub.json > env vars. Values are checked like the phone's speech
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Huy Le",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-claude-code",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -7,7 +7,7 @@ import { createInterface } from "readline";
|
|
|
7
7
|
// packages/cli/package.json
|
|
8
8
|
var package_default = {
|
|
9
9
|
name: "@huynle/hub-cli",
|
|
10
|
-
version: "0.9.
|
|
10
|
+
version: "0.9.2",
|
|
11
11
|
private: true,
|
|
12
12
|
description: "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
|
|
13
13
|
license: "MIT",
|
|
@@ -9227,7 +9227,7 @@ import { arch as osArch, platform as osPlatform } from "node:os";
|
|
|
9227
9227
|
// packages/node/package.json
|
|
9228
9228
|
var package_default2 = {
|
|
9229
9229
|
name: "@huynle/hub-node",
|
|
9230
|
-
version: "0.9.
|
|
9230
|
+
version: "0.9.2",
|
|
9231
9231
|
private: true,
|
|
9232
9232
|
description: "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
|
|
9233
9233
|
license: "MIT",
|
|
@@ -10682,7 +10682,7 @@ import path14 from "node:path";
|
|
|
10682
10682
|
// packages/runtime-openshell/package.json
|
|
10683
10683
|
var package_default3 = {
|
|
10684
10684
|
name: "@huynle/hub-runtime-openshell",
|
|
10685
|
-
version: "0.9.
|
|
10685
|
+
version: "0.9.2",
|
|
10686
10686
|
private: true,
|
|
10687
10687
|
description: "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
|
|
10688
10688
|
license: "MIT",
|
|
@@ -14640,7 +14640,7 @@ import path23 from "node:path";
|
|
|
14640
14640
|
// packages/claude-code/package.json
|
|
14641
14641
|
var package_default4 = {
|
|
14642
14642
|
name: "@huynle/hub-claude-code",
|
|
14643
|
-
version: "0.9.
|
|
14643
|
+
version: "0.9.2",
|
|
14644
14644
|
private: true,
|
|
14645
14645
|
description: "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
|
|
14646
14646
|
license: "MIT",
|
|
@@ -14929,7 +14929,7 @@ import path26 from "node:path";
|
|
|
14929
14929
|
// packages/preset/package.json
|
|
14930
14930
|
var package_default5 = {
|
|
14931
14931
|
name: "@huynle/hub-preset",
|
|
14932
|
-
version: "0.9.
|
|
14932
|
+
version: "0.9.2",
|
|
14933
14933
|
private: true,
|
|
14934
14934
|
description: "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
|
|
14935
14935
|
license: "MIT",
|
|
@@ -15454,7 +15454,7 @@ import { openSync as openSync6, closeSync as closeSync6, readFileSync as readFil
|
|
|
15454
15454
|
// packages/codex/package.json
|
|
15455
15455
|
var package_default6 = {
|
|
15456
15456
|
name: "@huynle/hub-codex",
|
|
15457
|
-
version: "0.9.
|
|
15457
|
+
version: "0.9.2",
|
|
15458
15458
|
private: true,
|
|
15459
15459
|
description: "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
|
|
15460
15460
|
license: "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-codex",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-node",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-preset",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-runtime-openshell",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-spoke-sdk",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -157,6 +157,8 @@ export interface WebhooksDeps {
|
|
|
157
157
|
readonly resolve: ResolveHost
|
|
158
158
|
/** hub.json agents.webhooks.allowPrivate. */
|
|
159
159
|
readonly allowPrivate?: boolean
|
|
160
|
+
/** hub.json agents.webhooks.allowPrivateHosts: exact hostnames that may resolve to private addresses (https only). */
|
|
161
|
+
readonly privateHosts?: readonly string[]
|
|
160
162
|
readonly fetch?: typeof globalThis.fetch
|
|
161
163
|
/** Whether an app token (by its id) may still have webhooks; its registrations are dropped when not. */
|
|
162
164
|
readonly appAlive?: (userID: string, tokenID: string) => boolean
|
|
@@ -283,8 +285,8 @@ export class Webhooks {
|
|
|
283
285
|
const { url } = input
|
|
284
286
|
if (typeof url !== "string" || url.length === 0) throw invalid("url must be a string")
|
|
285
287
|
if (url.length > MAX_WEBHOOK_URL) throw invalid(`url must be at most ${MAX_WEBHOOK_URL} characters`)
|
|
286
|
-
const problem = await publicURLProblem(url, { resolve: this.deps.resolve, label: "Webhook URL", allowPrivate: this.deps.allowPrivate === true })
|
|
287
|
-
if (problem) throw invalid(problem, this.deps.allowPrivate ? undefined : "Webhooks need an https URL on a public host")
|
|
288
|
+
const problem = await publicURLProblem(url, { resolve: this.deps.resolve, label: "Webhook URL", allowPrivate: this.deps.allowPrivate === true, ...(this.deps.privateHosts?.length ? { privateHosts: this.deps.privateHosts } : {}) })
|
|
289
|
+
if (problem) throw invalid(problem, this.deps.allowPrivate ? undefined : "Webhooks need an https URL on a public host (or one listed in hub.json agents.webhooks.allowPrivateHosts)")
|
|
288
290
|
const events = eventsOf(input.events)
|
|
289
291
|
let label: string | undefined
|
|
290
292
|
if (input.label !== undefined && input.label !== null) {
|
|
@@ -512,6 +514,7 @@ export class Webhooks {
|
|
|
512
514
|
resolve: this.deps.resolve,
|
|
513
515
|
label: "Webhook URL",
|
|
514
516
|
allowPrivate: this.deps.allowPrivate === true,
|
|
517
|
+
...(this.deps.privateHosts?.length ? { privateHosts: this.deps.privateHosts } : {}),
|
|
515
518
|
timeoutMs,
|
|
516
519
|
maxBytes: this.deps.maxResponseBytes ?? WEBHOOK_RESPONSE_MAX,
|
|
517
520
|
...(this.deps.fetch ? { fetch: this.deps.fetch } : {}),
|
package/src/central/config.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface CentralConfig {
|
|
|
71
71
|
readonly launchCaps?: HubLaunches
|
|
72
72
|
/** hub.json `agents.webhooks.allowPrivate`: webhooks may target http, loopback, private and local hosts (admin opt-in). */
|
|
73
73
|
readonly webhooksAllowPrivate?: true
|
|
74
|
+
/** hub.json `agents.webhooks.allowPrivateHosts`: exact hostnames whose https webhook URLs may resolve privately. */
|
|
75
|
+
readonly webhooksPrivateHosts?: readonly string[]
|
|
74
76
|
/**
|
|
75
77
|
* HUB_RELEASE_BASE: where /install.sh and /install.ps1 tell machines to download hubctl (default: this version's
|
|
76
78
|
* hub-releases GitHub release). https, or plain http on loopback or a private-network IP address.
|
|
@@ -132,6 +134,8 @@ export interface HubAgents {
|
|
|
132
134
|
export interface HubWebhooks {
|
|
133
135
|
/** Lets webhook URLs use http and private or local targets (self-hosted receivers); off by default (SSRF). */
|
|
134
136
|
readonly allowPrivate?: boolean
|
|
137
|
+
/** Exact hostnames whose https webhook URLs may resolve to private addresses (a LAN-hosted receiver), nothing else. */
|
|
138
|
+
readonly allowPrivateHosts?: readonly string[]
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
/** hub.json, validated. */
|
|
@@ -151,7 +155,9 @@ type Env = Readonly<Record<string, string | undefined>>
|
|
|
151
155
|
const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth", "agents", "cluster"])
|
|
152
156
|
const CLUSTER_KEYS = ["database", "valkey"] as const
|
|
153
157
|
const AGENTS_KEYS = new Set(["never", "launches", "webhooks"])
|
|
154
|
-
const WEBHOOK_KEYS = new Set(["allowPrivate"])
|
|
158
|
+
const WEBHOOK_KEYS = new Set(["allowPrivate", "allowPrivateHosts"])
|
|
159
|
+
const PRIVATE_HOSTS_MAX = 50
|
|
160
|
+
const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
|
155
161
|
const LAUNCH_CAP_KEYS = ["maxQueued", "maxActive", "perHour", "lostAfterMinutes"] as const
|
|
156
162
|
/** The longest lostAfterMinutes (a day): far below setTimeout's 32-bit limit. */
|
|
157
163
|
const LOST_AFTER_MAX = 1440
|
|
@@ -337,7 +343,22 @@ function readHubWebhooks(raw: unknown, file: string, warnings: string[], fail: (
|
|
|
337
343
|
const input = raw as Record<string, unknown>
|
|
338
344
|
for (const key of Object.keys(input)) if (!WEBHOOK_KEYS.has(key)) warnings.push(`${file}: unknown key "agents.webhooks.${key}" ignored`)
|
|
339
345
|
if (input.allowPrivate !== undefined && typeof input.allowPrivate !== "boolean") fail("agents.webhooks.allowPrivate must be true or false")
|
|
340
|
-
|
|
346
|
+
const hosts = input.allowPrivateHosts === undefined ? undefined : readPrivateHosts(input.allowPrivateHosts, fail)
|
|
347
|
+
return { ...(input.allowPrivate === undefined ? {} : { allowPrivate: input.allowPrivate as boolean }), ...(hosts ? { allowPrivateHosts: hosts } : {}) }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Plain DNS hostnames with a dot (no IPs, wildcards, schemes, ports or paths), lowercase, trailing dot dropped, deduplicated. */
|
|
351
|
+
function readPrivateHosts(raw: unknown, fail: (message: string) => never): string[] {
|
|
352
|
+
if (!Array.isArray(raw)) fail("agents.webhooks.allowPrivateHosts must be a list of hostnames")
|
|
353
|
+
const list = raw as unknown[]
|
|
354
|
+
if (list.length > PRIVATE_HOSTS_MAX) fail(`agents.webhooks.allowPrivateHosts holds at most ${PRIVATE_HOSTS_MAX} hostnames`)
|
|
355
|
+
const out: string[] = []
|
|
356
|
+
for (const item of list) {
|
|
357
|
+
const host = typeof item === "string" ? item.trim().toLowerCase().replace(/\.$/, "") : ""
|
|
358
|
+
if (!HOSTNAME.test(host) || /^[0-9.]+$/.test(host)) fail(`agents.webhooks.allowPrivateHosts: ${JSON.stringify(item)} is not a hostname (no IPs, wildcards, schemes, ports or paths)`)
|
|
359
|
+
if (!out.includes(host)) out.push(host)
|
|
360
|
+
}
|
|
361
|
+
return out
|
|
341
362
|
}
|
|
342
363
|
|
|
343
364
|
function readHubLaunches(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubLaunches {
|
|
@@ -536,6 +557,7 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
|
|
|
536
557
|
agentsNever: [...(file.agents?.never ?? [])],
|
|
537
558
|
...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
|
|
538
559
|
...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
|
|
560
|
+
...(file.agents?.webhooks?.allowPrivateHosts?.length ? { webhooksPrivateHosts: [...file.agents.webhooks.allowPrivateHosts] } : {}),
|
|
539
561
|
...(releaseBase ? { releaseBase } : {}),
|
|
540
562
|
...(cluster ? { cluster } : {}),
|
|
541
563
|
...(remoteFetch === "off" ? { remoteFetch: false } : {}),
|
package/src/central/http.ts
CHANGED
|
@@ -1061,6 +1061,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1061
1061
|
users: () => store.users().map((u) => u.id),
|
|
1062
1062
|
resolve: options.resolveHost ?? systemResolveHost,
|
|
1063
1063
|
...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
|
|
1064
|
+
...(config.webhooksPrivateHosts?.length ? { privateHosts: config.webhooksPrivateHosts } : {}),
|
|
1064
1065
|
appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && WEBHOOK_APP_SCOPES.some((scope) => t.scopes!.includes(scope))) === true,
|
|
1065
1066
|
appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
|
|
1066
1067
|
userAgent: `hub/${options.version ?? "0.0.0"}`,
|
|
@@ -82,6 +82,12 @@ export interface PublicURLOptions {
|
|
|
82
82
|
* addresses and local names pass without a lookup. Other schemes and credentials still fail.
|
|
83
83
|
*/
|
|
84
84
|
readonly allowPrivate?: boolean
|
|
85
|
+
/**
|
|
86
|
+
* The narrow admin opt-in (hub.json agents.webhooks.allowPrivateHosts): these exact hostnames, normalised (lowercase,
|
|
87
|
+
* no trailing dot), may resolve to private addresses, on port 443 only. Still https only, no credentials, and the
|
|
88
|
+
* name must resolve; a listed name also skips the local-suffix rule (.local, .lan, …).
|
|
89
|
+
*/
|
|
90
|
+
readonly privateHosts?: readonly string[]
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
/** Why the hub must not send to a URL (SSRF), or undefined when it may. */
|
|
@@ -100,7 +106,10 @@ export async function publicURLProblem(raw: string, options: PublicURLOptions):
|
|
|
100
106
|
if (options.allowPrivate) return undefined
|
|
101
107
|
const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.$/, "")
|
|
102
108
|
if (isIPv4(host) || isIPv6(host)) return isPrivateAddress(host) ? `${label} is a private or local address` : undefined
|
|
103
|
-
|
|
109
|
+
const listed = options.privateHosts?.some((h) => h.toLowerCase().replace(/\.$/, "") === host) === true
|
|
110
|
+
// A listed host is reachable on the https port only, so the exception never opens other services on that address.
|
|
111
|
+
if (listed && url.port !== "" && url.port !== "443") return `${label} must use port 443 for a host in allowPrivateHosts`
|
|
112
|
+
if (!listed && (host === "localhost" || !host.includes(".") || LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix)))) {
|
|
104
113
|
return `${label} is a local hostname`
|
|
105
114
|
}
|
|
106
115
|
let addresses: readonly string[]
|
|
@@ -110,7 +119,7 @@ export async function publicURLProblem(raw: string, options: PublicURLOptions):
|
|
|
110
119
|
return `${label} host ${host} does not resolve`
|
|
111
120
|
}
|
|
112
121
|
if (addresses.length === 0) return `${label} host ${host} does not resolve`
|
|
113
|
-
if (addresses.some(isPrivateAddress)) return `${label} resolves to a private or local address`
|
|
122
|
+
if (!listed && addresses.some(isPrivateAddress)) return `${label} resolves to a private or local address`
|
|
114
123
|
return undefined
|
|
115
124
|
}
|
|
116
125
|
|