From df9e8e8f06d33527e4a63feabb63bb3725892b9f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 21:55:55 +0200 Subject: [PATCH] Use daemon hostnames for initial Hub slugs (#3172) * fix(hub): send hostname during daemon enrollment Pass the local hostname as daemon enrollment metadata so Hub can assign useful default slugs without coupling daemon authority to the human CLI login. Document the rollout dependency on Hub accepting the optional field. * test(hub): verify enrollment hostname payload --- packages/server/src/server/bootstrap.ts | 1 + .../hub/relationship-controller.test.ts | 3 ++- .../src/server/hub/relationship-controller.ts | 2 ++ .../server/hub/relationship-remote.test.ts | 20 ++++++++++++++++--- .../src/server/hub/relationship-remote.ts | 2 ++ public-docs/hub/daemons.md | 4 +++- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 11618aa96..0321d3295 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1111,6 +1111,7 @@ export async function createPaseoDaemon( }); const hubRelationships = new HubRelationshipController({ paseoHome: config.paseoHome, + hostname: getHostname(), serverId, daemonPublicKey: daemonKeyPair.publicKeyB64, logger, diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index 78b4481ec..c7ffbd833 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { platform } from "node:os"; +import { hostname, platform } from "node:os"; import { afterEach, describe, expect, test } from "vitest"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; @@ -88,6 +88,7 @@ describe("Hub relationship", () => { .update(secret ?? "") .digest("base64url"), ); + expect(enrollment.hostname).toBe(hostname()); relationship.completeEnrollment(); await connecting.result; await relationship.socketDialed(); diff --git a/packages/server/src/server/hub/relationship-controller.ts b/packages/server/src/server/hub/relationship-controller.ts index 457a5b701..2c213aae6 100644 --- a/packages/server/src/server/hub/relationship-controller.ts +++ b/packages/server/src/server/hub/relationship-controller.ts @@ -137,6 +137,7 @@ export interface HubRelationshipRetryPolicy { export interface HubRelationshipControllerOptions { paseoHome: string; + hostname: string; serverId: string; daemonPublicKey: string; logger: pino.Logger; @@ -324,6 +325,7 @@ export class HubRelationshipController implements HubRelationshipManagement { idempotencyKey: pending.relationship.idempotencyKey, hubOrigin: pending.relationship.hubOrigin, token: pending.enrollment.token, + hostname: this.options.hostname, serverId: pending.identity.serverId, daemonPublicKey: pending.identity.daemonPublicKey, credentialVerifier: verifier, diff --git a/packages/server/src/server/hub/relationship-remote.test.ts b/packages/server/src/server/hub/relationship-remote.test.ts index e4950adab..8591ad8ad 100644 --- a/packages/server/src/server/hub/relationship-remote.test.ts +++ b/packages/server/src/server/hub/relationship-remote.test.ts @@ -61,6 +61,7 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", @@ -74,7 +75,10 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async }); test("enrollment rejects a transport URL that cannot open a WebSocket", async () => { - const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon"); + let enrolledHostname: string | undefined; + const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon", (enrollment) => { + enrolledHostname = enrollment.hostname; + }); const remote = new DirectHubRelationshipRemote(); await expect( @@ -83,12 +87,14 @@ test("enrollment rejects a transport URL that cannot open a WebSocket", async () idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", scopes: ["hub.execution.*"], }), ).rejects.toThrow("Hub WebSocket URL must use ws or wss"); + expect(enrolledHostname).toBe("test-daemon.local"); }); test("enrollment rejects a WebSocket URL with a fragment", async () => { @@ -101,6 +107,7 @@ test("enrollment rejects a WebSocket URL with a fragment", async () => { idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", @@ -119,6 +126,7 @@ test("enrollment rejects a WebSocket outside the enrolled Hub authority", async idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", @@ -138,6 +146,7 @@ test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (oper idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", @@ -290,11 +299,15 @@ async function startHubReturning(status: number): Promise { return `http://127.0.0.1:${address.port}`; } -async function startEnrollmentHub(webSocketUrl: string): Promise { +async function startEnrollmentHub( + webSocketUrl: string, + onEnrollment?: (enrollment: { daemonId: string; hostname: string }) => void, +): Promise { const server = createServer(async (request, response) => { let body = ""; for await (const chunk of request) body += chunk; - const enrollment = JSON.parse(body) as { daemonId: string }; + const enrollment = JSON.parse(body) as { daemonId: string; hostname: string }; + onEnrollment?.(enrollment); response.writeHead(200, { "content-type": "application/json" }).end( JSON.stringify({ daemonId: enrollment.daemonId, @@ -523,6 +536,7 @@ async function connectController( openPaseoHomes.push(paseoHome); const controller = new HubRelationshipController({ paseoHome, + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "daemon-public-key", logger: pino({ level: "silent" }), diff --git a/packages/server/src/server/hub/relationship-remote.ts b/packages/server/src/server/hub/relationship-remote.ts index 82172a411..a5321634f 100644 --- a/packages/server/src/server/hub/relationship-remote.ts +++ b/packages/server/src/server/hub/relationship-remote.ts @@ -7,6 +7,7 @@ export interface HubEnrollment { idempotencyKey: string; hubOrigin: string; token: string; + hostname: string; serverId: string; daemonPublicKey: string; credentialVerifier: string; @@ -96,6 +97,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote { body: JSON.stringify({ daemonId: input.daemonId, idempotencyKey: input.idempotencyKey, + hostname: input.hostname, serverId: input.serverId, daemonPublicKey: input.daemonPublicKey, credentialVerifier: input.credentialVerifier, diff --git a/public-docs/hub/daemons.md b/public-docs/hub/daemons.md index 2b9ae1ec3..2e0dce859 100644 --- a/public-docs/hub/daemons.md +++ b/public-docs/hub/daemons.md @@ -26,7 +26,9 @@ paseo hub connect `connect` uses the active login to request a single-use enrollment token. The daemon exchanges it for its own relationship credential; your CLI login is never stored as daemon authority. -Each daemon has two identifiers: an immutable generated ID and a friendly slug. Hub normalizes the slug you enter with lowercase words joined by hyphens, so `Build Studio` becomes `build-studio`. The slug is what the dashboard shows and what configuration references. +Hub derives the daemon's initial slug from its hostname. If that slug is already used in the organization, Hub adds a short daemon ID suffix. You can rename the daemon later in Hub. + +Each daemon has two identifiers: an immutable generated ID and a friendly slug. Hub normalizes slugs with lowercase words joined by hyphens, so `Build Studio` becomes `build-studio`. The slug is what the dashboard shows and what configuration references. You can rename the slug later without changing the daemon ID. Renaming after a configuration is active means updating that configuration.