@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.
- package/apps/cli/dist/commands/daemon.js +3 -8
- package/apps/cli/dist/commands/lib/bootstrap-config.js +8 -22
- package/apps/cli/dist/commands/lib/config.d.ts +1 -0
- package/apps/cli/dist/commands/lib/config.js +11 -12
- package/apps/cli/dist/commands/lib/doctor.js +15 -19
- package/apps/cli/dist/commands/lib/local-daemon.js +9 -14
- package/apps/cli/dist/commands/lib/scope.js +2 -2
- package/apps/cli/dist/commands/remote.js +2 -2
- package/apps/daemon/dist/config.js +4 -4
- package/apps/daemon/dist/pair.js +2 -6
- package/apps/daemon/dist/relay-http.js +4 -12
- package/apps/daemon/package.json +1 -1
- package/build-identity.json +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/packages/client/dist/http.js +7 -14
- package/packages/shared/dist/index.d.ts +1 -0
- package/packages/shared/dist/index.js +1 -0
- package/packages/shared/dist/relay-url.d.ts +8 -0
- package/packages/shared/dist/relay-url.js +37 -0
|
@@ -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,
|
|
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 && (
|
|
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
|
|
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 {
|
|
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 ?
|
|
35
|
-
const controllerRelayUrl = controller ?
|
|
36
|
-
const daemonRelayUrl = daemon ?
|
|
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 ??
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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" ?
|
|
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 {
|
|
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) ===
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|
package/apps/daemon/dist/pair.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/apps/daemon/package.json
CHANGED
package/build-identity.json
CHANGED
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cydm/happy-elves",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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.
|
|
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,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")
|
|
5
|
-
let url;
|
|
5
|
+
const raw = requireNonEmpty(value, "relayUrl");
|
|
6
6
|
try {
|
|
7
|
-
|
|
7
|
+
return canonicalRelayIdentity(raw);
|
|
8
8
|
}
|
|
9
|
-
catch {
|
|
10
|
-
throw new ControllerClientError(`Invalid relayUrl: ${
|
|
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
|
}
|
|
@@ -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
|
+
}
|