@muhaven/mcp 0.3.0 → 0.4.0

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
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] — 2026-05-24
11
+
12
+ ### Added
13
+
14
+ - **Wave 5 Option D OPEN-D — `muhaven-broker start` / `update` session-key
15
+ CLI.** Automates the manual last mile after a dashboard mint / revoke so
16
+ the operator no longer hand-edits `MUHAVEN_BROKER_SESSION_KEY` + restarts
17
+ the daemon:
18
+ - `muhaven-broker start --session <key|->` — bring the daemon UP on a
19
+ provided key (when it is NOT running). Refuses if a daemon is already
20
+ bound to the endpoint (points the operator at `update`).
21
+ - `muhaven-broker update --session <key|->` — ROTATE the key on a
22
+ (possibly) running daemon: stop → swap → restart, **reusing the
23
+ existing device-flow JWT** (a key rotation does not force a fresh
24
+ device-code login). Fully stops the old daemon before the new one binds
25
+ the endpoint.
26
+ - Both accept `--session -` to read the key from stdin (keeps it out of
27
+ `ps` / shell history), and when run WITHOUT `--session` ask
28
+ interactively ("Do you have a session key from the dashboard? [Y/n]" →
29
+ masked paste). Non-TTY (CI / piped) never hangs — it requires
30
+ `--session` instead.
31
+ - `setup` gained the same interactive prompt: with no
32
+ `MUHAVEN_BROKER_SESSION_KEY` set, it asks whether you have a
33
+ dashboard-minted key (paste it) or mints a fresh one (the
34
+ fresh-install default). Scripted runs (env var set, or non-TTY) keep
35
+ the prior self-mint behavior.
36
+ - **Key-persistence model: Option B (operator decision 2026-05-24).** The
37
+ resolved key is injected ONLY into the spawned daemon's child env — it
38
+ never touches disk. The daemon (`loadBrokerConfig`) and the keystore are
39
+ unchanged; the broker wire protocol is unchanged (no protocol bump). The
40
+ session key is validated (`0x` + 64 hex) and NEVER logged / echoed /
41
+ embedded in an error message.
42
+
43
+ ### Changed
44
+
45
+ - `runStop` (`broker/stop.ts`) gained an optional `clearJwtOnStop` flag
46
+ (default `true`, preserving the `stop` subcommand's behavior). `update`
47
+ passes `false` so the JWT survives the key rotation.
48
+
10
49
  ## [0.3.0] — 2026-05-23
11
50
 
12
51
  ### Added
package/dist/broker.cjs CHANGED
@@ -3,6 +3,7 @@
3
3
  var os = require('os');
4
4
  var child_process = require('child_process');
5
5
  var path = require('path');
6
+ var readline = require('readline');
6
7
  var net = require('net');
7
8
  var promises = require('fs/promises');
8
9
  var accounts = require('viem/accounts');
@@ -2366,6 +2367,63 @@ async function runBrokerDaemonCli() {
2366
2367
  await new Promise(() => {
2367
2368
  });
2368
2369
  }
2370
+
2371
+ // src/broker/session-input.ts
2372
+ var SESSION_KEY_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
2373
+ function validateSessionKeyShape(key) {
2374
+ if (key.length === 0) return "session key is empty";
2375
+ if (!key.startsWith("0x")) return "session key must be 0x-prefixed";
2376
+ if (!SESSION_KEY_HEX_RE.test(key)) {
2377
+ return `session key must be a 0x-prefixed 32-byte hex string (got ${key.length} chars; expected 66)`;
2378
+ }
2379
+ return null;
2380
+ }
2381
+ async function resolveSessionKey(opts) {
2382
+ const { sessionFlag, policy, deps } = opts;
2383
+ if (sessionFlag !== void 0) {
2384
+ let raw;
2385
+ if (sessionFlag === "-") {
2386
+ const stdin = await deps.readStdinAll();
2387
+ raw = stdin.trim();
2388
+ if (raw.length === 0) {
2389
+ return { kind: "error", message: "--session - was given but stdin was empty" };
2390
+ }
2391
+ } else {
2392
+ raw = sessionFlag.trim();
2393
+ }
2394
+ const shapeErr2 = validateSessionKeyShape(raw);
2395
+ if (shapeErr2) return { kind: "error", message: shapeErr2 };
2396
+ return { kind: "key", key: raw };
2397
+ }
2398
+ if (!deps.isTty) {
2399
+ if (policy === "mint-fallback") return { kind: "mint" };
2400
+ return {
2401
+ kind: "error",
2402
+ message: "no session key provided and stdin is not a TTY \u2014 pass --session <key> (or `--session -` to pipe it), or run `muhaven-broker setup` to mint a fresh key"
2403
+ };
2404
+ }
2405
+ const hasKey = await deps.promptYesNo(
2406
+ "Do you have a session key from the dashboard? [Y/n] "
2407
+ );
2408
+ if (!hasKey) {
2409
+ if (policy === "mint-fallback") return { kind: "mint" };
2410
+ return {
2411
+ kind: "error",
2412
+ message: "a session key is required for this command \u2014 paste the dashboard-minted key, or run `muhaven-broker setup` to mint one"
2413
+ };
2414
+ }
2415
+ const pasted = (await deps.promptSecret("Paste the session key: ")).trim();
2416
+ const shapeErr = validateSessionKeyShape(pasted);
2417
+ if (shapeErr) {
2418
+ return {
2419
+ kind: "error",
2420
+ message: `${shapeErr} \u2014 re-run and paste the key from the dashboard's session-reveal modal`
2421
+ };
2422
+ }
2423
+ return { kind: "key", key: pasted };
2424
+ }
2425
+
2426
+ // src/broker/setup.ts
2369
2427
  var DANGEROUS_NODE_ENV_VARS = [
2370
2428
  "NODE_OPTIONS",
2371
2429
  "NODE_TLS_REJECT_UNAUTHORIZED",
@@ -2453,6 +2511,26 @@ function validateBrokerEndpointFlag(value, platformId) {
2453
2511
  }
2454
2512
  return null;
2455
2513
  }
2514
+ var LOGIN_SEED_ENV_KEYS = [
2515
+ "MUHAVEN_BACKEND_URL",
2516
+ "MUHAVEN_DASHBOARD_URL",
2517
+ "MUHAVEN_BROKER_ENDPOINT"
2518
+ ];
2519
+ async function withSeededLoginEnv(effectiveEnv, fn) {
2520
+ const originalValues = {};
2521
+ for (const k of LOGIN_SEED_ENV_KEYS) {
2522
+ originalValues[k] = process.env[k];
2523
+ if (effectiveEnv[k]) process.env[k] = effectiveEnv[k];
2524
+ }
2525
+ try {
2526
+ return await fn();
2527
+ } finally {
2528
+ for (const k of LOGIN_SEED_ENV_KEYS) {
2529
+ if (originalValues[k] === void 0) delete process.env[k];
2530
+ else process.env[k] = originalValues[k];
2531
+ }
2532
+ }
2533
+ }
2456
2534
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2457
2535
  async function waitForBroker(options) {
2458
2536
  const timeoutMs = options.timeoutMs ?? 8e3;
@@ -2702,12 +2780,32 @@ async function runSetup(argv, deps) {
2702
2780
  }
2703
2781
  let sessionKey = effectiveEnv.MUHAVEN_BROKER_SESSION_KEY;
2704
2782
  let mintedKey = false;
2705
- if (!sessionKey || sessionKey === "") {
2706
- sessionKey = deps.mintSessionKey();
2707
- mintedKey = true;
2708
- deps.print("Session key: minted fresh (secp256k1, ephemeral to this daemon).");
2709
- } else {
2783
+ if (sessionKey && sessionKey.length > 0) {
2710
2784
  deps.print("Session key: using MUHAVEN_BROKER_SESSION_KEY from env.");
2785
+ } else {
2786
+ const sessionInput = deps.sessionInput ?? {
2787
+ isTty: false,
2788
+ readStdinAll: async () => "",
2789
+ promptYesNo: async () => false,
2790
+ promptSecret: async () => ""
2791
+ };
2792
+ const resolution = await resolveSessionKey({
2793
+ sessionFlag: void 0,
2794
+ policy: "mint-fallback",
2795
+ deps: sessionInput
2796
+ });
2797
+ if (resolution.kind === "error") {
2798
+ deps.printErr(`error: ${resolution.message}`);
2799
+ return 2;
2800
+ }
2801
+ if (resolution.kind === "key") {
2802
+ sessionKey = resolution.key;
2803
+ deps.print("Session key: using the pasted dashboard key.");
2804
+ } else {
2805
+ sessionKey = deps.mintSessionKey();
2806
+ mintedKey = true;
2807
+ deps.print("Session key: minted fresh (secp256k1, ephemeral to this daemon).");
2808
+ }
2711
2809
  }
2712
2810
  effectiveEnv.MUHAVEN_BROKER_SESSION_KEY = sessionKey;
2713
2811
  if (flags.foreground) {
@@ -2791,21 +2889,7 @@ async function runSetup(argv, deps) {
2791
2889
  if (flags.dashboardBaseUrl) {
2792
2890
  loginArgv.push("--dashboard-base-url", flags.dashboardBaseUrl);
2793
2891
  }
2794
- const restorationKeys = ["MUHAVEN_BACKEND_URL", "MUHAVEN_DASHBOARD_URL", "MUHAVEN_BROKER_ENDPOINT"];
2795
- const originalValues = {};
2796
- for (const k of restorationKeys) {
2797
- originalValues[k] = process.env[k];
2798
- if (effectiveEnv[k]) process.env[k] = effectiveEnv[k];
2799
- }
2800
- let code;
2801
- try {
2802
- code = await deps.runLogin(loginArgv);
2803
- } finally {
2804
- for (const k of restorationKeys) {
2805
- if (originalValues[k] === void 0) delete process.env[k];
2806
- else process.env[k] = originalValues[k];
2807
- }
2808
- }
2892
+ const code = await withSeededLoginEnv(effectiveEnv, () => deps.runLogin(loginArgv));
2809
2893
  if (code !== 0) {
2810
2894
  deps.printErr(
2811
2895
  "Setup: login step failed \u2014 daemon is still running, re-run `muhaven-broker login` to retry."
@@ -2889,13 +2973,15 @@ async function runStop(deps) {
2889
2973
  deps.print("Broker daemon: not running, nothing to stop.");
2890
2974
  return 0;
2891
2975
  }
2892
- try {
2893
- await broker.clearJwt();
2894
- deps.print("JWT cleared from keystore.");
2895
- } catch (err) {
2896
- deps.print(
2897
- `Warning: clearJwt failed (${err instanceof Error ? err.message : String(err)}); continuing with daemon shutdown.`
2898
- );
2976
+ if (deps.clearJwtOnStop ?? true) {
2977
+ try {
2978
+ await broker.clearJwt();
2979
+ deps.print("JWT cleared from keystore.");
2980
+ } catch (err) {
2981
+ deps.print(
2982
+ `Warning: clearJwt failed (${err instanceof Error ? err.message : String(err)}); continuing with daemon shutdown.`
2983
+ );
2984
+ }
2899
2985
  }
2900
2986
  const pid = hello.pid;
2901
2987
  if (pid === void 0) {
@@ -2953,6 +3039,190 @@ function defaultKillProcess(pid, signal) {
2953
3039
  }
2954
3040
  }
2955
3041
 
3042
+ // src/broker/bring-up.ts
3043
+ function parseBringUpFlags(argv) {
3044
+ let session;
3045
+ let noLaunchBrowser = false;
3046
+ let skipLogin = false;
3047
+ let brokerEndpoint;
3048
+ let backendBaseUrl;
3049
+ let dashboardBaseUrl;
3050
+ for (let i = 0; i < argv.length; i++) {
3051
+ const a = argv[i];
3052
+ if (a === "--no-launch-browser") noLaunchBrowser = true;
3053
+ else if (a === "--skip-login") skipLogin = true;
3054
+ else if (a === "--session") {
3055
+ const next = argv[i + 1];
3056
+ if (next === void 0) {
3057
+ throw new Error("--session requires a value (a 0x\u2026 key, or `-` to read from stdin)");
3058
+ }
3059
+ if (next !== "-" && next.startsWith("-")) {
3060
+ throw new Error(`--session requires a key value (or \`-\` for stdin), got flag: ${next}`);
3061
+ }
3062
+ session = argv[++i];
3063
+ } else if (a === "--broker-endpoint" && i + 1 < argv.length) brokerEndpoint = argv[++i];
3064
+ else if (a === "--backend-base-url" && i + 1 < argv.length) backendBaseUrl = argv[++i];
3065
+ else if (a === "--dashboard-base-url" && i + 1 < argv.length) dashboardBaseUrl = argv[++i];
3066
+ else throw new Error(`unknown flag: ${a}`);
3067
+ }
3068
+ return { session, noLaunchBrowser, skipLogin, brokerEndpoint, backendBaseUrl, dashboardBaseUrl };
3069
+ }
3070
+ function usageLine(mode) {
3071
+ return `usage: muhaven-broker ${mode} --session <key|-> [--no-launch-browser] [--skip-login]
3072
+ [--broker-endpoint PATH] [--backend-base-url URL]
3073
+ [--dashboard-base-url URL]
3074
+ (omit --session to be asked interactively; pipe the key with \`--session -\`)`;
3075
+ }
3076
+ async function runBringUp(mode, argv, deps) {
3077
+ let flags;
3078
+ try {
3079
+ flags = parseBringUpFlags(argv);
3080
+ } catch (err) {
3081
+ deps.printErr(`error: ${err.message}`);
3082
+ deps.printErr(usageLine(mode));
3083
+ return 2;
3084
+ }
3085
+ if (flags.backendBaseUrl) {
3086
+ const e = validateHttpUrlFlag("--backend-base-url", flags.backendBaseUrl);
3087
+ if (e) {
3088
+ deps.printErr(`error: ${e}`);
3089
+ return 2;
3090
+ }
3091
+ }
3092
+ if (flags.dashboardBaseUrl) {
3093
+ const e = validateHttpUrlFlag("--dashboard-base-url", flags.dashboardBaseUrl);
3094
+ if (e) {
3095
+ deps.printErr(`error: ${e}`);
3096
+ return 2;
3097
+ }
3098
+ }
3099
+ if (flags.brokerEndpoint) {
3100
+ const e = validateBrokerEndpointFlag(flags.brokerEndpoint, deps.platformId);
3101
+ if (e) {
3102
+ deps.printErr(`error: ${e}`);
3103
+ return 2;
3104
+ }
3105
+ }
3106
+ const resolution = await resolveSessionKey({
3107
+ sessionFlag: flags.session,
3108
+ policy: "require",
3109
+ deps: deps.sessionPrompt
3110
+ });
3111
+ if (resolution.kind !== "key") {
3112
+ const message = resolution.kind === "error" ? resolution.message : "no session key resolved";
3113
+ deps.printErr(`error: ${message}`);
3114
+ return 2;
3115
+ }
3116
+ const sessionKey = resolution.key;
3117
+ const overrides = applyEnvDefaults({
3118
+ env: deps.env,
3119
+ platformId: deps.platformId,
3120
+ osRelease: deps.osRelease
3121
+ });
3122
+ const effectiveEnv = {};
3123
+ for (const [k, v] of Object.entries(deps.env)) {
3124
+ if (typeof v === "string") effectiveEnv[k] = v;
3125
+ }
3126
+ for (const [k, v] of Object.entries(overrides.toSet)) effectiveEnv[k] = v;
3127
+ if (flags.brokerEndpoint) effectiveEnv.MUHAVEN_BROKER_ENDPOINT = flags.brokerEndpoint;
3128
+ if (flags.backendBaseUrl) effectiveEnv.MUHAVEN_BACKEND_URL = flags.backendBaseUrl;
3129
+ if (flags.dashboardBaseUrl) effectiveEnv.MUHAVEN_DASHBOARD_URL = flags.dashboardBaseUrl;
3130
+ for (const name of overrides.preserved) deps.print(`Env preserved: ${name} (set in your shell)`);
3131
+ for (const [k, v] of Object.entries(overrides.toSet)) deps.print(`Env defaulted: ${k}=${v}`);
3132
+ const config = loadMcpConfig(effectiveEnv);
3133
+ const broker = deps.newBrokerClient(config.brokerEndpoint, config.brokerTimeoutMs);
3134
+ let running = false;
3135
+ try {
3136
+ await broker.hello();
3137
+ running = true;
3138
+ } catch {
3139
+ running = false;
3140
+ }
3141
+ if (mode === "start") {
3142
+ if (running) {
3143
+ deps.printErr(
3144
+ `Broker daemon is already running at ${config.brokerEndpoint}. To rotate its key use: muhaven-broker update --session <key>`
3145
+ );
3146
+ return 1;
3147
+ }
3148
+ deps.print("Broker daemon: not running \u2014 starting one (detached) on the provided key ...");
3149
+ } else {
3150
+ if (running) {
3151
+ deps.print("Broker daemon: running \u2014 stopping it before installing the new key ...");
3152
+ const stopCode = await deps.stopDaemon(config.brokerEndpoint, config.brokerTimeoutMs);
3153
+ if (stopCode !== 0) {
3154
+ deps.printErr(
3155
+ `Broker daemon stop returned ${stopCode}; refusing to start a second daemon on the same endpoint. Resolve the running daemon (muhaven-broker doctor) and retry.`
3156
+ );
3157
+ return stopCode;
3158
+ }
3159
+ } else {
3160
+ deps.print("Broker daemon: not running \u2014 `update` will start a fresh one on the provided key.");
3161
+ }
3162
+ }
3163
+ const daemonPid = deps.spawnDaemon({
3164
+ binPath: deps.resolveBinPath(),
3165
+ env: {
3166
+ ...overrides.toSet,
3167
+ MUHAVEN_BROKER_ENDPOINT: config.brokerEndpoint,
3168
+ MUHAVEN_BACKEND_URL: effectiveEnv.MUHAVEN_BACKEND_URL,
3169
+ MUHAVEN_DASHBOARD_URL: effectiveEnv.MUHAVEN_DASHBOARD_URL,
3170
+ MUHAVEN_BROKER_SESSION_KEY: sessionKey
3171
+ }
3172
+ });
3173
+ let ready;
3174
+ try {
3175
+ ready = await deps.waitForBroker({ broker });
3176
+ } catch (err) {
3177
+ deps.printErr(err.message);
3178
+ deps.printErr(
3179
+ " hint: check that no other broker is bound to the same endpoint (muhaven-broker doctor)."
3180
+ );
3181
+ return 1;
3182
+ }
3183
+ deps.print(`Broker daemon: ready (PID ${daemonPid}, endpoint ${config.brokerEndpoint}).`);
3184
+ try {
3185
+ const h = await broker.hello();
3186
+ const hasKey = h.hasSessionKey ?? true;
3187
+ if (!hasKey) {
3188
+ deps.printErr(
3189
+ "Broker came up in READ-ONLY posture \u2014 the session key did not reach the daemon, so it cannot sign. Stop it (muhaven-broker stop) and retry."
3190
+ );
3191
+ return 1;
3192
+ }
3193
+ if (h.sessionKeyAddress) deps.print(`Broker signer: ${h.sessionKeyAddress}`);
3194
+ } catch {
3195
+ }
3196
+ if (flags.skipLogin) {
3197
+ deps.print("Login: skipped per --skip-login.");
3198
+ } else if (ready.hasJwt) {
3199
+ deps.print("Login: skipped \u2014 JWT already in keystore (reused).");
3200
+ } else {
3201
+ const loginArgv = [];
3202
+ if (flags.noLaunchBrowser) loginArgv.push("--no-launch-browser");
3203
+ if (flags.brokerEndpoint) loginArgv.push("--broker-endpoint", flags.brokerEndpoint);
3204
+ if (flags.backendBaseUrl) loginArgv.push("--backend-base-url", flags.backendBaseUrl);
3205
+ if (flags.dashboardBaseUrl) loginArgv.push("--dashboard-base-url", flags.dashboardBaseUrl);
3206
+ const code = await withSeededLoginEnv(effectiveEnv, () => deps.runLogin(loginArgv));
3207
+ if (code !== 0) {
3208
+ deps.printErr(
3209
+ "Login step failed \u2014 the daemon is running on the new key; re-run `muhaven-broker login` to retry."
3210
+ );
3211
+ return code;
3212
+ }
3213
+ }
3214
+ deps.print("");
3215
+ deps.print("================================");
3216
+ deps.print(mode === "start" ? "Broker started." : "Session key rotated.");
3217
+ deps.print(` Daemon PID : ${daemonPid}`);
3218
+ const killCmd = deps.platformId === "win32" ? `Stop-Process -Id ${daemonPid}` : `kill ${daemonPid}`;
3219
+ deps.print(` Stop daemon: ${killCmd} (or: muhaven-broker stop)`);
3220
+ deps.print(` Endpoint : ${config.brokerEndpoint}`);
3221
+ deps.print(" Rotate key : muhaven-broker update --session <new-key>");
3222
+ deps.print("================================");
3223
+ return 0;
3224
+ }
3225
+
2956
3226
  // src/broker/cli.ts
2957
3227
  function print(line) {
2958
3228
  process.stdout.write(line + "\n");
@@ -3265,6 +3535,11 @@ function printUsage() {
3265
3535
  print(" (claude-code today; claude-desktop / cursor reserved for Wave 5)");
3266
3536
  print(" [--register-scope user|project|local] scope for the host-config write");
3267
3537
  print(" (default: user \u2014 every project sees the server)");
3538
+ print(" start Bring the daemon up on a DASHBOARD-minted session key (daemon NOT running)");
3539
+ print(" --session <key|-> the key (or `-` to read it from stdin); omit to be");
3540
+ print(" asked interactively. [--skip-login] [--no-launch-browser]");
3541
+ print(" update Rotate the session key on a running daemon (stop \u2192 swap \u2192 restart,");
3542
+ print(" reusing the existing JWT). --session <key|-> (or interactive).");
3268
3543
  print(" stop Cleanly stop a running daemon (SIGTERM with SIGKILL fallback");
3269
3544
  print(" after 5s). Also clears the keystore JWT as a best effort.");
3270
3545
  print(" login Acquire a JWT via the device-code flow + store in keystore");
@@ -3276,7 +3551,7 @@ function printUsage() {
3276
3551
  }
3277
3552
  function getBrokerPackageVersion() {
3278
3553
  {
3279
- return "0.3.0";
3554
+ return "0.4.0";
3280
3555
  }
3281
3556
  }
3282
3557
  function printVersion() {
@@ -3312,6 +3587,66 @@ function defaultShellOut(cmd, argv) {
3312
3587
  });
3313
3588
  });
3314
3589
  }
3590
+ function stdinIsTty() {
3591
+ return process.stdin.isTTY === true;
3592
+ }
3593
+ function promptYesNo(question) {
3594
+ return new Promise((resolve) => {
3595
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
3596
+ rl.question(question, (answer) => {
3597
+ rl.close();
3598
+ const a = answer.trim().toLowerCase();
3599
+ resolve(a === "" || a === "y" || a === "yes");
3600
+ });
3601
+ });
3602
+ }
3603
+ function promptSecret(question) {
3604
+ return new Promise((resolve) => {
3605
+ process.stdout.write(`${question.trimEnd()} (input hidden)
3606
+ `);
3607
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
3608
+ const rlAny = rl;
3609
+ rlAny._writeToOutput = () => {
3610
+ };
3611
+ rl.question("", (answer) => {
3612
+ rl.close();
3613
+ process.stdout.write("\n");
3614
+ resolve(answer);
3615
+ });
3616
+ });
3617
+ }
3618
+ async function readStdinAll() {
3619
+ if (process.stdin.isTTY) return "";
3620
+ const chunks = [];
3621
+ for await (const chunk of process.stdin) {
3622
+ chunks.push(Buffer.from(chunk));
3623
+ }
3624
+ return Buffer.concat(chunks).toString("utf8");
3625
+ }
3626
+ function makeSessionPromptDeps() {
3627
+ return {
3628
+ isTty: stdinIsTty(),
3629
+ readStdinAll,
3630
+ promptYesNo,
3631
+ promptSecret
3632
+ };
3633
+ }
3634
+ function makeStopDeps(clearJwtOnStop, override) {
3635
+ const resolved = override ?? (() => {
3636
+ const config = loadMcpConfig();
3637
+ return { endpoint: config.brokerEndpoint, brokerTimeoutMs: config.brokerTimeoutMs };
3638
+ })();
3639
+ return {
3640
+ print,
3641
+ printErr,
3642
+ newBrokerClient: (endpoint, timeoutMs) => new BrokerClient({ endpoint, timeoutMs }),
3643
+ killProcess: defaultKillProcess,
3644
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
3645
+ endpoint: resolved.endpoint,
3646
+ brokerTimeoutMs: resolved.brokerTimeoutMs,
3647
+ clearJwtOnStop
3648
+ };
3649
+ }
3315
3650
  async function runSetup2(argv) {
3316
3651
  const deps = {
3317
3652
  print,
@@ -3326,22 +3661,35 @@ async function runSetup2(argv) {
3326
3661
  env: process.env,
3327
3662
  platformId: process.platform,
3328
3663
  osRelease: os.release(),
3329
- shellOut: defaultShellOut
3664
+ shellOut: defaultShellOut,
3665
+ sessionInput: makeSessionPromptDeps()
3330
3666
  };
3331
3667
  return runSetup(argv, deps);
3332
3668
  }
3333
3669
  async function runStop2() {
3334
- const config = loadMcpConfig();
3335
- const deps = {
3670
+ return runStop(makeStopDeps(true));
3671
+ }
3672
+ function makeBringUpDeps() {
3673
+ return {
3336
3674
  print,
3337
3675
  printErr,
3338
3676
  newBrokerClient: (endpoint, timeoutMs) => new BrokerClient({ endpoint, timeoutMs }),
3339
- killProcess: defaultKillProcess,
3340
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
3341
- endpoint: config.brokerEndpoint,
3342
- brokerTimeoutMs: config.brokerTimeoutMs
3677
+ spawnDaemon,
3678
+ waitForBroker,
3679
+ // `update` stops the old daemon but PRESERVES the JWT (key rotation
3680
+ // must not force a device-code re-login). Targets the resolved
3681
+ // endpoint so a `--broker-endpoint` override stops the right daemon.
3682
+ stopDaemon: (endpoint, brokerTimeoutMs) => runStop(makeStopDeps(false, { endpoint, brokerTimeoutMs })),
3683
+ runLogin,
3684
+ resolveBinPath: resolveBrokerBinPath,
3685
+ env: process.env,
3686
+ platformId: process.platform,
3687
+ osRelease: os.release(),
3688
+ sessionPrompt: makeSessionPromptDeps()
3343
3689
  };
3344
- return runStop(deps);
3690
+ }
3691
+ async function runStartOrUpdate(mode, argv) {
3692
+ return runBringUp(mode, argv, makeBringUpDeps());
3345
3693
  }
3346
3694
  async function runCli(argv) {
3347
3695
  const [sub, ...rest] = argv;
@@ -3351,6 +3699,10 @@ async function runCli(argv) {
3351
3699
  return 0;
3352
3700
  case "setup":
3353
3701
  return runSetup2(rest);
3702
+ case "start":
3703
+ return runStartOrUpdate("start", rest);
3704
+ case "update":
3705
+ return runStartOrUpdate("update", rest);
3354
3706
  case "stop":
3355
3707
  return runStop2();
3356
3708
  case "login":
@@ -3381,4 +3733,5 @@ exports.runDoctor = runDoctor;
3381
3733
  exports.runLogin = runLogin;
3382
3734
  exports.runLogout = runLogout;
3383
3735
  exports.runSetup = runSetup2;
3736
+ exports.runStartOrUpdate = runStartOrUpdate;
3384
3737
  exports.runStop = runStop2;
package/dist/broker.d.cts CHANGED
@@ -1,3 +1,30 @@
1
+ /**
2
+ * `muhaven-broker start` / `muhaven-broker update` — install a
3
+ * DASHBOARD-minted Scoped session key onto the broker daemon in one shot.
4
+ *
5
+ * Wave 5 Option D OPEN-D (2026-05-24). Replaces the manual last mile after
6
+ * a dashboard mint / revoke:
7
+ *
8
+ * start — bring the daemon UP on a provided key (daemon NOT running).
9
+ * update — ROTATE the key on a (possibly) running daemon: stop → swap →
10
+ * restart → REUSE the existing JWT (a key rotation must not
11
+ * force a device-code re-login).
12
+ *
13
+ * Both resolve the key via the shared precedence (`--session` flag >
14
+ * interactive masked prompt > error — see `session-input.ts`). Both
15
+ * REQUIRE a key (unlike `setup`, which self-mints on the fresh-install
16
+ * path). The key is injected ONLY into the spawned daemon's child env
17
+ * (**Option B** — operator decision 2026-05-24); it never touches disk,
18
+ * so the daemon (`loadBrokerConfig`) and the keystore stay unchanged.
19
+ *
20
+ * The orchestrator is pure-ish: all IO (broker IPC, daemon spawn, stop,
21
+ * login, prompts) is injected via `BringUpDeps` so the decision tree is
22
+ * unit-testable without spawning real processes — mirrors the
23
+ * `runSetup` / `runStop` style.
24
+ */
25
+
26
+ type BringUpMode = 'start' | 'update';
27
+
1
28
  /**
2
29
  * `muhaven-broker` CLI subcommand router.
3
30
  *
@@ -8,6 +35,7 @@
8
35
  * doctor → environment + keystore capability report
9
36
  * --help, -h → usage
10
37
  */
38
+
11
39
  interface LoginFlags {
12
40
  noLaunchBrowser: boolean;
13
41
  brokerEndpoint?: string;
@@ -43,6 +71,8 @@ declare function runSetup(argv: readonly string[]): Promise<number>;
43
71
  * Wire `runStop` against the real BrokerClient + Node's process.kill.
44
72
  */
45
73
  declare function runStop(): Promise<number>;
74
+ /** Wire `muhaven-broker start` / `update` against the real cli helpers. */
75
+ declare function runStartOrUpdate(mode: BringUpMode, argv: readonly string[]): Promise<number>;
46
76
  declare function runCli(argv: readonly string[]): Promise<number>;
47
77
 
48
- export { getBrokerPackageVersion, parseLoginFlags, runCli, runDoctor, runLogin, runLogout, runSetup, runStop };
78
+ export { getBrokerPackageVersion, parseLoginFlags, runCli, runDoctor, runLogin, runLogout, runSetup, runStartOrUpdate, runStop };
package/dist/broker.d.ts CHANGED
@@ -1,3 +1,30 @@
1
+ /**
2
+ * `muhaven-broker start` / `muhaven-broker update` — install a
3
+ * DASHBOARD-minted Scoped session key onto the broker daemon in one shot.
4
+ *
5
+ * Wave 5 Option D OPEN-D (2026-05-24). Replaces the manual last mile after
6
+ * a dashboard mint / revoke:
7
+ *
8
+ * start — bring the daemon UP on a provided key (daemon NOT running).
9
+ * update — ROTATE the key on a (possibly) running daemon: stop → swap →
10
+ * restart → REUSE the existing JWT (a key rotation must not
11
+ * force a device-code re-login).
12
+ *
13
+ * Both resolve the key via the shared precedence (`--session` flag >
14
+ * interactive masked prompt > error — see `session-input.ts`). Both
15
+ * REQUIRE a key (unlike `setup`, which self-mints on the fresh-install
16
+ * path). The key is injected ONLY into the spawned daemon's child env
17
+ * (**Option B** — operator decision 2026-05-24); it never touches disk,
18
+ * so the daemon (`loadBrokerConfig`) and the keystore stay unchanged.
19
+ *
20
+ * The orchestrator is pure-ish: all IO (broker IPC, daemon spawn, stop,
21
+ * login, prompts) is injected via `BringUpDeps` so the decision tree is
22
+ * unit-testable without spawning real processes — mirrors the
23
+ * `runSetup` / `runStop` style.
24
+ */
25
+
26
+ type BringUpMode = 'start' | 'update';
27
+
1
28
  /**
2
29
  * `muhaven-broker` CLI subcommand router.
3
30
  *
@@ -8,6 +35,7 @@
8
35
  * doctor → environment + keystore capability report
9
36
  * --help, -h → usage
10
37
  */
38
+
11
39
  interface LoginFlags {
12
40
  noLaunchBrowser: boolean;
13
41
  brokerEndpoint?: string;
@@ -43,6 +71,8 @@ declare function runSetup(argv: readonly string[]): Promise<number>;
43
71
  * Wire `runStop` against the real BrokerClient + Node's process.kill.
44
72
  */
45
73
  declare function runStop(): Promise<number>;
74
+ /** Wire `muhaven-broker start` / `update` against the real cli helpers. */
75
+ declare function runStartOrUpdate(mode: BringUpMode, argv: readonly string[]): Promise<number>;
46
76
  declare function runCli(argv: readonly string[]): Promise<number>;
47
77
 
48
- export { getBrokerPackageVersion, parseLoginFlags, runCli, runDoctor, runLogin, runLogout, runSetup, runStop };
78
+ export { getBrokerPackageVersion, parseLoginFlags, runCli, runDoctor, runLogin, runLogout, runSetup, runStartOrUpdate, runStop };
package/dist/broker.js CHANGED
@@ -2,6 +2,7 @@ import path, { join, dirname, resolve } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { platform, release, hostname, homedir } from 'os';
4
4
  import { exec, spawn } from 'child_process';
5
+ import { createInterface } from 'readline';
5
6
  import { connect, createServer } from 'net';
6
7
  import { mkdir, chmod, writeFile, readFile, unlink, rename, readdir, stat } from 'fs/promises';
7
8
  import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
@@ -2368,6 +2369,63 @@ async function runBrokerDaemonCli() {
2368
2369
  await new Promise(() => {
2369
2370
  });
2370
2371
  }
2372
+
2373
+ // src/broker/session-input.ts
2374
+ var SESSION_KEY_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
2375
+ function validateSessionKeyShape(key) {
2376
+ if (key.length === 0) return "session key is empty";
2377
+ if (!key.startsWith("0x")) return "session key must be 0x-prefixed";
2378
+ if (!SESSION_KEY_HEX_RE.test(key)) {
2379
+ return `session key must be a 0x-prefixed 32-byte hex string (got ${key.length} chars; expected 66)`;
2380
+ }
2381
+ return null;
2382
+ }
2383
+ async function resolveSessionKey(opts) {
2384
+ const { sessionFlag, policy, deps } = opts;
2385
+ if (sessionFlag !== void 0) {
2386
+ let raw;
2387
+ if (sessionFlag === "-") {
2388
+ const stdin = await deps.readStdinAll();
2389
+ raw = stdin.trim();
2390
+ if (raw.length === 0) {
2391
+ return { kind: "error", message: "--session - was given but stdin was empty" };
2392
+ }
2393
+ } else {
2394
+ raw = sessionFlag.trim();
2395
+ }
2396
+ const shapeErr2 = validateSessionKeyShape(raw);
2397
+ if (shapeErr2) return { kind: "error", message: shapeErr2 };
2398
+ return { kind: "key", key: raw };
2399
+ }
2400
+ if (!deps.isTty) {
2401
+ if (policy === "mint-fallback") return { kind: "mint" };
2402
+ return {
2403
+ kind: "error",
2404
+ message: "no session key provided and stdin is not a TTY \u2014 pass --session <key> (or `--session -` to pipe it), or run `muhaven-broker setup` to mint a fresh key"
2405
+ };
2406
+ }
2407
+ const hasKey = await deps.promptYesNo(
2408
+ "Do you have a session key from the dashboard? [Y/n] "
2409
+ );
2410
+ if (!hasKey) {
2411
+ if (policy === "mint-fallback") return { kind: "mint" };
2412
+ return {
2413
+ kind: "error",
2414
+ message: "a session key is required for this command \u2014 paste the dashboard-minted key, or run `muhaven-broker setup` to mint one"
2415
+ };
2416
+ }
2417
+ const pasted = (await deps.promptSecret("Paste the session key: ")).trim();
2418
+ const shapeErr = validateSessionKeyShape(pasted);
2419
+ if (shapeErr) {
2420
+ return {
2421
+ kind: "error",
2422
+ message: `${shapeErr} \u2014 re-run and paste the key from the dashboard's session-reveal modal`
2423
+ };
2424
+ }
2425
+ return { kind: "key", key: pasted };
2426
+ }
2427
+
2428
+ // src/broker/setup.ts
2371
2429
  var DANGEROUS_NODE_ENV_VARS = [
2372
2430
  "NODE_OPTIONS",
2373
2431
  "NODE_TLS_REJECT_UNAUTHORIZED",
@@ -2455,6 +2513,26 @@ function validateBrokerEndpointFlag(value, platformId) {
2455
2513
  }
2456
2514
  return null;
2457
2515
  }
2516
+ var LOGIN_SEED_ENV_KEYS = [
2517
+ "MUHAVEN_BACKEND_URL",
2518
+ "MUHAVEN_DASHBOARD_URL",
2519
+ "MUHAVEN_BROKER_ENDPOINT"
2520
+ ];
2521
+ async function withSeededLoginEnv(effectiveEnv, fn) {
2522
+ const originalValues = {};
2523
+ for (const k of LOGIN_SEED_ENV_KEYS) {
2524
+ originalValues[k] = process.env[k];
2525
+ if (effectiveEnv[k]) process.env[k] = effectiveEnv[k];
2526
+ }
2527
+ try {
2528
+ return await fn();
2529
+ } finally {
2530
+ for (const k of LOGIN_SEED_ENV_KEYS) {
2531
+ if (originalValues[k] === void 0) delete process.env[k];
2532
+ else process.env[k] = originalValues[k];
2533
+ }
2534
+ }
2535
+ }
2458
2536
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2459
2537
  async function waitForBroker(options) {
2460
2538
  const timeoutMs = options.timeoutMs ?? 8e3;
@@ -2704,12 +2782,32 @@ async function runSetup(argv, deps) {
2704
2782
  }
2705
2783
  let sessionKey = effectiveEnv.MUHAVEN_BROKER_SESSION_KEY;
2706
2784
  let mintedKey = false;
2707
- if (!sessionKey || sessionKey === "") {
2708
- sessionKey = deps.mintSessionKey();
2709
- mintedKey = true;
2710
- deps.print("Session key: minted fresh (secp256k1, ephemeral to this daemon).");
2711
- } else {
2785
+ if (sessionKey && sessionKey.length > 0) {
2712
2786
  deps.print("Session key: using MUHAVEN_BROKER_SESSION_KEY from env.");
2787
+ } else {
2788
+ const sessionInput = deps.sessionInput ?? {
2789
+ isTty: false,
2790
+ readStdinAll: async () => "",
2791
+ promptYesNo: async () => false,
2792
+ promptSecret: async () => ""
2793
+ };
2794
+ const resolution = await resolveSessionKey({
2795
+ sessionFlag: void 0,
2796
+ policy: "mint-fallback",
2797
+ deps: sessionInput
2798
+ });
2799
+ if (resolution.kind === "error") {
2800
+ deps.printErr(`error: ${resolution.message}`);
2801
+ return 2;
2802
+ }
2803
+ if (resolution.kind === "key") {
2804
+ sessionKey = resolution.key;
2805
+ deps.print("Session key: using the pasted dashboard key.");
2806
+ } else {
2807
+ sessionKey = deps.mintSessionKey();
2808
+ mintedKey = true;
2809
+ deps.print("Session key: minted fresh (secp256k1, ephemeral to this daemon).");
2810
+ }
2713
2811
  }
2714
2812
  effectiveEnv.MUHAVEN_BROKER_SESSION_KEY = sessionKey;
2715
2813
  if (flags.foreground) {
@@ -2793,21 +2891,7 @@ async function runSetup(argv, deps) {
2793
2891
  if (flags.dashboardBaseUrl) {
2794
2892
  loginArgv.push("--dashboard-base-url", flags.dashboardBaseUrl);
2795
2893
  }
2796
- const restorationKeys = ["MUHAVEN_BACKEND_URL", "MUHAVEN_DASHBOARD_URL", "MUHAVEN_BROKER_ENDPOINT"];
2797
- const originalValues = {};
2798
- for (const k of restorationKeys) {
2799
- originalValues[k] = process.env[k];
2800
- if (effectiveEnv[k]) process.env[k] = effectiveEnv[k];
2801
- }
2802
- let code;
2803
- try {
2804
- code = await deps.runLogin(loginArgv);
2805
- } finally {
2806
- for (const k of restorationKeys) {
2807
- if (originalValues[k] === void 0) delete process.env[k];
2808
- else process.env[k] = originalValues[k];
2809
- }
2810
- }
2894
+ const code = await withSeededLoginEnv(effectiveEnv, () => deps.runLogin(loginArgv));
2811
2895
  if (code !== 0) {
2812
2896
  deps.printErr(
2813
2897
  "Setup: login step failed \u2014 daemon is still running, re-run `muhaven-broker login` to retry."
@@ -2891,13 +2975,15 @@ async function runStop(deps) {
2891
2975
  deps.print("Broker daemon: not running, nothing to stop.");
2892
2976
  return 0;
2893
2977
  }
2894
- try {
2895
- await broker.clearJwt();
2896
- deps.print("JWT cleared from keystore.");
2897
- } catch (err) {
2898
- deps.print(
2899
- `Warning: clearJwt failed (${err instanceof Error ? err.message : String(err)}); continuing with daemon shutdown.`
2900
- );
2978
+ if (deps.clearJwtOnStop ?? true) {
2979
+ try {
2980
+ await broker.clearJwt();
2981
+ deps.print("JWT cleared from keystore.");
2982
+ } catch (err) {
2983
+ deps.print(
2984
+ `Warning: clearJwt failed (${err instanceof Error ? err.message : String(err)}); continuing with daemon shutdown.`
2985
+ );
2986
+ }
2901
2987
  }
2902
2988
  const pid = hello.pid;
2903
2989
  if (pid === void 0) {
@@ -2955,6 +3041,190 @@ function defaultKillProcess(pid, signal) {
2955
3041
  }
2956
3042
  }
2957
3043
 
3044
+ // src/broker/bring-up.ts
3045
+ function parseBringUpFlags(argv) {
3046
+ let session;
3047
+ let noLaunchBrowser = false;
3048
+ let skipLogin = false;
3049
+ let brokerEndpoint;
3050
+ let backendBaseUrl;
3051
+ let dashboardBaseUrl;
3052
+ for (let i = 0; i < argv.length; i++) {
3053
+ const a = argv[i];
3054
+ if (a === "--no-launch-browser") noLaunchBrowser = true;
3055
+ else if (a === "--skip-login") skipLogin = true;
3056
+ else if (a === "--session") {
3057
+ const next = argv[i + 1];
3058
+ if (next === void 0) {
3059
+ throw new Error("--session requires a value (a 0x\u2026 key, or `-` to read from stdin)");
3060
+ }
3061
+ if (next !== "-" && next.startsWith("-")) {
3062
+ throw new Error(`--session requires a key value (or \`-\` for stdin), got flag: ${next}`);
3063
+ }
3064
+ session = argv[++i];
3065
+ } else if (a === "--broker-endpoint" && i + 1 < argv.length) brokerEndpoint = argv[++i];
3066
+ else if (a === "--backend-base-url" && i + 1 < argv.length) backendBaseUrl = argv[++i];
3067
+ else if (a === "--dashboard-base-url" && i + 1 < argv.length) dashboardBaseUrl = argv[++i];
3068
+ else throw new Error(`unknown flag: ${a}`);
3069
+ }
3070
+ return { session, noLaunchBrowser, skipLogin, brokerEndpoint, backendBaseUrl, dashboardBaseUrl };
3071
+ }
3072
+ function usageLine(mode) {
3073
+ return `usage: muhaven-broker ${mode} --session <key|-> [--no-launch-browser] [--skip-login]
3074
+ [--broker-endpoint PATH] [--backend-base-url URL]
3075
+ [--dashboard-base-url URL]
3076
+ (omit --session to be asked interactively; pipe the key with \`--session -\`)`;
3077
+ }
3078
+ async function runBringUp(mode, argv, deps) {
3079
+ let flags;
3080
+ try {
3081
+ flags = parseBringUpFlags(argv);
3082
+ } catch (err) {
3083
+ deps.printErr(`error: ${err.message}`);
3084
+ deps.printErr(usageLine(mode));
3085
+ return 2;
3086
+ }
3087
+ if (flags.backendBaseUrl) {
3088
+ const e = validateHttpUrlFlag("--backend-base-url", flags.backendBaseUrl);
3089
+ if (e) {
3090
+ deps.printErr(`error: ${e}`);
3091
+ return 2;
3092
+ }
3093
+ }
3094
+ if (flags.dashboardBaseUrl) {
3095
+ const e = validateHttpUrlFlag("--dashboard-base-url", flags.dashboardBaseUrl);
3096
+ if (e) {
3097
+ deps.printErr(`error: ${e}`);
3098
+ return 2;
3099
+ }
3100
+ }
3101
+ if (flags.brokerEndpoint) {
3102
+ const e = validateBrokerEndpointFlag(flags.brokerEndpoint, deps.platformId);
3103
+ if (e) {
3104
+ deps.printErr(`error: ${e}`);
3105
+ return 2;
3106
+ }
3107
+ }
3108
+ const resolution = await resolveSessionKey({
3109
+ sessionFlag: flags.session,
3110
+ policy: "require",
3111
+ deps: deps.sessionPrompt
3112
+ });
3113
+ if (resolution.kind !== "key") {
3114
+ const message = resolution.kind === "error" ? resolution.message : "no session key resolved";
3115
+ deps.printErr(`error: ${message}`);
3116
+ return 2;
3117
+ }
3118
+ const sessionKey = resolution.key;
3119
+ const overrides = applyEnvDefaults({
3120
+ env: deps.env,
3121
+ platformId: deps.platformId,
3122
+ osRelease: deps.osRelease
3123
+ });
3124
+ const effectiveEnv = {};
3125
+ for (const [k, v] of Object.entries(deps.env)) {
3126
+ if (typeof v === "string") effectiveEnv[k] = v;
3127
+ }
3128
+ for (const [k, v] of Object.entries(overrides.toSet)) effectiveEnv[k] = v;
3129
+ if (flags.brokerEndpoint) effectiveEnv.MUHAVEN_BROKER_ENDPOINT = flags.brokerEndpoint;
3130
+ if (flags.backendBaseUrl) effectiveEnv.MUHAVEN_BACKEND_URL = flags.backendBaseUrl;
3131
+ if (flags.dashboardBaseUrl) effectiveEnv.MUHAVEN_DASHBOARD_URL = flags.dashboardBaseUrl;
3132
+ for (const name of overrides.preserved) deps.print(`Env preserved: ${name} (set in your shell)`);
3133
+ for (const [k, v] of Object.entries(overrides.toSet)) deps.print(`Env defaulted: ${k}=${v}`);
3134
+ const config = loadMcpConfig(effectiveEnv);
3135
+ const broker = deps.newBrokerClient(config.brokerEndpoint, config.brokerTimeoutMs);
3136
+ let running = false;
3137
+ try {
3138
+ await broker.hello();
3139
+ running = true;
3140
+ } catch {
3141
+ running = false;
3142
+ }
3143
+ if (mode === "start") {
3144
+ if (running) {
3145
+ deps.printErr(
3146
+ `Broker daemon is already running at ${config.brokerEndpoint}. To rotate its key use: muhaven-broker update --session <key>`
3147
+ );
3148
+ return 1;
3149
+ }
3150
+ deps.print("Broker daemon: not running \u2014 starting one (detached) on the provided key ...");
3151
+ } else {
3152
+ if (running) {
3153
+ deps.print("Broker daemon: running \u2014 stopping it before installing the new key ...");
3154
+ const stopCode = await deps.stopDaemon(config.brokerEndpoint, config.brokerTimeoutMs);
3155
+ if (stopCode !== 0) {
3156
+ deps.printErr(
3157
+ `Broker daemon stop returned ${stopCode}; refusing to start a second daemon on the same endpoint. Resolve the running daemon (muhaven-broker doctor) and retry.`
3158
+ );
3159
+ return stopCode;
3160
+ }
3161
+ } else {
3162
+ deps.print("Broker daemon: not running \u2014 `update` will start a fresh one on the provided key.");
3163
+ }
3164
+ }
3165
+ const daemonPid = deps.spawnDaemon({
3166
+ binPath: deps.resolveBinPath(),
3167
+ env: {
3168
+ ...overrides.toSet,
3169
+ MUHAVEN_BROKER_ENDPOINT: config.brokerEndpoint,
3170
+ MUHAVEN_BACKEND_URL: effectiveEnv.MUHAVEN_BACKEND_URL,
3171
+ MUHAVEN_DASHBOARD_URL: effectiveEnv.MUHAVEN_DASHBOARD_URL,
3172
+ MUHAVEN_BROKER_SESSION_KEY: sessionKey
3173
+ }
3174
+ });
3175
+ let ready;
3176
+ try {
3177
+ ready = await deps.waitForBroker({ broker });
3178
+ } catch (err) {
3179
+ deps.printErr(err.message);
3180
+ deps.printErr(
3181
+ " hint: check that no other broker is bound to the same endpoint (muhaven-broker doctor)."
3182
+ );
3183
+ return 1;
3184
+ }
3185
+ deps.print(`Broker daemon: ready (PID ${daemonPid}, endpoint ${config.brokerEndpoint}).`);
3186
+ try {
3187
+ const h = await broker.hello();
3188
+ const hasKey = h.hasSessionKey ?? true;
3189
+ if (!hasKey) {
3190
+ deps.printErr(
3191
+ "Broker came up in READ-ONLY posture \u2014 the session key did not reach the daemon, so it cannot sign. Stop it (muhaven-broker stop) and retry."
3192
+ );
3193
+ return 1;
3194
+ }
3195
+ if (h.sessionKeyAddress) deps.print(`Broker signer: ${h.sessionKeyAddress}`);
3196
+ } catch {
3197
+ }
3198
+ if (flags.skipLogin) {
3199
+ deps.print("Login: skipped per --skip-login.");
3200
+ } else if (ready.hasJwt) {
3201
+ deps.print("Login: skipped \u2014 JWT already in keystore (reused).");
3202
+ } else {
3203
+ const loginArgv = [];
3204
+ if (flags.noLaunchBrowser) loginArgv.push("--no-launch-browser");
3205
+ if (flags.brokerEndpoint) loginArgv.push("--broker-endpoint", flags.brokerEndpoint);
3206
+ if (flags.backendBaseUrl) loginArgv.push("--backend-base-url", flags.backendBaseUrl);
3207
+ if (flags.dashboardBaseUrl) loginArgv.push("--dashboard-base-url", flags.dashboardBaseUrl);
3208
+ const code = await withSeededLoginEnv(effectiveEnv, () => deps.runLogin(loginArgv));
3209
+ if (code !== 0) {
3210
+ deps.printErr(
3211
+ "Login step failed \u2014 the daemon is running on the new key; re-run `muhaven-broker login` to retry."
3212
+ );
3213
+ return code;
3214
+ }
3215
+ }
3216
+ deps.print("");
3217
+ deps.print("================================");
3218
+ deps.print(mode === "start" ? "Broker started." : "Session key rotated.");
3219
+ deps.print(` Daemon PID : ${daemonPid}`);
3220
+ const killCmd = deps.platformId === "win32" ? `Stop-Process -Id ${daemonPid}` : `kill ${daemonPid}`;
3221
+ deps.print(` Stop daemon: ${killCmd} (or: muhaven-broker stop)`);
3222
+ deps.print(` Endpoint : ${config.brokerEndpoint}`);
3223
+ deps.print(" Rotate key : muhaven-broker update --session <new-key>");
3224
+ deps.print("================================");
3225
+ return 0;
3226
+ }
3227
+
2958
3228
  // src/broker/cli.ts
2959
3229
  function print(line) {
2960
3230
  process.stdout.write(line + "\n");
@@ -3267,6 +3537,11 @@ function printUsage() {
3267
3537
  print(" (claude-code today; claude-desktop / cursor reserved for Wave 5)");
3268
3538
  print(" [--register-scope user|project|local] scope for the host-config write");
3269
3539
  print(" (default: user \u2014 every project sees the server)");
3540
+ print(" start Bring the daemon up on a DASHBOARD-minted session key (daemon NOT running)");
3541
+ print(" --session <key|-> the key (or `-` to read it from stdin); omit to be");
3542
+ print(" asked interactively. [--skip-login] [--no-launch-browser]");
3543
+ print(" update Rotate the session key on a running daemon (stop \u2192 swap \u2192 restart,");
3544
+ print(" reusing the existing JWT). --session <key|-> (or interactive).");
3270
3545
  print(" stop Cleanly stop a running daemon (SIGTERM with SIGKILL fallback");
3271
3546
  print(" after 5s). Also clears the keystore JWT as a best effort.");
3272
3547
  print(" login Acquire a JWT via the device-code flow + store in keystore");
@@ -3278,7 +3553,7 @@ function printUsage() {
3278
3553
  }
3279
3554
  function getBrokerPackageVersion() {
3280
3555
  {
3281
- return "0.3.0";
3556
+ return "0.4.0";
3282
3557
  }
3283
3558
  }
3284
3559
  function printVersion() {
@@ -3314,6 +3589,66 @@ function defaultShellOut(cmd, argv) {
3314
3589
  });
3315
3590
  });
3316
3591
  }
3592
+ function stdinIsTty() {
3593
+ return process.stdin.isTTY === true;
3594
+ }
3595
+ function promptYesNo(question) {
3596
+ return new Promise((resolve) => {
3597
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3598
+ rl.question(question, (answer) => {
3599
+ rl.close();
3600
+ const a = answer.trim().toLowerCase();
3601
+ resolve(a === "" || a === "y" || a === "yes");
3602
+ });
3603
+ });
3604
+ }
3605
+ function promptSecret(question) {
3606
+ return new Promise((resolve) => {
3607
+ process.stdout.write(`${question.trimEnd()} (input hidden)
3608
+ `);
3609
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3610
+ const rlAny = rl;
3611
+ rlAny._writeToOutput = () => {
3612
+ };
3613
+ rl.question("", (answer) => {
3614
+ rl.close();
3615
+ process.stdout.write("\n");
3616
+ resolve(answer);
3617
+ });
3618
+ });
3619
+ }
3620
+ async function readStdinAll() {
3621
+ if (process.stdin.isTTY) return "";
3622
+ const chunks = [];
3623
+ for await (const chunk of process.stdin) {
3624
+ chunks.push(Buffer.from(chunk));
3625
+ }
3626
+ return Buffer.concat(chunks).toString("utf8");
3627
+ }
3628
+ function makeSessionPromptDeps() {
3629
+ return {
3630
+ isTty: stdinIsTty(),
3631
+ readStdinAll,
3632
+ promptYesNo,
3633
+ promptSecret
3634
+ };
3635
+ }
3636
+ function makeStopDeps(clearJwtOnStop, override) {
3637
+ const resolved = override ?? (() => {
3638
+ const config = loadMcpConfig();
3639
+ return { endpoint: config.brokerEndpoint, brokerTimeoutMs: config.brokerTimeoutMs };
3640
+ })();
3641
+ return {
3642
+ print,
3643
+ printErr,
3644
+ newBrokerClient: (endpoint, timeoutMs) => new BrokerClient({ endpoint, timeoutMs }),
3645
+ killProcess: defaultKillProcess,
3646
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
3647
+ endpoint: resolved.endpoint,
3648
+ brokerTimeoutMs: resolved.brokerTimeoutMs,
3649
+ clearJwtOnStop
3650
+ };
3651
+ }
3317
3652
  async function runSetup2(argv) {
3318
3653
  const deps = {
3319
3654
  print,
@@ -3328,22 +3663,35 @@ async function runSetup2(argv) {
3328
3663
  env: process.env,
3329
3664
  platformId: process.platform,
3330
3665
  osRelease: release(),
3331
- shellOut: defaultShellOut
3666
+ shellOut: defaultShellOut,
3667
+ sessionInput: makeSessionPromptDeps()
3332
3668
  };
3333
3669
  return runSetup(argv, deps);
3334
3670
  }
3335
3671
  async function runStop2() {
3336
- const config = loadMcpConfig();
3337
- const deps = {
3672
+ return runStop(makeStopDeps(true));
3673
+ }
3674
+ function makeBringUpDeps() {
3675
+ return {
3338
3676
  print,
3339
3677
  printErr,
3340
3678
  newBrokerClient: (endpoint, timeoutMs) => new BrokerClient({ endpoint, timeoutMs }),
3341
- killProcess: defaultKillProcess,
3342
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
3343
- endpoint: config.brokerEndpoint,
3344
- brokerTimeoutMs: config.brokerTimeoutMs
3679
+ spawnDaemon,
3680
+ waitForBroker,
3681
+ // `update` stops the old daemon but PRESERVES the JWT (key rotation
3682
+ // must not force a device-code re-login). Targets the resolved
3683
+ // endpoint so a `--broker-endpoint` override stops the right daemon.
3684
+ stopDaemon: (endpoint, brokerTimeoutMs) => runStop(makeStopDeps(false, { endpoint, brokerTimeoutMs })),
3685
+ runLogin,
3686
+ resolveBinPath: resolveBrokerBinPath,
3687
+ env: process.env,
3688
+ platformId: process.platform,
3689
+ osRelease: release(),
3690
+ sessionPrompt: makeSessionPromptDeps()
3345
3691
  };
3346
- return runStop(deps);
3692
+ }
3693
+ async function runStartOrUpdate(mode, argv) {
3694
+ return runBringUp(mode, argv, makeBringUpDeps());
3347
3695
  }
3348
3696
  async function runCli(argv) {
3349
3697
  const [sub, ...rest] = argv;
@@ -3353,6 +3701,10 @@ async function runCli(argv) {
3353
3701
  return 0;
3354
3702
  case "setup":
3355
3703
  return runSetup2(rest);
3704
+ case "start":
3705
+ return runStartOrUpdate("start", rest);
3706
+ case "update":
3707
+ return runStartOrUpdate("update", rest);
3356
3708
  case "stop":
3357
3709
  return runStop2();
3358
3710
  case "login":
@@ -3376,4 +3728,4 @@ async function runCli(argv) {
3376
3728
  }
3377
3729
  }
3378
3730
 
3379
- export { getBrokerPackageVersion, parseLoginFlags, runCli, runDoctor, runLogin, runLogout, runSetup2 as runSetup, runStop2 as runStop };
3731
+ export { getBrokerPackageVersion, parseLoginFlags, runCli, runDoctor, runLogin, runLogout, runSetup2 as runSetup, runStartOrUpdate, runStop2 as runStop };
package/dist/index.cjs CHANGED
@@ -3679,7 +3679,7 @@ var SERVER_NAME = "@muhaven/mcp";
3679
3679
  var SERVER_VERSION = resolveServerVersion();
3680
3680
  function resolveServerVersion() {
3681
3681
  {
3682
- return "0.3.0";
3682
+ return "0.4.0";
3683
3683
  }
3684
3684
  }
3685
3685
  function toJsonInputSchema(schema) {
package/dist/index.js CHANGED
@@ -3675,7 +3675,7 @@ var SERVER_NAME = "@muhaven/mcp";
3675
3675
  var SERVER_VERSION = resolveServerVersion();
3676
3676
  function resolveServerVersion() {
3677
3677
  {
3678
- return "0.3.0";
3678
+ return "0.4.0";
3679
3679
  }
3680
3680
  }
3681
3681
  function toJsonInputSchema(schema) {
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.2",
4
4
  "name": "muhaven-mcp",
5
5
  "display_name": "MuHaven (RWA portfolio)",
6
- "version": "0.3.0",
6
+ "version": "0.4.0",
7
7
  "description": "Confidential RWA portfolio management on Fhenix CoFHE. Read your encrypted balances, propose yield claims and policy changes — all signing happens in a sibling broker daemon, the LLM never sees your private key.",
8
8
  "long_description": "MuHaven MCP exposes 24 tools across read.* / position.* / policy.* / issuer.* / governance.* groups for managing real-world asset (RWA) tokens with FHE-encrypted balances. Authentication uses a one-time device-code ceremony (run `muhaven-broker login`); subsequent tool calls fetch the JWT from the broker over a Unix socket. Position / governance tools deep-link to the dashboard for passkey signing — they NEVER auto-submit to a bundler. The companion `muhaven-broker` daemon must be running before tools can be invoked. See README for setup.",
9
9
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhaven/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MuHaven MCP server — read/position/policy toolsets bridging Claude Desktop / Cursor / Claude Code to the MuHaven backend, with a sibling muhaven-broker daemon holding the session-key private half over a local IPC socket",
5
5
  "type": "module",
6
6
  "repository": {