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.