@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 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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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-cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "private": true,
5
5
  "description": "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.9.1",
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.1",
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.1",
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-protocol",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (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.1",
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-runtime-sdk",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "private": true,
5
5
  "description": "The Runtime interface `hub node` runtimes implement (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-sdk",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "private": true,
5
5
  "description": "Typed client for the hub's /api/agent (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.1",
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 } : {}),
@@ -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
- return input.allowPrivate === undefined ? {} : { allowPrivate: input.allowPrivate as boolean }
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 } : {}),
@@ -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
- if (host === "localhost" || !host.includes(".") || LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
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