@cydm/happy-elves 0.1.0-beta.69 → 0.1.0-beta.70

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.
@@ -1,7 +1,7 @@
1
1
  import { createHash, timingSafeEqual } from "node:crypto";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { assertLocalDaemonVerified, cliErrorFromFetch, CliError, compactText, ControllerClient, configDir, configPath, daemonPidPath, deriveOrchestration, diagnoseInstallation, emitDoctorResult, idleSessionStatuses, localDaemonStatus, normalizeRelayUrl, ok, parsePairingClaimResponse, randomId, readConfig, readDaemonConfig, readLocalAuditLog, readRelayJson, redactedDaemonConfig, requirePositional, requireRelayUrl, requireString, showMachine, spawnDaemonStart, stopLocalDaemon, throwRelayHttpError, wantsJson, withAccountConfigTransaction } from "./lib/index.js";
4
+ import { assertLocalDaemonVerified, canonicalRelayUrl, cliErrorFromFetch, CliError, compactText, ControllerClient, configDir, configPath, daemonPidPath, deriveOrchestration, diagnoseInstallation, emitDoctorResult, idleSessionStatuses, localDaemonStatus, ok, parsePairingClaimResponse, randomId, readConfig, readDaemonConfig, readLocalAuditLog, readRelayJson, redactedDaemonConfig, requirePositional, requireRelayUrl, requireString, showMachine, spawnDaemonStart, stopLocalDaemon, throwRelayHttpError, wantsJson, withAccountConfigTransaction } from "./lib/index.js";
5
5
  function positiveLimit(flags, fallback) {
6
6
  const value = flags.limit;
7
7
  if (value === undefined)
@@ -103,7 +103,7 @@ export async function handleDaemon({ domain, action, flags }) {
103
103
  throw new CliError(`Daemon configuration already exists at ${transaction.daemonPath}. Pairing never replaces an existing machine identity; use an explicit account recovery or migration flow.`, "CONFIG_EXISTS");
104
104
  }
105
105
  const existingController = await transaction.readController();
106
- if (existingController && (canonicalPairRelay(existingController.relayUrl) !== canonicalPairRelay(relayUrl) ||
106
+ if (existingController && (canonicalRelayUrl(existingController.relayUrl, "Controller config") !== canonicalRelayUrl(relayUrl) ||
107
107
  !pairSecretsEqual(existingController.accountSecret, accountSecret))) {
108
108
  throw new CliError("Daemon pairing does not match the existing controller relay/account. No relay request was sent and no configuration was changed.", "CONFIG_SPLIT_BRAIN");
109
109
  }
@@ -145,7 +145,7 @@ export async function handleDaemon({ domain, action, flags }) {
145
145
  return true;
146
146
  }
147
147
  if (domain === "daemon" && action === "doctor") {
148
- const requestedRelay = typeof flags.relay === "string" ? flags.relay : undefined;
148
+ const requestedRelay = typeof flags.relay === "string" ? requireRelayUrl(flags) : undefined;
149
149
  const data = await diagnoseInstallation("daemon", requestedRelay);
150
150
  emitDoctorResult("daemon.doctor", "Daemon doctor", data, flags);
151
151
  return true;
@@ -346,11 +346,6 @@ export async function handleDaemon({ domain, action, flags }) {
346
346
  }
347
347
  return false;
348
348
  }
349
- function canonicalPairRelay(value) {
350
- const url = new URL(value);
351
- url.hash = "";
352
- return normalizeRelayUrl(url.toString());
353
- }
354
349
  function pairSecretsEqual(left, right) {
355
350
  const leftDigest = createHash("sha256").update(left).digest();
356
351
  const rightDigest = createHash("sha256").update(right).digest();
@@ -2,7 +2,7 @@ import { ControllerClient } from "../../../../../packages/client/dist/index.js";
2
2
  import { generateAccountSecret, randomId } from "../../../../../packages/shared/dist/index.js";
3
3
  import { CliError } from "../../errors.js";
4
4
  import { defaultHostedControllerUrl, defaultHostedRelayUrl } from "./paths.js";
5
- import { normalizeRelayUrl, redactedRelayUrl, requireRelayUrl, withAccountConfigTransaction, } from "./config.js";
5
+ import { canonicalRelayUrl, redactedRelayUrl, requireRelayUrl, withAccountConfigTransaction, } from "./config.js";
6
6
  import { cliErrorFromFetch, parsePairingClaimResponse, parsePairingStartResponse, readRelayJson, throwRelayHttpError } from "./relay-http.js";
7
7
  function settledConfigValue(result) {
8
8
  if (result.status === "rejected")
@@ -31,9 +31,9 @@ export async function readStartConfigPreflight(flags, transaction) {
31
31
  }
32
32
  export function classifyStartConfig(params) {
33
33
  const { controller, daemon } = params;
34
- const explicitRelayUrl = params.explicitRelayUrl ? canonicalBootstrapRelayUrl(params.explicitRelayUrl) : undefined;
35
- const controllerRelayUrl = controller ? canonicalBootstrapRelayUrl(controller.relayUrl) : undefined;
36
- const daemonRelayUrl = daemon ? canonicalBootstrapRelayUrl(daemon.relayUrl) : undefined;
34
+ const explicitRelayUrl = params.explicitRelayUrl ? canonicalRelayUrl(params.explicitRelayUrl) : undefined;
35
+ const controllerRelayUrl = controller ? canonicalRelayUrl(controller.relayUrl, "Controller config") : undefined;
36
+ const daemonRelayUrl = daemon ? canonicalRelayUrl(daemon.relayUrl, "Daemon config") : undefined;
37
37
  if (controller && daemon) {
38
38
  if (controllerRelayUrl !== daemonRelayUrl || controller.accountSecret !== daemon.accountSecret) {
39
39
  throw splitBrainError(controller, daemon);
@@ -50,33 +50,19 @@ export function classifyStartConfig(params) {
50
50
  }
51
51
  return {
52
52
  kind: controller && daemon ? "ready" : controller ? "controller-only" : "fresh",
53
- relayUrl: explicitRelayUrl ?? configuredRelay ?? canonicalBootstrapRelayUrl(defaultHostedRelayUrl),
53
+ relayUrl: explicitRelayUrl ?? configuredRelay ?? canonicalRelayUrl(defaultHostedRelayUrl),
54
54
  explicitRelay: Boolean(explicitRelayUrl),
55
55
  ...(controller ? { controller } : {}),
56
56
  ...(daemon ? { daemon } : {}),
57
57
  };
58
58
  }
59
59
  function splitBrainError(controller, daemon) {
60
- const relayDetail = canonicalBootstrapRelayUrl(controller.relayUrl) === canonicalBootstrapRelayUrl(daemon.relayUrl)
60
+ const relayDetail = canonicalRelayUrl(controller.relayUrl, "Controller config") === canonicalRelayUrl(daemon.relayUrl, "Daemon config")
61
61
  ? `Both files name ${redactedRelayUrl(controller.relayUrl)}, but their account identities differ.`
62
62
  : `Controller relay: ${redactedRelayUrl(controller.relayUrl)}. Daemon relay: ${redactedRelayUrl(daemon.relayUrl)}.`;
63
63
  return new CliError(`Controller and daemon configurations belong to different workspaces. ${relayDetail} ` +
64
64
  "No configuration or daemon process was changed. Recover the controller identity from an already signed-in Web Controller via Settings > Browsers > Add and a one-time invite.", "CONFIG_SPLIT_BRAIN");
65
65
  }
66
- function canonicalBootstrapRelayUrl(value) {
67
- let url;
68
- try {
69
- url = new URL(value);
70
- }
71
- catch {
72
- throw new CliError("Relay URL is invalid.", "CONFIG_INVALID");
73
- }
74
- if (url.protocol !== "http:" && url.protocol !== "https:") {
75
- throw new CliError("Relay URL must use http or https.", "CONFIG_INVALID");
76
- }
77
- url.pathname = url.pathname.replace(/\/+$/, "");
78
- return normalizeRelayUrl(url.toString());
79
- }
80
66
  async function createControllerConfig(params, transaction, deferWrite = false) {
81
67
  const accountSecret = generateAccountSecret();
82
68
  let response;
@@ -107,7 +93,7 @@ async function createControllerConfig(params, transaction, deferWrite = false) {
107
93
  }
108
94
  export async function ensureControllerConfig(params) {
109
95
  if (params.existing) {
110
- if (canonicalBootstrapRelayUrl(params.existing.relayUrl) !== canonicalBootstrapRelayUrl(params.relayUrl)) {
96
+ if (canonicalRelayUrl(params.existing.relayUrl, "Controller config") !== canonicalRelayUrl(params.relayUrl)) {
111
97
  throw new CliError("Existing controller credentials do not match the requested relay. happy-elves start does not migrate accounts.", "CONFIG_RELAY_MISMATCH");
112
98
  }
113
99
  return { config: params.existing, created: false, reconfigured: false };
@@ -160,7 +146,7 @@ async function createDaemonConfig(config, machineName, transaction, deferWrite =
160
146
  }
161
147
  export async function ensureDaemonPaired(config, machineName, existing, transaction, deferWrite = false, signal, expectedAccountId) {
162
148
  if (existing) {
163
- if (canonicalBootstrapRelayUrl(existing.relayUrl) !== canonicalBootstrapRelayUrl(config.relayUrl) ||
149
+ if (canonicalRelayUrl(existing.relayUrl, "Daemon config") !== canonicalRelayUrl(config.relayUrl, "Controller config") ||
164
150
  existing.accountSecret !== config.accountSecret) {
165
151
  throw splitBrainError(config, existing);
166
152
  }
@@ -27,6 +27,7 @@ export type AccountConfigTransaction = {
27
27
  export declare function requireString(flags: Record<string, string | boolean>, key: string): string;
28
28
  export declare function requirePositional(value: string | undefined, name: string): string;
29
29
  export declare function normalizeRelayUrl(value: string): string;
30
+ export declare function canonicalRelayUrl(value: string, label?: string): string;
30
31
  export declare function redactedRelayUrl(value: string): string;
31
32
  export declare function requireRelayUrl(flags: Record<string, string | boolean>, key?: string): string;
32
33
  export declare function readConfig(flags: Record<string, string | boolean>): Promise<CliConfig>;
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { canonicalRelayIdentity } from "../../../../../packages/shared/dist/index.js";
4
5
  import { isDaemonProcessAlive, readDaemonProcessStartedAtMs } from "../../../../../packages/shared/dist/node/daemon-process.js";
5
6
  import { CliError } from "../../errors.js";
6
7
  import { configDir, configPath } from "./paths.js";
@@ -24,6 +25,14 @@ export function requirePositional(value, name) {
24
25
  export function normalizeRelayUrl(value) {
25
26
  return value.replace(/\/+$/, "");
26
27
  }
28
+ export function canonicalRelayUrl(value, label = "Relay") {
29
+ try {
30
+ return canonicalRelayIdentity(value);
31
+ }
32
+ catch (error) {
33
+ throw new CliError(`${label} relayUrl is invalid: ${error instanceof Error ? error.message : String(error)}`, "CONFIG_INVALID");
34
+ }
35
+ }
27
36
  export function redactedRelayUrl(value) {
28
37
  try {
29
38
  const url = new URL(value);
@@ -45,17 +54,7 @@ function assertStringField(candidate, field, label) {
45
54
  return value;
46
55
  }
47
56
  function assertRelayUrl(value, label) {
48
- let url;
49
- try {
50
- url = new URL(value);
51
- }
52
- catch {
53
- throw new CliError(`${label} config relayUrl is invalid.`, "CONFIG_INVALID");
54
- }
55
- if (url.protocol !== "http:" && url.protocol !== "https:") {
56
- throw new CliError(`${label} config relayUrl must use http or https.`, "CONFIG_INVALID");
57
- }
58
- return normalizeRelayUrl(value);
57
+ return canonicalRelayUrl(value, `${label} config`);
59
58
  }
60
59
  export function requireRelayUrl(flags, key = "relay") {
61
60
  try {
@@ -145,7 +144,7 @@ async function readOptionalDaemonConfig() {
145
144
  return value === undefined ? undefined : assertDaemonConfigShape(value);
146
145
  }
147
146
  export async function readConfig(flags) {
148
- const relayUrl = typeof flags.relay === "string" ? normalizeRelayUrl(flags.relay) : process.env.HAPPY_ELVES_RELAY_URL;
147
+ const relayUrl = typeof flags.relay === "string" ? flags.relay : process.env.HAPPY_ELVES_RELAY_URL;
149
148
  const controllerToken = typeof flags.token === "string" ? flags.token : process.env.HAPPY_ELVES_CONTROLLER_TOKEN;
150
149
  const accountSecret = typeof flags.secret === "string" ? flags.secret : process.env.HAPPY_ELVES_ACCOUNT_SECRET;
151
150
  if (relayUrl && controllerToken && accountSecret) {
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { daemonRuntimeConfigFingerprint, parseDaemonRuntimeIdentity, } from "../../../../../packages/shared/dist/node/daemon-process.js";
5
5
  import { CliError } from "../../errors.js";
6
6
  import { CliExit } from "./cli-exit.js";
7
- import { normalizeRelayUrl, redactedControllerConfig, redactedDaemonConfig, redactedRelayUrl, withAccountConfigTransaction, } from "./config.js";
7
+ import { canonicalRelayUrl, redactedControllerConfig, redactedDaemonConfig, requireRelayUrl, withAccountConfigTransaction, } from "./config.js";
8
8
  import { localDaemonStatus, readLocalDaemonPidIdentity } from "./local-daemon.js";
9
9
  import { ok, wantsJson, writeError } from "./json.js";
10
10
  import { configDir, configPath, daemonRuntimeIdentityPath } from "./paths.js";
@@ -14,7 +14,8 @@ export async function diagnoseInstallation(mode = "controller", requestedRelay)
14
14
  const { controller, daemon, controllerState, daemonState } = await readConfigSnapshot(checks, mode === "controller");
15
15
  let splitBrain = false;
16
16
  if (controller && daemon) {
17
- const relayMatches = canonicalRelayUrl(controller.relayUrl) === canonicalRelayUrl(daemon.relayUrl);
17
+ const relayMatches = canonicalRelayUrl(controller.relayUrl, "Controller config") ===
18
+ canonicalRelayUrl(daemon.relayUrl, "Daemon config");
18
19
  const accountMatches = secretValuesEqual(controller.accountSecret, daemon.accountSecret);
19
20
  splitBrain = !relayMatches || !accountMatches;
20
21
  checks.push({
@@ -33,9 +34,9 @@ export async function diagnoseInstallation(mode = "controller", requestedRelay)
33
34
  }
34
35
  const relayTargets = new Map();
35
36
  if (controller)
36
- relayTargets.set(`controller:${canonicalRelayUrl(controller.relayUrl)}`, { name: "controller-relay", relayUrl: controller.relayUrl });
37
+ relayTargets.set(`controller:${canonicalRelayUrl(controller.relayUrl, "Controller config")}`, { name: "controller-relay", relayUrl: controller.relayUrl });
37
38
  if (daemon)
38
- relayTargets.set(`daemon:${canonicalRelayUrl(daemon.relayUrl)}`, { name: "daemon-relay", relayUrl: daemon.relayUrl });
39
+ relayTargets.set(`daemon:${canonicalRelayUrl(daemon.relayUrl, "Daemon config")}`, { name: "daemon-relay", relayUrl: daemon.relayUrl });
39
40
  if (requestedRelay)
40
41
  relayTargets.set(`requested:${canonicalRelayUrl(requestedRelay)}`, { name: "requested-relay", relayUrl: requestedRelay });
41
42
  const relayChecks = await Promise.all([...relayTargets.values()].map(probeRelay));
@@ -71,7 +72,7 @@ export async function diagnoseInstallation(mode = "controller", requestedRelay)
71
72
  return { ok: checks.every((check) => check.ok), ...(code ? { code } : {}), checks };
72
73
  }
73
74
  export async function topLevelDoctor(flags) {
74
- const requestedRelay = typeof flags.relay === "string" ? flags.relay : undefined;
75
+ const requestedRelay = typeof flags.relay === "string" ? requireRelayUrl(flags) : undefined;
75
76
  const data = await diagnoseInstallation("controller", requestedRelay);
76
77
  emitDoctorResult("doctor", "Happy Elves doctor", data, flags);
77
78
  }
@@ -216,8 +217,16 @@ async function inspectRuntimeIdentity(daemonRunning, daemonConfig) {
216
217
  code: "DAEMON_RUNTIME_CONFIG_MISMATCH",
217
218
  };
218
219
  }
220
+ let relayMatches = false;
221
+ try {
222
+ relayMatches = canonicalRelayUrl(runtime.relayUrl, "Daemon runtime") ===
223
+ canonicalRelayUrl(daemonConfig.relayUrl, "Daemon config");
224
+ }
225
+ catch {
226
+ relayMatches = false;
227
+ }
219
228
  const configMatches = runtime.configFingerprint === daemonRuntimeConfigFingerprint(daemonConfig) &&
220
- canonicalRelayUrl(runtime.relayUrl) === canonicalRelayUrl(daemonConfig.relayUrl) &&
229
+ relayMatches &&
221
230
  runtime.accountId === daemonConfig.accountId &&
222
231
  runtime.machineId === daemonConfig.machineId;
223
232
  return {
@@ -240,19 +249,6 @@ function secretValuesEqual(left, right) {
240
249
  const rightDigest = createHash("sha256").update(right).digest();
241
250
  return timingSafeEqual(leftDigest, rightDigest);
242
251
  }
243
- function canonicalRelayUrl(value) {
244
- try {
245
- const url = new URL(value);
246
- url.username = "";
247
- url.password = "";
248
- url.search = "";
249
- url.hash = "";
250
- return normalizeRelayUrl(url.toString());
251
- }
252
- catch {
253
- return normalizeRelayUrl(redactedRelayUrl(value));
254
- }
255
- }
256
252
  function displayRelayUrl(value) {
257
253
  return canonicalRelayUrl(value);
258
254
  }
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { canonicalRelayIdentity } from "../../../../../packages/shared/dist/index.js";
5
6
  import { daemonRuntimeConfigFingerprint, daemonProcessStatusForIdentity, isDaemonProcessAlive, parseDaemonPidIdentity, parseDaemonRuntimeIdentity, stopVerifiedDaemonProcess, } from "../../../../../packages/shared/dist/node/daemon-process.js";
6
7
  import { CliError } from "../../errors.js";
7
8
  import { configDir, daemonPidPath, daemonRuntimeIdentityPath } from "./paths.js";
@@ -47,8 +48,15 @@ export async function inspectLocalDaemonRuntime(config) {
47
48
  pidIdentity.record.startedAt === runtime.startedAt;
48
49
  if (!ownerMatches)
49
50
  return { state: "owner-mismatch", identity: runtime };
51
+ let relayMatches = false;
52
+ try {
53
+ relayMatches = canonicalRelayIdentity(runtime.relayUrl) === canonicalRelayIdentity(config.relayUrl);
54
+ }
55
+ catch {
56
+ relayMatches = false;
57
+ }
50
58
  const configMatches = runtime.configFingerprint === daemonRuntimeConfigFingerprint(config) &&
51
- canonicalRelayUrl(runtime.relayUrl) === canonicalRelayUrl(config.relayUrl) &&
59
+ relayMatches &&
52
60
  runtime.accountId === config.accountId &&
53
61
  runtime.machineId === config.machineId;
54
62
  return configMatches
@@ -198,16 +206,3 @@ async function settleChildExit(handle, timeoutMs) {
198
206
  clearTimeout(timer);
199
207
  return settled;
200
208
  }
201
- function canonicalRelayUrl(value) {
202
- try {
203
- const url = new URL(value);
204
- url.username = "";
205
- url.password = "";
206
- url.search = "";
207
- url.hash = "";
208
- return url.toString().replace(/\/+$/u, "");
209
- }
210
- catch {
211
- return value.replace(/\/+$/u, "");
212
- }
213
- }
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "../../errors.js";
2
- import { normalizeRelayUrl } from "./config.js";
2
+ import { canonicalRelayUrl } from "./config.js";
3
3
  export const DEFAULT_COMMAND_TIMEOUT_MS = 1000 * 1000;
4
4
  export function parseDurationMs(value, fallbackMs) {
5
5
  if (typeof value !== "string")
@@ -117,7 +117,7 @@ export function parseControllerJoinUrl(value) {
117
117
  const relay = url.searchParams.get("relay");
118
118
  url.search = "";
119
119
  url.hash = "";
120
- return { relayUrl: normalizeRelayUrl(relay ?? url.toString()), code, inviteSecret };
120
+ return { relayUrl: canonicalRelayUrl(relay ?? url.toString()), code, inviteSecret };
121
121
  }
122
122
  export function parseStopOn(value) {
123
123
  if (typeof value !== "string" || !value.trim())
@@ -1,6 +1,6 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import os from "node:os";
3
- import { CliError, ControllerClient, configPath, normalizeRelayUrl, ok, parseControllerJoinUrl, parseDurationMs, randomId, readConfig, redactedRelayUrl, relayHealth, requirePositional, requireRelayUrl, requireString, showMachine, wantsJson, wantsVerbose, withAccountConfigTransaction } from "./lib/index.js";
3
+ import { canonicalRelayUrl, CliError, ControllerClient, configPath, ok, parseControllerJoinUrl, parseDurationMs, randomId, readConfig, redactedRelayUrl, relayHealth, requirePositional, requireRelayUrl, requireString, showMachine, wantsJson, wantsVerbose, withAccountConfigTransaction } from "./lib/index.js";
4
4
  function sameSecret(left, right) {
5
5
  const leftBytes = Buffer.from(left, "utf8");
6
6
  const rightBytes = Buffer.from(right, "utf8");
@@ -150,7 +150,7 @@ export async function handleRemote({ domain, action, positional, flags }) {
150
150
  if (!daemonConfig) {
151
151
  throw new CliError("Controller recovery requires an existing daemon configuration from the account being recovered.", "CONTROLLER_RECOVERY_REQUIRES_DAEMON");
152
152
  }
153
- if (normalizeRelayUrl(daemonConfig.relayUrl) !== normalizeRelayUrl(relayUrl)) {
153
+ if (canonicalRelayUrl(daemonConfig.relayUrl, "Daemon config") !== canonicalRelayUrl(relayUrl)) {
154
154
  throw new CliError(`Controller recovery relay ${redactedRelayUrl(relayUrl)} does not match daemon relay ${redactedRelayUrl(daemonConfig.relayUrl)}. The invite was not consumed.`, "CONFIG_SPLIT_BRAIN");
155
155
  }
156
156
  // Validate an existing controller before consuming the one-time
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { canonicalRelayIdentity } from "../../../packages/shared/dist/index.js";
4
5
  import { configDir, configPath } from "./paths.js";
5
6
  import { DaemonCliError } from "./errors.js";
6
7
  const runtimeEnvKeys = new Set([
@@ -35,11 +36,9 @@ function assertConfigShape(value) {
35
36
  if (typeof relayUrl !== "string") {
36
37
  throw new DaemonCliError("Daemon config relayUrl is invalid.", "CONFIG_INVALID");
37
38
  }
39
+ let canonicalRelayUrl;
38
40
  try {
39
- const parsedRelayUrl = new URL(relayUrl);
40
- if (parsedRelayUrl.protocol !== "http:" && parsedRelayUrl.protocol !== "https:") {
41
- throw new Error("Unsupported protocol");
42
- }
41
+ canonicalRelayUrl = canonicalRelayIdentity(relayUrl);
43
42
  }
44
43
  catch {
45
44
  throw new DaemonCliError("Daemon config relayUrl is invalid.", "CONFIG_INVALID");
@@ -54,6 +53,7 @@ function assertConfigShape(value) {
54
53
  }
55
54
  return {
56
55
  ...candidate,
56
+ relayUrl: canonicalRelayUrl,
57
57
  ...(Object.keys(runtimeEnv).length > 0 ? { runtimeEnv } : {}),
58
58
  };
59
59
  }
@@ -2,7 +2,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
2
2
  import os from "node:os";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { randomId } from "../../../packages/shared/dist/index.js";
5
+ import { canonicalRelayIdentity, randomId } from "../../../packages/shared/dist/index.js";
6
6
  import { acquireDaemonAccountConfigLock } from "./account-config-lock.js";
7
7
  import { parseDaemonFlags, requireString } from "./args.js";
8
8
  import { configDir, configPath } from "./paths.js";
@@ -157,11 +157,7 @@ async function assertControllerCompatible(relayUrl, accountSecret) {
157
157
  }
158
158
  }
159
159
  function canonicalRelay(value) {
160
- const url = new URL(value);
161
- if (url.protocol !== "http:" && url.protocol !== "https:")
162
- throw new Error("unsupported relay protocol");
163
- url.hash = "";
164
- return url.toString().replace(/\/+$/u, "");
160
+ return canonicalRelayIdentity(value);
165
161
  }
166
162
  function displayRelayUrl(value) {
167
163
  const url = new URL(value);
@@ -1,4 +1,4 @@
1
- import { parseServerMessage } from "../../../packages/shared/dist/index.js";
1
+ import { canonicalRelayIdentity, parseServerMessage } from "../../../packages/shared/dist/index.js";
2
2
  import { DaemonCliError } from "./errors.js";
3
3
  export async function relayHttpError(response, fallback) {
4
4
  const text = await response.text();
@@ -37,26 +37,18 @@ export function parsePairingClaimResponse(value) {
37
37
  }
38
38
  return { accountId, machineId, machineToken };
39
39
  }
40
- function normalizeRelayUrl(value) {
41
- return value.replace(/\/+$/, "");
42
- }
43
40
  export function assertRelayUrl(value) {
44
- let url;
45
41
  try {
46
- url = new URL(value);
42
+ return canonicalRelayIdentity(value);
47
43
  }
48
44
  catch {
49
45
  throw new DaemonCliError("Daemon relayUrl is invalid.", "INVALID_ARGUMENT");
50
46
  }
51
- if (url.protocol !== "http:" && url.protocol !== "https:") {
52
- throw new DaemonCliError("Daemon relayUrl must use http or https.", "INVALID_ARGUMENT");
53
- }
54
- return normalizeRelayUrl(value);
55
47
  }
56
48
  export function relayWsUrl(relayUrl) {
57
- const url = new URL(relayUrl);
49
+ const url = new URL(assertRelayUrl(relayUrl));
58
50
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
59
- url.pathname = "/ws";
51
+ url.pathname = `${url.pathname.replace(/\/+$/u, "")}/ws`;
60
52
  url.search = "";
61
53
  return url.toString();
62
54
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.69",
3
+ "version": "0.1.0-beta.70",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
- "cliVersion": "0.1.0-beta.69",
2
+ "cliVersion": "0.1.0-beta.70",
3
3
  "channel": "beta",
4
- "commit": "0757ffd251eceab467d3faf4abd69aaea554ce3d",
4
+ "commit": "bbda639f03e3302c5cfdf4f2cb8417dd0a5ea1f7",
5
5
  "orchestratorContractVersion": 4
6
6
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.69",
3
+ "version": "0.1.0-beta.70",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@cydm/happy-elves",
9
- "version": "0.1.0-beta.69",
9
+ "version": "0.1.0-beta.70",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@fastify/cors": "11.2.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.69",
3
+ "version": "0.1.0-beta.70",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,26 +1,19 @@
1
+ import { canonicalRelayIdentity } from "../../shared/dist/index.js";
1
2
  import { ControllerClientError } from "./errors.js";
2
3
  import { requireNonEmpty } from "./validation.js";
3
4
  export function normalizeRelayUrl(value) {
4
- const raw = requireNonEmpty(value, "relayUrl").replace(/\/+$/, "");
5
- let url;
5
+ const raw = requireNonEmpty(value, "relayUrl");
6
6
  try {
7
- url = new URL(raw);
7
+ return canonicalRelayIdentity(raw);
8
8
  }
9
- catch {
10
- throw new ControllerClientError(`Invalid relayUrl: ${value}`, "INVALID_ARGUMENT");
11
- }
12
- if (url.protocol !== "http:" && url.protocol !== "https:") {
13
- throw new ControllerClientError(`Invalid relayUrl protocol: ${url.protocol}`, "INVALID_ARGUMENT");
9
+ catch (error) {
10
+ throw new ControllerClientError(`Invalid relayUrl: ${error instanceof Error ? error.message : String(error)}`, "INVALID_ARGUMENT");
14
11
  }
15
- url.pathname = url.pathname.replace(/\/+$/, "");
16
- url.search = "";
17
- url.hash = "";
18
- return url.toString().replace(/\/+$/, "");
19
12
  }
20
13
  export function wsUrl(relayUrl) {
21
- const url = new URL(relayUrl);
14
+ const url = new URL(normalizeRelayUrl(relayUrl));
22
15
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
23
- url.pathname = "/ws";
16
+ url.pathname = `${url.pathname.replace(/\/+$/u, "")}/ws`;
24
17
  url.search = "";
25
18
  return url.toString();
26
19
  }
@@ -4,5 +4,6 @@ export * from "./diagnostics.js";
4
4
  export * from "./ids.js";
5
5
  export * from "./protocol.js";
6
6
  export * from "./process-command.js";
7
+ export * from "./relay-url.js";
7
8
  export * from "./session-name.js";
8
9
  export * from "./session-state.js";
@@ -4,5 +4,6 @@ export * from "./diagnostics.js";
4
4
  export * from "./ids.js";
5
5
  export * from "./protocol.js";
6
6
  export * from "./process-command.js";
7
+ export * from "./relay-url.js";
7
8
  export * from "./session-name.js";
8
9
  export * from "./session-state.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Return the stable identity of a relay endpoint.
3
+ *
4
+ * Relay URLs are origins with an optional base path. Credentials and URL
5
+ * metadata are never meaningful relay identity, so accepting them would let
6
+ * different callers compare or contact the same input differently.
7
+ */
8
+ export declare function canonicalRelayIdentity(value: string): string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Return the stable identity of a relay endpoint.
3
+ *
4
+ * Relay URLs are origins with an optional base path. Credentials and URL
5
+ * metadata are never meaningful relay identity, so accepting them would let
6
+ * different callers compare or contact the same input differently.
7
+ */
8
+ export function canonicalRelayIdentity(value) {
9
+ if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
10
+ throw new Error("Relay URL must be a non-empty URL without surrounding whitespace.");
11
+ }
12
+ let url;
13
+ try {
14
+ url = new URL(value);
15
+ }
16
+ catch {
17
+ throw new Error("Relay URL is invalid.");
18
+ }
19
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
20
+ throw new Error("Relay URL must use http or https.");
21
+ }
22
+ const authorityStart = value.indexOf("//");
23
+ const authority = authorityStart >= 0
24
+ ? value.slice(authorityStart + 2).split(/[/?#]/u, 1)[0]
25
+ : "";
26
+ if (url.username || url.password || authority.includes("@")) {
27
+ throw new Error("Relay URL must not contain userinfo.");
28
+ }
29
+ if (value.includes("?")) {
30
+ throw new Error("Relay URL must not contain a query.");
31
+ }
32
+ if (value.includes("#")) {
33
+ throw new Error("Relay URL must not contain a fragment.");
34
+ }
35
+ url.pathname = url.pathname.replace(/\/+$/u, "");
36
+ return url.toString().replace(/\/+$/u, "");
37
+ }