From f453d61dd3485b4b4fef032bd1311ced5896a0da Mon Sep 17 00:00:00 2001 From: Balamurugan Marimuthu <246387390+bmarimuthu-nv@users.noreply.github.com> Date: Fri, 1 May 2026 19:34:37 -0700 Subject: [PATCH 1/3] Clarify README onboarding flows Signed-off-by: Balamurugan Marimuthu <246387390+bmarimuthu-nv@users.noreply.github.com> --- README.md | 84 ++++++++++++++++++++++++++----------------------------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index ff45c95..9c5c4c2 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,17 @@

maglev

-

Run AI coding shells locally, then control them from your browser or phone.

+

Run AI coding sessions locally, then control them from your browser or phone.

## Start Here -Pick the setup that matches where your shell will run: +Pick the setup that matches where your sessions will run: | Use case | Best fit | Open the UI from | |----------|----------|------------------| -| Local laptop or devbox | Hub and shell on the same machine | `http://localhost:3006` | -| SSH workstation | Hub on the remote machine, browser through an SSH tunnel | `http://localhost:3006` on your laptop | -| Slurm/HPC node | Hub inside the allocation, broker on a reachable login/VNC node | The URL printed by `maglev hub start --remote` | +| Local laptop or devbox | Hub and sessions on the same machine | The URL printed by `maglev hub start` | +| SSH workstation | Hub on the remote machine, browser through an SSH tunnel | The forwarded URL on your laptop | +| Slurm/HPC node | Server on a reachable login/VNC node, hub inside the allocation | The URL printed by `maglev hub start --remote` | ## Install @@ -78,25 +78,28 @@ bun run build:standalone ## Local Setup -Use this when your browser and coding shell are on the same machine. +Use this when your browser and coding environment are on the same machine. ```bash maglev hub start --name local -maglev shell ``` Open: ```text -http://localhost:3006 +The URL printed by `maglev hub start`. ``` -Optional: start the runner if you want the web UI to create new shells later. +By default, `maglev hub start` chooses a free local port to avoid conflicts. If you want the traditional fixed local URL, pin the port explicitly: ```bash -maglev runner start +maglev hub start --name local --port 3006 ``` +Then open `http://localhost:3006`. + +Create sessions from the web UI. `maglev hub start` also starts the local runner for that hub, so you do not need to run `maglev shell` or `maglev runner start` manually for the normal flow. + ## SSH Setup Use this when Maglev runs on a remote workstation but you browse from your laptop. @@ -105,69 +108,62 @@ On the remote workstation: ```bash maglev hub start --name devbox --host 127.0.0.1 -maglev shell ``` +Use the port printed by `maglev hub start` in your SSH tunnel. Example, if the hub prints `http://127.0.0.1:43891`: + On your laptop: ```bash -ssh -L 3006:127.0.0.1:3006 user@devbox +ssh -L 43891:127.0.0.1:43891 user@devbox ``` Then open this on your laptop: ```text -http://localhost:3006 +http://localhost:43891 ``` -Optional: keep remote spawning available from the web UI: +If you prefer a stable tunnel command, start the remote hub with `--port 3006` and forward `3006`. -```bash -maglev runner start -``` +Create sessions from the web UI after the runner appears in the machine list. ## Slurm / HPC Setup -Use this when shells run on ephemeral compute nodes that your browser cannot reach directly. +Use this when sessions run on ephemeral compute nodes that your browser cannot reach directly. -On a stable login, VNC, or jump node: +Assumption: the login node and allocated Slurm node/container share the same home directory. At minimum, they must share `~/.maglev`. The server writes connection and auth state there, and `maglev hub start --remote` reads it inside the allocation. If your site gives jobs a different home directory, mount or bind the login node's `~/.maglev` into the job/container before starting the hub. -```bash -# Terminal 1: keep the broker running -maglev server +| Where | Run | Purpose | +|-------|-----|---------| +| Client laptop/browser | Open the URL printed by `maglev hub start --remote` | Use the web UI | +| Login, VNC, or jump node | `maglev server service install` | Keep the Maglev server reachable | +| Login, VNC, or jump node | `maglev auth github login` | Authenticate browser access once | +| Slurm node/container | `maglev hub start --name "slurm-${SLURM_JOB_ID:-manual}" --remote` | Start the hub and runner inside the allocation | -# Terminal 2: authenticate browser access once +The default Linux setup is to run the server as a user service on the stable login/VNC/jump node: + +```bash +maglev server service install maglev auth github login ``` -Inside the Slurm allocation: +Then, inside the Slurm allocation: ```bash srun --pty bash maglev hub start --name "slurm-${SLURM_JOB_ID:-manual}" --remote -maglev shell ``` Open the URL printed by `maglev hub start --remote`. -If the browser cannot reach the broker hostname, start the broker with the public URL you actually use: +If the browser cannot reach the server hostname, start the server with the public URL you actually use: ```bash -maglev server --public-url https://your-reachable-broker.example +maglev server --public-url https://your-reachable-server.example ``` -If the login node and compute node do not share `~/.maglev`, pass broker details explicitly: - -```bash -# On the login/VNC node -cat ~/.maglev/broker-url -cat ~/.maglev/broker-key - -# Inside the Slurm allocation -maglev hub start --remote \ - --broker-url http://login-node:3010 \ - --broker-token "" -``` +If Linux user services are not available on the login/VNC/jump node, keep `maglev server` running in a terminal or under your site's preferred process manager. ## Daily Commands @@ -185,11 +181,11 @@ maglev server hubs ## Services -For long-running Linux user services: +For long-running Linux hosts, install services instead of keeping foreground terminals open: ```bash -maglev hub service install maglev server service install +maglev hub service install ``` For named hubs: @@ -203,9 +199,9 @@ maglev hub logs --name devbox-a --follow ## Mental Model - `maglev hub` stores session state and serves the web UI. -- `maglev shell` starts a shell session and registers it with the hub. -- `maglev runner` lets the web UI spawn new shells on that machine. -- `maglev server` is the optional broker for machines your browser cannot reach directly. +- `maglev hub start` starts the matching runner automatically. +- `maglev runner` lets the web UI create sessions on that machine; direct runner commands are mostly for status, logs, and troubleshooting. +- `maglev server` is the remote access entrypoint for machines your browser cannot reach directly. ## More Docs From bebc0d57d8f879db74ab1a0f37236d1d91957075 Mon Sep 17 00:00:00 2001 From: Balamurugan Marimuthu <246387390+bmarimuthu-nv@users.noreply.github.com> Date: Fri, 1 May 2026 19:49:49 -0700 Subject: [PATCH 2/3] Rename remote access broker URLs to server URLs Signed-off-by: Balamurugan Marimuthu <246387390+bmarimuthu-nv@users.noreply.github.com> --- README.md | 38 ++++++++++++-- cli/src/commands/hub.test.ts | 16 +++--- cli/src/commands/hub.ts | 58 +++++++++++---------- cli/src/commands/server.ts | 40 +++++++------- cli/src/runtime/assets.ts | 2 +- docs/guide/faq.md | 4 +- docs/guide/how-it-works.md | 4 +- docs/guide/installation.md | 12 +++-- docs/guide/why-maglev.md | 24 ++++----- docs/public/schemas/settings.schema.json | 5 ++ hub/src/broker/client.ts | 22 ++++---- hub/src/broker/index.ts | 66 +++++++++++++----------- hub/src/broker/key.ts | 38 ++++++++++++-- hub/src/config/serverSettings.ts | 35 ++++++++----- hub/src/config/settings.ts | 3 +- hub/src/configuration.ts | 10 ++-- hub/src/index.ts | 50 +++++++++--------- hub/src/web/brokerSession.ts | 2 +- hub/src/web/routes/auth.ts | 8 +-- hub/src/web/server.ts | 2 +- 20 files changed, 263 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index 9c5c4c2..779b4da 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,24 @@ Pick the setup that matches where your sessions will run: | SSH workstation | Hub on the remote machine, browser through an SSH tunnel | The forwarded URL on your laptop | | Slurm/HPC node | Server on a reachable login/VNC node, hub inside the allocation | The URL printed by `maglev hub start --remote` | +## Direct vs Server Mode + +Maglev does not guess whether your browser can reach a hub. You choose the mode when starting the hub. + +Use direct mode when your browser can reach the hub URL directly, including through an SSH tunnel: + +```bash +maglev hub start --name devbox +``` + +Use server mode when the hub runs somewhere your browser cannot reach directly, such as inside a Slurm allocation or container: + +```bash +maglev hub start --name "slurm-${SLURM_JOB_ID:-manual}" --remote +``` + +In server mode, the hub still listens on a local port, but it also registers with `maglev server`. The server URL is discovered from `~/.maglev/server-url` written by `maglev server`, from saved settings, or from `--server-url `. + ## Install Fast path: install the latest prebuilt release for your machine. @@ -78,7 +96,7 @@ bun run build:standalone ## Local Setup -Use this when your browser and coding environment are on the same machine. +Use this direct-mode setup when your browser and coding environment are on the same machine. ```bash maglev hub start --name local @@ -102,7 +120,7 @@ Create sessions from the web UI. `maglev hub start` also starts the local runner ## SSH Setup -Use this when Maglev runs on a remote workstation but you browse from your laptop. +Use this direct-mode setup when Maglev runs on a remote workstation and your browser reaches it through an SSH tunnel. On the remote workstation: @@ -110,7 +128,7 @@ On the remote workstation: maglev hub start --name devbox --host 127.0.0.1 ``` -Use the port printed by `maglev hub start` in your SSH tunnel. Example, if the hub prints `http://127.0.0.1:43891`: +By default, the remote hub also chooses a free local port to avoid conflicts. Use the port printed by `maglev hub start` in your SSH tunnel. Example, if the hub prints `http://127.0.0.1:43891`: On your laptop: @@ -124,13 +142,23 @@ Then open this on your laptop: http://localhost:43891 ``` -If you prefer a stable tunnel command, start the remote hub with `--port 3006` and forward `3006`. +If you prefer a stable tunnel command, pin the remote hub port: + +```bash +maglev hub start --name devbox --host 127.0.0.1 --port 3006 +``` + +Then forward `3006`: + +```bash +ssh -L 3006:127.0.0.1:3006 user@devbox +``` Create sessions from the web UI after the runner appears in the machine list. ## Slurm / HPC Setup -Use this when sessions run on ephemeral compute nodes that your browser cannot reach directly. +Use this server-mode setup when sessions run on ephemeral compute nodes that your browser cannot reach directly. Assumption: the login node and allocated Slurm node/container share the same home directory. At minimum, they must share `~/.maglev`. The server writes connection and auth state there, and `maglev hub start --remote` reads it inside the allocation. If your site gives jobs a different home directory, mount or bind the login node's `~/.maglev` into the job/container before starting the hub. diff --git a/cli/src/commands/hub.test.ts b/cli/src/commands/hub.test.ts index 4b80199..cec043e 100644 --- a/cli/src/commands/hub.test.ts +++ b/cli/src/commands/hub.test.ts @@ -17,7 +17,7 @@ describe('hub command arg filtering', () => { '--name', 'cw-devbox-1', '--remote', '--config', '/tmp/maglev-config-Sbn3W8.yaml', - '--broker-url', 'http://broker:3010', + '--server-url', 'http://server:3010', '--host', '127.0.0.1', '--port', '15115' ]) @@ -25,12 +25,12 @@ describe('hub command arg filtering', () => { expect(args).toEqual([ '--remote', '--config', '/tmp/maglev-config-Sbn3W8.yaml', - '--broker-url', 'http://broker:3010', + '--server-url', 'http://server:3010', '--host', '127.0.0.1', '--port', '15115' ]) expect(__test__.parseHubArgs(args)).toEqual({ - brokerUrl: 'http://broker:3010', + serverUrl: 'http://server:3010', configPath: '/tmp/maglev-config-Sbn3W8.yaml', host: '127.0.0.1', port: '15115' @@ -55,21 +55,21 @@ describe('hub command arg filtering', () => { [ '--remote', '--config', '/tmp/old.yaml', - '--broker-url', 'http://old-broker:3010', + '--server-url', 'http://old-server:3010', '--host', '127.0.0.1', '--port', '15115' ], [ '--remote', '--config', '/tmp/new.yaml', - '--broker-url', 'http://new-broker:3010' + '--server-url', 'http://new-server:3010' ] ) expect(args).toEqual([ '--remote', '--config', '/tmp/new.yaml', - '--broker-url', 'http://new-broker:3010', + '--server-url', 'http://new-server:3010', '--host', '127.0.0.1', '--port', '15115' ]) @@ -80,7 +80,7 @@ describe('hub command arg filtering', () => { [ '--remote', '--config', '/tmp/current.yaml', - '--broker-url', 'http://broker:3010', + '--server-url', 'http://server:3010', '--port', '15115' ], ['--remote'] @@ -89,7 +89,7 @@ describe('hub command arg filtering', () => { expect(args).toEqual([ '--remote', '--config', '/tmp/current.yaml', - '--broker-url', 'http://broker:3010', + '--server-url', 'http://server:3010', '--port', '15115' ]) }) diff --git a/cli/src/commands/hub.ts b/cli/src/commands/hub.ts index 1664233..546ed6d 100644 --- a/cli/src/commands/hub.ts +++ b/cli/src/commands/hub.ts @@ -13,7 +13,7 @@ import type { CommandDefinition, CommandContext } from './types' const HUB_SERVICE_NAME = 'maglev-hub.service' const HUB_DAEMON_DIR = join(configuration.maglevHomeDir, 'hub-daemons') -type ParsedHubArgs = { host?: string; port?: string; brokerUrl?: string; brokerToken?: string; name?: string; configPath?: string; debug?: boolean } +type ParsedHubArgs = { host?: string; port?: string; serverUrl?: string; serverToken?: string; name?: string; configPath?: string; debug?: boolean } type HubDaemonState = { name: string pid: number @@ -73,10 +73,10 @@ function parseHubArgs(args: string[]): ParsedHubArgs { result.host = args[++i] } else if (arg === '--port' && i + 1 < args.length) { result.port = args[++i] - } else if (arg === '--broker-url' && i + 1 < args.length) { - result.brokerUrl = args[++i] - } else if (arg === '--broker-token' && i + 1 < args.length) { - result.brokerToken = args[++i] + } else if ((arg === '--server-url' || arg === '--broker-url') && i + 1 < args.length) { + result.serverUrl = args[++i] + } else if ((arg === '--server-token' || arg === '--broker-token') && i + 1 < args.length) { + result.serverToken = args[++i] } else if (arg === '--name' && i + 1 < args.length) { result.name = args[++i] } else if (arg === '--config' && i + 1 < args.length) { @@ -87,10 +87,14 @@ function parseHubArgs(args: string[]): ParsedHubArgs { result.host = arg.slice('--host='.length) } else if (arg.startsWith('--port=')) { result.port = arg.slice('--port='.length) + } else if (arg.startsWith('--server-url=')) { + result.serverUrl = arg.slice('--server-url='.length) } else if (arg.startsWith('--broker-url=')) { - result.brokerUrl = arg.slice('--broker-url='.length) + result.serverUrl = arg.slice('--broker-url='.length) + } else if (arg.startsWith('--server-token=')) { + result.serverToken = arg.slice('--server-token='.length) } else if (arg.startsWith('--broker-token=')) { - result.brokerToken = arg.slice('--broker-token='.length) + result.serverToken = arg.slice('--broker-token='.length) } else if (arg.startsWith('--name=')) { result.name = arg.slice('--name='.length) } else if (arg.startsWith('--config=')) { @@ -115,8 +119,8 @@ ${chalk.bold('Usage:')} ${chalk.bold('Options:')} --host Bind host --port Listen port - --broker-url Broker URL for remote mode - --broker-token Optional broker registration token + --server-url Server URL for remote mode + --server-token Optional server registration token --name Stable hub name (default: hostname) --config Optional hub config YAML --debug Run hub in the foreground for debugging @@ -311,7 +315,7 @@ function ensureMachineIdForHub(name: string): string { function buildHubRuntimeEnv(args: string[], machineIdOverride?: string): NodeJS.ProcessEnv { const env = { ...process.env } - const { host, port, brokerUrl, brokerToken, name, configPath } = parseHubArgs(args) + const { host, port, serverUrl, serverToken, name, configPath } = parseHubArgs(args) if (host) { env.MAGLEV_LISTEN_HOST = host @@ -321,11 +325,11 @@ function buildHubRuntimeEnv(args: string[], machineIdOverride?: string): NodeJS. env.MAGLEV_LISTEN_PORT = port env.WEBAPP_PORT = port } - if (brokerUrl) { - env.MAGLEV_BROKER_URL = brokerUrl + if (serverUrl) { + env.MAGLEV_SERVER_URL = serverUrl } - if (brokerToken) { - env.MAGLEV_BROKER_TOKEN = brokerToken + if (serverToken) { + env.MAGLEV_SERVER_TOKEN = serverToken } if (configPath) { env.MAGLEV_HUB_CONFIG = configPath @@ -546,7 +550,7 @@ async function stopRunnerForHub(commandArgs: string[], logFd?: number): Promise< } function filterDaemonStartArgs(args: string[]): string[] { - const flagsWithValue = new Set(['--host', '--port', '--broker-url', '--broker-token', '--name', '--config']) + const flagsWithValue = new Set(['--host', '--port', '--server-url', '--server-token', '--broker-url', '--broker-token', '--name', '--config']) const filtered: string[] = [] let skippedPositionalName = false for (let i = 0; i < args.length; i++) { @@ -585,7 +589,7 @@ function mergeDaemonArgs(storedArgs: string[] | undefined, overrideArgs: string[ const merged: string[] = [] const overrideMap = new Map() const passthroughFlags = new Set() - const valueFlags = new Set(['--host', '--port', '--broker-url', '--broker-token', '--config']) + const valueFlags = new Set(['--host', '--port', '--server-url', '--server-token', '--broker-url', '--broker-token', '--config']) for (let i = 0; i < overrideArgs.length; i++) { const arg = overrideArgs[i] @@ -1076,7 +1080,7 @@ export const hubCommand: CommandDefinition = { if (context.commandArgs[0] === 'daemon-run') { const passthroughArgs = context.commandArgs.slice(1) - const { host, port, brokerUrl, brokerToken, name: parsedName, configPath } = parseHubArgs(passthroughArgs) + const { host, port, serverUrl, serverToken, name: parsedName, configPath } = parseHubArgs(passthroughArgs) const name = parsedName || sanitizeDaemonName(hostname() || 'local') if (host) { process.env.MAGLEV_LISTEN_HOST = host @@ -1086,11 +1090,11 @@ export const hubCommand: CommandDefinition = { process.env.MAGLEV_LISTEN_PORT = port process.env.WEBAPP_PORT = port } - if (brokerUrl) { - process.env.MAGLEV_BROKER_URL = brokerUrl + if (serverUrl) { + process.env.MAGLEV_SERVER_URL = serverUrl } - if (brokerToken) { - process.env.MAGLEV_BROKER_TOKEN = brokerToken + if (serverToken) { + process.env.MAGLEV_SERVER_TOKEN = serverToken } if (configPath) { process.env.MAGLEV_HUB_CONFIG = configPath @@ -1118,9 +1122,9 @@ export const hubCommand: CommandDefinition = { return } - const { host, port, brokerUrl, brokerToken, name: parsedName, configPath, debug } = parseHubArgs(context.commandArgs) + const { host, port, serverUrl, serverToken, name: parsedName, configPath, debug } = parseHubArgs(context.commandArgs) const name = parsedName || sanitizeDaemonName(hostname() || 'local') - const allowedPrefixes = ['--host', '--port', '--broker-url', '--broker-token', '--name', '--config', '--remote', '--debug'] + const allowedPrefixes = ['--host', '--port', '--server-url', '--server-token', '--broker-url', '--broker-token', '--name', '--config', '--remote', '--debug'] const unexpectedArgs = context.commandArgs.filter((arg) => { if (!arg.startsWith('-')) { return true @@ -1145,11 +1149,11 @@ export const hubCommand: CommandDefinition = { process.env.MAGLEV_LISTEN_PORT = resolved.port process.env.WEBAPP_PORT = resolved.port } - if (resolved.brokerUrl) { - process.env.MAGLEV_BROKER_URL = resolved.brokerUrl + if (resolved.serverUrl) { + process.env.MAGLEV_SERVER_URL = resolved.serverUrl } - if (resolved.brokerToken) { - process.env.MAGLEV_BROKER_TOKEN = resolved.brokerToken + if (resolved.serverToken) { + process.env.MAGLEV_SERVER_TOKEN = resolved.serverToken } if (resolved.configPath) { process.env.MAGLEV_HUB_CONFIG = resolved.configPath diff --git a/cli/src/commands/server.ts b/cli/src/commands/server.ts index 5df8947..028ff58 100644 --- a/cli/src/commands/server.ts +++ b/cli/src/commands/server.ts @@ -30,7 +30,7 @@ function parseBrokerArgs(args: string[]): ParsedBrokerArgs { result.port = args[++i] } else if (arg === '--public-url' && i + 1 < args.length) { result.publicUrl = args[++i] - } else if (arg === '--broker-token' && i + 1 < args.length) { + } else if ((arg === '--server-token' || arg === '--broker-token') && i + 1 < args.length) { result.token = args[++i] } else if (arg.startsWith('--host=')) { result.host = arg.slice('--host='.length) @@ -38,6 +38,8 @@ function parseBrokerArgs(args: string[]): ParsedBrokerArgs { result.port = arg.slice('--port='.length) } else if (arg.startsWith('--public-url=')) { result.publicUrl = arg.slice('--public-url='.length) + } else if (arg.startsWith('--server-token=')) { + result.token = arg.slice('--server-token='.length) } else if (arg.startsWith('--broker-token=')) { result.token = arg.slice('--broker-token='.length) } else { @@ -244,10 +246,10 @@ function runBrokerServiceCommand(commandArgs: string[]): void { return default: console.log(` -${chalk.bold('maglev server service')} - Manage the user-level broker daemon +${chalk.bold('maglev server service')} - Manage the user-level remote access server ${chalk.bold('Usage:')} - maglev server service install [broker args] + maglev server service install [server args] maglev server service start maglev server service stop maglev server service restart @@ -287,13 +289,13 @@ type BrokerHubsResponse = { async function listBrokerHubs(): Promise { const brokerUrlState = await readBrokerUrl() if (!brokerUrlState?.url) { - throw new Error('Broker URL not found. Start `maglev server` first so it can write ~/.maglev/broker-url.') + throw new Error('Server URL not found. Start `maglev server` first so it can write ~/.maglev/server-url.') } const githubAuthState = await readRemoteGitHubAuthState() const githubAuth = githubAuthState?.state.githubAuth if (!githubAuth) { - throw new Error('Broker auth requires cached GitHub auth. Run `maglev auth github login` first.') + throw new Error('Server auth requires cached GitHub auth. Run `maglev auth github login` first.') } const brokerSessionToken = await signBrokerSessionToken({ @@ -310,13 +312,13 @@ async function listBrokerHubs(): Promise { const body = await response.json().catch(() => null) as BrokerHubsResponse | null if (!response.ok) { - throw new Error(body?.error || `Broker request failed with status ${response.status}`) + throw new Error(body?.error || `Server request failed with status ${response.status}`) } const activeHubs = body?.hubs ?? [] const recentHubs = body?.recentHubs ?? [] - console.log(chalk.bold(`\nBroker Hubs (${brokerUrlState.url})\n`)) + console.log(chalk.bold(`\nServer Hubs (${brokerUrlState.url})\n`)) const printFolders = (hub: BrokerHubRecord) => { if (hub.configError) { @@ -368,7 +370,7 @@ async function listBrokerHubs(): Promise { export const serverCommand: CommandDefinition = { name: 'server', - description: 'Manage the broker server (start, stop, status)', + description: 'Manage the remote access server (start, stop, status)', requiresRuntimeAssets: false, run: async (context: CommandContext) => { try { @@ -387,7 +389,7 @@ export const serverCommand: CommandDefinition = { console.log(` ${chalk.bold('maglev server')} -Start the self-hosted remote broker on a stable machine such as a VNC/login node. +Start the self-hosted remote access server on a stable machine such as a VNC/login node. ${chalk.bold('Usage:')} maglev server [options] @@ -400,19 +402,19 @@ ${chalk.bold('Options:')} --host Bind host (default: 0.0.0.0) --port Optional listen port; defaults to a free auto-picked port --public-url Optional public base URL; defaults to http://: - --broker-token Optional override for hub registration; default: ~/.maglev/broker-key + --server-token Optional override for hub registration; default: ~/.maglev/server-key ${chalk.bold('Environment:')} - MAGLEV_BROKER_LISTEN_HOST - MAGLEV_BROKER_LISTEN_PORT - MAGLEV_BROKER_PUBLIC_URL - MAGLEV_BROKER_TOKEN + MAGLEV_SERVER_LISTEN_HOST + MAGLEV_SERVER_LISTEN_PORT + MAGLEV_SERVER_PUBLIC_URL + MAGLEV_SERVER_TOKEN ${chalk.bold('Example:')} maglev server maglev server hubs maglev server --port 3010 - maglev server --public-url https://vnc-broker.internal + maglev server --public-url https://vnc-server.internal `) process.exit(0) } @@ -422,16 +424,16 @@ ${chalk.bold('Example:')} } if (host) { - process.env.MAGLEV_BROKER_LISTEN_HOST = host + process.env.MAGLEV_SERVER_LISTEN_HOST = host } if (port) { - process.env.MAGLEV_BROKER_LISTEN_PORT = port + process.env.MAGLEV_SERVER_LISTEN_PORT = port } if (publicUrl) { - process.env.MAGLEV_BROKER_PUBLIC_URL = publicUrl + process.env.MAGLEV_SERVER_PUBLIC_URL = publicUrl } if (token) { - process.env.MAGLEV_BROKER_TOKEN = token + process.env.MAGLEV_SERVER_TOKEN = token } const { startBroker } = await import('../../../hub/src/broker') diff --git a/cli/src/runtime/assets.ts b/cli/src/runtime/assets.ts index 35ca585..6f672f0 100644 --- a/cli/src/runtime/assets.ts +++ b/cli/src/runtime/assets.ts @@ -1,5 +1,5 @@ export async function ensureRuntimeAssets(): Promise { // Legacy runtime asset extraction existed only for the retired managed // tunnel transport. Keep the hook as a no-op so command wiring stays - // stable while the broker-based model remains the only live path. + // stable while the server-based model remains the only live path. } diff --git a/docs/guide/faq.md b/docs/guide/faq.md index d01d1c3..44b915c 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -38,7 +38,7 @@ maglev hub --remote ### What is the access token for? -`MAGLEV_API_TOKEN` authenticates CLI-to-hub access. In local browser flows it can also be used for manual sign-in. In broker-based remote mode, browser access goes through broker/GitHub auth instead. +`MAGLEV_API_TOKEN` authenticates CLI-to-hub access. In local browser flows it can also be used for manual sign-in. In server-based remote mode, browser access goes through server/GitHub auth instead. ### Can I use Maglev without Telegram? @@ -80,7 +80,7 @@ Maglev is local-first: ### Can others access my Maglev instance? -Only if they can satisfy your configured browser auth path. For public access, use HTTPS and prefer the broker-based remote flow. +Only if they can satisfy your configured browser auth path. For public access, use HTTPS and prefer the server-based remote flow. ## Troubleshooting diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index 62a40cb..8b1a286 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -85,10 +85,10 @@ The web app is terminal-first: Maglev supports: - local-only use on `localhost` -- self-hosted remote access through the broker +- self-hosted remote access through the server - optional public exposure through your own tunnel or reverse proxy -In remote mode the broker handles hub registration and browser routing. The session itself still runs on your machine. +In remote mode the server handles hub registration and browser routing. The session itself still runs on your machine. ## Seamless Handoff diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 868c032..688888b 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -99,15 +99,15 @@ maglev auth github login maglev hub --remote ``` -The broker: +The server: - stores state under `~/.maglev/` -- writes its public URL to `~/.maglev/broker-url` -- stores the registration key in `~/.maglev/broker-key` +- writes its public URL to `~/.maglev/server-url` +- stores the registration key in `~/.maglev/server-key` The hub: -- reads `~/.maglev/broker-url` automatically unless overridden +- reads `~/.maglev/server-url` automatically unless overridden - prints the browser URL after registration - uses GitHub device auth for browser sign-in @@ -158,6 +158,8 @@ The runner keeps a local control server plus a list of tracked shell sessions. | `MAGLEV_LISTEN_HOST` | `127.0.0.1` | Hub bind address | | `MAGLEV_LISTEN_PORT` | `3006` | Hub port | | `MAGLEV_PUBLIC_URL` | - | Public HTTPS URL | +| `MAGLEV_SERVER_URL` | `~/.maglev/server-url` | Remote access server URL for `maglev hub --remote` | +| `MAGLEV_SERVER_TOKEN` | `~/.maglev/server-key` | Optional hub registration token override | | `CORS_ORIGINS` | - | Allowed browser origins | | `MAGLEV_HOME` | `~/.maglev` | Config directory | | `DB_PATH` | `~/.maglev/maglev.db` | Database file | @@ -197,6 +199,6 @@ maglev hub logs --name devbox-a --follow ## Notes -- `maglev server` runs the remote broker for coordinating hubs across machines +- `maglev server` runs the remote access server for coordinating hubs across machines - browser access is terminal-first; sessions are shells, not chat agents - files and review continue to work for shell sessions diff --git a/docs/guide/why-maglev.md b/docs/guide/why-maglev.md index 045c83b..aeca0fb 100644 --- a/docs/guide/why-maglev.md +++ b/docs/guide/why-maglev.md @@ -10,9 +10,9 @@ The short version: Happy is built around a centralized service. Maglev is built |--------|-------|--------| | **Architecture** | Centralized cloud service | Local-first, self-hosted | | **Data location** | Stored on the service, encrypted | Stored on your machine | -| **Remote access** | Through the hosted service | Through your hub directly, or through your self-hosted broker | +| **Remote access** | Through the hosted service | Through your hub directly, or through your self-hosted server | | **Deployment** | Multi-service stack | Single workspace / self-hosted components | -| **Main complexity** | E2EE, key handling, service ops | Running your own hub and optional broker | +| **Main complexity** | E2EE, key handling, service ops | Running your own hub and optional server | Choose Maglev if you want local ownership, self-hosting, and a simple mental model. @@ -40,33 +40,33 @@ For remote access, Maglev supports two practical shapes: 1. Direct self-hosting - expose the hub yourself with HTTPS, a reverse proxy, Tailscale, Cloudflare Tunnel, or similar -2. Broker-based remote access +2. Server-based remote access - run `maglev server` on a stable machine you control - run `maglev hub --remote` on the machine that hosts the sessions - - the hub opens an outbound broker connection - - the broker routes browser HTTP, SSE, and WebSocket traffic back to that hub + - the hub opens an outbound server connection + - the server routes browser HTTP, SSE, and WebSocket traffic back to that hub -The key point is that the broker is also yours. There is no managed Maglev relay service in the architecture. +The key point is that the server is also yours. There is no managed Maglev relay service in the architecture. ## Maglev Remote Architecture ```text ┌──────────────┐ HTTPS / WS ┌────────────────┐ persistent WS ┌──────────────┐ │ Browser/PWA │ ◄────────────────►│ Self-hosted │◄───────────────────►│ Hub │ -│ or Phone │ │ Broker │ │ + Sessions │ +│ or Phone │ │ Server │ │ + Sessions │ └──────────────┘ └────────────────┘ └──────────────┘ │ ▼ local SQLite + files ``` -What the broker does: +What the server does: - gives you a stable public URL - keeps track of live hubs - forwards browser traffic to the right hub -What the broker does not do: +What the server does not do: - store your session data - own your long-term application state @@ -82,9 +82,9 @@ Maglev assumes you control the infrastructure: - local-only mode: browser talks to your hub directly - self-hosted public mode: you secure the hub with your own HTTPS/reverse proxy setup -- broker mode: you secure the browser-to-broker path, and the broker forwards traffic to your hub over the hub's outbound session +- server mode: you secure the browser-to-server path, and the server forwards traffic to your hub over the hub's outbound session -For internet-facing broker mode, browser sign-in is handled through GitHub-backed broker auth, then the hub issues its own JWT for app access. +For internet-facing server mode, browser sign-in is handled through GitHub-backed server auth, then the hub issues its own JWT for app access. ## Why The Architectures Diverge @@ -100,7 +100,7 @@ That leads to different tradeoffs: | Dimension | Happy | Maglev | |-----------|-------|--------| -| **Server role** | Primary shared backend | Your own hub and optional broker | +| **Server role** | Primary shared backend | Your own hub and optional server | | **State storage** | Hosted service | Local hub | | **Scaling model** | Shared multi-tenant infra | Per-user or per-team self-hosting | | **Trust boundary** | Hosted backend must not see plaintext | You control the machines and network path | diff --git a/docs/public/schemas/settings.schema.json b/docs/public/schemas/settings.schema.json index 30ce312..48f00f1 100644 --- a/docs/public/schemas/settings.schema.json +++ b/docs/public/schemas/settings.schema.json @@ -32,6 +32,11 @@ "format": "uri", "description": "Public URL for external access (e.g., Telegram Mini App). ENV: MAGLEV_PUBLIC_URL" }, + "serverUrl": { + "type": "string", + "format": "uri", + "description": "Remote access server URL for hub registration. ENV: MAGLEV_SERVER_URL" + }, "corsOrigins": { "type": "array", "items": { diff --git a/hub/src/broker/client.ts b/hub/src/broker/client.ts index cb63c46..96dd031 100644 --- a/hub/src/broker/client.ts +++ b/hub/src/broker/client.ts @@ -174,7 +174,7 @@ export class BrokerClient { async start(): Promise { if (this.started) { if (!this.hubUrl) { - throw new Error('Broker client started without a hub URL') + throw new Error('Server client started without a hub URL') } return this.hubUrl } @@ -229,7 +229,7 @@ export class BrokerClient { this.socket = socket socket.send(JSON.stringify(this.buildRegisterMessage())) this.reconnecting = false - this.emitStatus(`Connected to broker ${this.config.brokerUrl}`) + this.emitStatus(`Connected to server ${this.config.brokerUrl}`) resolve() } @@ -238,7 +238,7 @@ export class BrokerClient { return } settled = true - reject(new Error(`Failed to connect to broker WebSocket ${this.wsUrl}`)) + reject(new Error(`Failed to connect to server WebSocket ${this.wsUrl}`)) } socket.onclose = () => { @@ -246,7 +246,7 @@ export class BrokerClient { return } settled = true - reject(new Error(`Broker WebSocket closed during connect ${this.wsUrl}`)) + reject(new Error(`Server WebSocket closed during connect ${this.wsUrl}`)) } }) @@ -263,7 +263,7 @@ export class BrokerClient { if (wasActiveSocket) { this.socket = null this.closeProxySockets() - this.emitStatus(`Disconnected from broker ${this.config.brokerUrl}`) + this.emitStatus(`Disconnected from server ${this.config.brokerUrl}`) this.scheduleReconnect() } } @@ -312,7 +312,7 @@ export class BrokerClient { } this.reconnecting = true - this.emitStatus(`Broker connection lost; retrying in ${BrokerClient.RECONNECT_DELAY_MS / 1000}s`) + this.emitStatus(`Server connection lost; retrying in ${BrokerClient.RECONNECT_DELAY_MS / 1000}s`) this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null void this.reconnect() @@ -326,12 +326,12 @@ export class BrokerClient { } try { - this.emitStatus(`Reconnecting to broker ${this.config.brokerUrl}`) + this.emitStatus(`Reconnecting to server ${this.config.brokerUrl}`) await this.connect() - this.emitStatus(`Re-registered hub ${this.hubId} with broker`) + this.emitStatus(`Re-registered hub ${this.hubId} with server`) } catch (error) { this.emitStatus( - `Broker reconnect failed: ${error instanceof Error ? error.message : String(error)}` + `Server reconnect failed: ${error instanceof Error ? error.message : String(error)}` ) this.reconnecting = false this.scheduleReconnect() @@ -349,7 +349,7 @@ export class BrokerClient { try { message = JSON.parse(text) as BrokerMessage } catch { - console.error('[Broker] Received malformed JSON message, ignoring') + console.error('[Server] Received malformed JSON message, ignoring') return } @@ -365,7 +365,7 @@ export class BrokerClient { try { response = await this.forwardProxyRequest(message) } catch (error) { - console.error(`[Broker] Failed to proxy ${message.method} ${message.path}:`, error) + console.error(`[Server] Failed to proxy ${message.method} ${message.path}:`, error) response = createProxyErrorResponse(message.requestId, error) } if (this.socket?.readyState === WebSocket.OPEN) { diff --git a/hub/src/broker/index.ts b/hub/src/broker/index.ts index 38ffd6a..e472286 100644 --- a/hub/src/broker/index.ts +++ b/hub/src/broker/index.ts @@ -142,13 +142,17 @@ function getEnv(name: string): string | undefined { return value ? value : undefined } +function getEnvWithLegacy(name: string, legacyName: string): string | undefined { + return getEnv(name) ?? getEnv(legacyName) +} + async function findFreePort(): Promise { return await new Promise((resolve, reject) => { const server = createServer() server.listen(0, '0.0.0.0', () => { const address = server.address() if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Failed to determine an available broker port'))) + server.close(() => reject(new Error('Failed to determine an available server port'))) return } const { port } = address @@ -165,21 +169,21 @@ async function findFreePort(): Promise { } async function getBrokerConfig(): Promise { - const host = getEnv('MAGLEV_BROKER_LISTEN_HOST') ?? '0.0.0.0' - const portValue = getEnv('MAGLEV_BROKER_LISTEN_PORT') + const host = getEnvWithLegacy('MAGLEV_SERVER_LISTEN_HOST', 'MAGLEV_BROKER_LISTEN_HOST') ?? '0.0.0.0' + const portValue = getEnvWithLegacy('MAGLEV_SERVER_LISTEN_PORT', 'MAGLEV_BROKER_LISTEN_PORT') let port: number if (portValue) { port = Number.parseInt(portValue, 10) if (!Number.isFinite(port) || port <= 0 || port > 65535) { - throw new Error(`Invalid MAGLEV_BROKER_LISTEN_PORT: ${portValue}`) + throw new Error(`Invalid MAGLEV_SERVER_LISTEN_PORT: ${portValue}`) } } else { port = await findFreePort() } - const publicUrl = getEnv('MAGLEV_BROKER_PUBLIC_URL') ?? `http://${getHostname()}:${port}` - const configuredToken = getEnv('MAGLEV_BROKER_TOKEN') + const publicUrl = getEnvWithLegacy('MAGLEV_SERVER_PUBLIC_URL', 'MAGLEV_BROKER_PUBLIC_URL') ?? `http://${getHostname()}:${port}` + const configuredToken = getEnvWithLegacy('MAGLEV_SERVER_TOKEN', 'MAGLEV_BROKER_TOKEN') if (configuredToken) { return { host, @@ -1015,7 +1019,7 @@ function createTimeoutPromise(requestId: string, targetClientId?: string): Promi async function proxyHttpRequest(config: BrokerConfig, hub: RegisteredHub, req: Request, tailPath: string, session: BrokerUserSession): Promise { if (!hub.socket) { - return new Response('Hub is not connected to the broker', { status: 503 }) + return new Response('Hub is not connected to the server', { status: 503 }) } const url = new URL(req.url) @@ -1087,17 +1091,17 @@ export async function startBroker(): Promise { const authConfig = await getBrokerAuthConfig() const brokerUrlPath = await writeBrokerUrl(config.publicUrl) - console.log('Maglev Broker starting...') - console.log(`[Broker] Listen host: ${config.host}`) - console.log(`[Broker] Listen port: ${config.port}`) - console.log(`[Broker] Public URL: ${config.publicUrl}`) - console.log(`[Broker] URL file: ${brokerUrlPath}`) + console.log('Maglev Server starting...') + console.log(`[Server] Listen host: ${config.host}`) + console.log(`[Server] Listen port: ${config.port}`) + console.log(`[Server] Public URL: ${config.publicUrl}`) + console.log(`[Server] URL file: ${brokerUrlPath}`) if (config.tokenPath) { - console.log(`[Broker] Registration key: ${config.tokenCreated ? 'created' : 'loaded'} from ${config.tokenPath}`) + console.log(`[Server] Registration key: ${config.tokenCreated ? 'created' : 'loaded'} from ${config.tokenPath}`) } else { - console.log('[Broker] Registration key: loaded from MAGLEV_BROKER_TOKEN') + console.log('[Server] Registration key: loaded from MAGLEV_SERVER_TOKEN') } - console.log('[Broker] Mode: self-hosted control plane') + console.log('[Server] Mode: self-hosted control plane') const pruneInterval = setInterval(pruneExpiredHubs, 5_000) @@ -1113,7 +1117,7 @@ export async function startBroker(): Promise { if (config.token) { const providedToken = url.searchParams.get('token')?.trim() || null if (providedToken !== config.token) { - return new Response('Broker token mismatch', { status: 401 }) + return new Response('Server token mismatch', { status: 401 }) } } const clientId = randomUUID() @@ -1136,7 +1140,7 @@ export async function startBroker(): Promise { const activeHubs = listActiveHubs() return Response.json({ status: 'ok', - service: 'maglev-broker', + service: 'maglev-server', requestId: randomUUID(), activeHubs: activeHubs.length, recentHubs: listRecentHubs().length @@ -1145,7 +1149,7 @@ export async function startBroker(): Promise { if (url.pathname === '/api/github/device/start' && req.method === 'POST') { if (!authConfig.gitHubDeviceAuth) { - return Response.json({ error: 'GitHub device auth is not configured for the broker' }, { status: 503 }) + return Response.json({ error: 'GitHub device auth is not configured for the server' }, { status: 503 }) } try { return Response.json(await authConfig.gitHubDeviceAuth.start()) @@ -1156,7 +1160,7 @@ export async function startBroker(): Promise { if (url.pathname === '/api/github/device/poll' && req.method === 'POST') { if (!authConfig.gitHubDeviceAuth) { - return Response.json({ error: 'GitHub device auth is not configured for the broker' }, { status: 503 }) + return Response.json({ error: 'GitHub device auth is not configured for the server' }, { status: 503 }) } const body = await req.json().catch(() => null) as { deviceCode?: unknown } | null const deviceCode = typeof body?.deviceCode === 'string' ? body.deviceCode.trim() : '' @@ -1198,7 +1202,7 @@ export async function startBroker(): Promise { if (url.pathname === '/api/hubs' && req.method === 'GET') { if (!brokerSession) { - return Response.json({ error: 'Broker session required' }, { status: 401 }) + return Response.json({ error: 'Server session required' }, { status: 401 }) } const activeHubs = listActiveHubs() return Response.json({ @@ -1215,7 +1219,7 @@ export async function startBroker(): Promise { if (url.pathname.startsWith('/api/hubs/') && req.method === 'GET') { if (!brokerSession) { - return Response.json({ error: 'Broker session required' }, { status: 401 }) + return Response.json({ error: 'Server session required' }, { status: 401 }) } const hubId = decodeURIComponent(url.pathname.slice('/api/hubs/'.length)) const hub = registry.get(hubId) @@ -1244,7 +1248,7 @@ export async function startBroker(): Promise { if (url.pathname.startsWith('/h/')) { if (!brokerSession) { - return new Response('Broker session required', { status: 401 }) + return new Response('Server session required', { status: 401 }) } const tail = url.pathname.slice('/h/'.length) const slashIndex = tail.indexOf('/') @@ -1259,7 +1263,7 @@ export async function startBroker(): Promise { if (req.headers.get('upgrade')) { if (!hub.socket) { - return new Response('Hub is not connected to the broker', { status: 503 }) + return new Response('Hub is not connected to the server', { status: 503 }) } const wsId = randomUUID() const upgraded = serverRef.upgrade(req, { @@ -1289,7 +1293,7 @@ export async function startBroker(): Promise { return proxyHttpRequest(config, hub, req, tailPath, brokerSession).catch((error) => { return new Response( - error instanceof Error ? error.message : 'Broker proxy error', + error instanceof Error ? error.message : 'Server proxy error', { status: 502 } ) }) @@ -1297,7 +1301,7 @@ export async function startBroker(): Promise { if (url.pathname === '/' || url.pathname === '/index.html') { if (!brokerSession) { - return new Response(renderBrokerLogin(config, authConfig.gitHubDeviceAuth ? undefined : 'Broker GitHub auth is not configured.'), { + return new Response(renderBrokerLogin(config, authConfig.gitHubDeviceAuth ? undefined : 'Server GitHub auth is not configured.'), { headers: { 'content-type': 'text/html; charset=utf-8' } @@ -1489,19 +1493,19 @@ export async function startBroker(): Promise { }) console.log('') - console.log('Maglev Broker is ready!') - console.log(`[Broker] Local: http://${config.host}:${config.port}`) - console.log(`[Broker] Public: ${config.publicUrl}`) + console.log('Maglev Server is ready!') + console.log(`[Server] Local: http://${config.host}:${config.port}`) + console.log(`[Server] Public: ${config.publicUrl}`) const shutdown = () => { - console.log('\nShutting down broker...') + console.log('\nShutting down server...') clearInterval(pruneInterval) for (const pending of pendingRequests.values()) { - pending.reject(new Error('Broker shutting down')) + pending.reject(new Error('Server shutting down')) } pendingRequests.clear() for (const controller of pendingStreams.values()) { - controller.error(new Error('Broker shutting down')) + controller.error(new Error('Server shutting down')) } pendingStreams.clear() server.stop() diff --git a/hub/src/broker/key.ts b/hub/src/broker/key.ts index ad417c8..9ca0050 100644 --- a/hub/src/broker/key.ts +++ b/hub/src/broker/key.ts @@ -9,10 +9,18 @@ function getBrokerHome(): string { } export function getBrokerKeyPath(): string { - return join(getBrokerHome(), 'broker-key') + return join(getBrokerHome(), 'server-key') } export function getBrokerUrlPath(): string { + return join(getBrokerHome(), 'server-url') +} + +function getLegacyBrokerKeyPath(): string { + return join(getBrokerHome(), 'broker-key') +} + +function getLegacyBrokerUrlPath(): string { return join(getBrokerHome(), 'broker-url') } @@ -40,11 +48,22 @@ export async function getOrCreateBrokerKey(): Promise<{ key: string; path: strin if (existsSync(path)) { const key = (await readFile(path, 'utf8')).trim() if (!key) { - throw new Error(`Broker key file is empty: ${path}`) + throw new Error(`Server key file is empty: ${path}`) } return { key, path, created: false } } + const legacyPath = getLegacyBrokerKeyPath() + if (existsSync(legacyPath)) { + const key = (await readFile(legacyPath, 'utf8')).trim() + if (!key) { + throw new Error(`Legacy broker key file is empty: ${legacyPath}`) + } + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile(path, `${key}\n`, { mode: 0o600 }) + return { key, path, created: false } + } + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) const key = generateBrokerKey() await writeFile(path, `${key}\n`, { mode: 0o600 }) @@ -61,12 +80,23 @@ export async function writeBrokerUrl(publicUrl: string): Promise { export async function readBrokerUrl(): Promise<{ url: string; path: string } | null> { const path = getBrokerUrlPath() if (!existsSync(path)) { - return null + const legacyPath = getLegacyBrokerUrlPath() + if (!existsSync(legacyPath)) { + return null + } + + const legacyUrl = (await readFile(legacyPath, 'utf8')).trim() + if (!legacyUrl) { + throw new Error(`Legacy broker URL file is empty: ${legacyPath}`) + } + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile(path, `${legacyUrl}\n`, { mode: 0o600 }) + return { url: legacyUrl, path } } const url = (await readFile(path, 'utf8')).trim() if (!url) { - throw new Error(`Broker URL file is empty: ${path}`) + throw new Error(`Server URL file is empty: ${path}`) } return { url, path } } diff --git a/hub/src/config/serverSettings.ts b/hub/src/config/serverSettings.ts index a18eb5d..e9e8164 100644 --- a/hub/src/config/serverSettings.ts +++ b/hub/src/config/serverSettings.ts @@ -30,7 +30,7 @@ export interface ServerSettings { login: string name?: string } | null - brokerUrl: string | null + serverUrl: string | null } export interface ServerSettingsResult { @@ -46,7 +46,7 @@ export interface ServerSettingsResult { githubOwner: 'env' | 'file' | 'default' githubAllowedUsers: 'env' | 'file' | 'default' githubAuth: 'file' | 'default' - brokerUrl: 'env' | 'file' | 'default' + serverUrl: 'env' | 'file' | 'default' } savedToFile: boolean } @@ -125,7 +125,7 @@ export async function loadServerSettings(dataDir: string): Promise file > null let telegramBotToken: string | null = null @@ -306,17 +306,28 @@ export async function loadServerSettings(dataDir: string): Promise { const raw = process.env.MAGLEV_TERMINAL_SUPERVISION_HUMAN_OVERRIDE_MS?.trim() diff --git a/hub/src/index.ts b/hub/src/index.ts index 6b092cd..fdba272 100644 --- a/hub/src/index.ts +++ b/hub/src/index.ts @@ -106,10 +106,10 @@ async function main() { // Load configuration (async - loads from env/file with persistence) const remoteMode = resolveRemoteMode(process.argv) const config = await createConfiguration() - const discoveredBrokerUrl = remoteMode && !config.brokerUrl + const discoveredServerUrl = remoteMode && !config.serverUrl ? await readBrokerUrl() : null - const effectiveBrokerUrl = config.brokerUrl ?? discoveredBrokerUrl?.url ?? null + const effectiveServerUrl = config.serverUrl ?? discoveredServerUrl?.url ?? null const baseCorsOrigins = normalizeOrigins(config.corsOrigins) const corsOrigins = baseCorsOrigins @@ -150,11 +150,11 @@ async function main() { // Display tunnel status console.log(`[Hub] Remote mode: ${remoteMode ? 'enabled (--remote)' : 'disabled'}`) if (remoteMode) { - if (effectiveBrokerUrl) { - if (config.brokerUrl) { - console.log(`[Hub] Broker URL: ${config.brokerUrl} (${formatSource(config.sources.brokerUrl)})`) - } else if (discoveredBrokerUrl) { - console.log(`[Hub] Broker URL: ${discoveredBrokerUrl.url} (${discoveredBrokerUrl.path})`) + if (effectiveServerUrl) { + if (config.serverUrl) { + console.log(`[Hub] Server URL: ${config.serverUrl} (${formatSource(config.sources.serverUrl)})`) + } else if (discoveredServerUrl) { + console.log(`[Hub] Server URL: ${discoveredServerUrl.url} (${discoveredServerUrl.path})`) } } } else { @@ -176,8 +176,8 @@ async function main() { if (!config.githubOauthClientId) { throw new Error('Remote mode requires MAGLEV_GITHUB_OAUTH_CLIENT_ID') } - if (!effectiveBrokerUrl) { - throw new Error('Remote mode requires a broker URL. Start `maglev server` first so it can write ~/.maglev/broker-url, or pass `--broker-url`.') + if (!effectiveServerUrl) { + throw new Error('Remote mode requires a server URL. Start `maglev server` first so it can write ~/.maglev/server-url, or pass `--server-url`.') } if (!config.githubOwner && config.githubAllowedUsers.length === 0 && !config.githubAuth) { throw new Error('Remote mode requires MAGLEV_GITHUB_OWNER, MAGLEV_GITHUB_ALLOWED_USERS, or a bootstrapped owner from `maglev auth github login`') @@ -288,20 +288,20 @@ async function main() { throw new Error('Remote mode requires a GitHub owner identity') } - const configuredBrokerToken = process.env.MAGLEV_BROKER_TOKEN?.trim() || null - const brokerKey = configuredBrokerToken + const configuredServerToken = process.env.MAGLEV_SERVER_TOKEN?.trim() || process.env.MAGLEV_BROKER_TOKEN?.trim() || null + const brokerKey = configuredServerToken ? null : await getOrCreateBrokerKey() if (brokerKey) { - console.log(`[Broker] Registration key: ${brokerKey.created ? 'created' : 'loaded'} from ${brokerKey.path}`) + console.log(`[Server] Registration key: ${brokerKey.created ? 'created' : 'loaded'} from ${brokerKey.path}`) } else { - console.log('[Broker] Registration key: loaded from MAGLEV_BROKER_TOKEN') + console.log('[Server] Registration key: loaded from MAGLEV_SERVER_TOKEN') } brokerClient = new BrokerClient({ - brokerUrl: effectiveBrokerUrl!, - brokerToken: configuredBrokerToken ?? brokerKey?.key ?? null, + brokerUrl: effectiveServerUrl!, + brokerToken: configuredServerToken ?? brokerKey?.key ?? null, owner: brokerOwner, localHost: config.listenHost, localPort: config.listenPort, @@ -309,30 +309,30 @@ async function main() { launchFolders: hubLaunchConfig.folders, configError: hubLaunchConfig.error, onStatusChange: (status) => { - console.log(`[Broker] ${status}`) + console.log(`[Server] ${status}`) } }) try { tunnelUrl = await brokerClient.start() - console.log(`[Broker] Registered hub ${brokerClient.getHubId()}`) + console.log(`[Server] Registered hub ${brokerClient.getHubId()}`) } catch (error) { const message = error instanceof Error ? error.message : String(error) - const brokerSource = config.brokerUrl - ? `${config.brokerUrl} (${formatSource(config.sources.brokerUrl)})` - : discoveredBrokerUrl - ? `${discoveredBrokerUrl.url} (${discoveredBrokerUrl.path})` - : effectiveBrokerUrl! + const serverSource = config.serverUrl + ? `${config.serverUrl} (${formatSource(config.sources.serverUrl)})` + : discoveredServerUrl + ? `${discoveredServerUrl.url} (${discoveredServerUrl.path})` + : effectiveServerUrl! throw new Error( - `Remote mode broker registration failed for ${brokerSource}: ${message}. ` + - 'Pass `--broker-url ` or fix ~/.maglev/broker-url, then retry.' + `Remote mode server registration failed for ${serverSource}: ${message}. ` + + 'Pass `--server-url ` or fix ~/.maglev/server-url, then retry.' ) } } if (tunnelUrl) { const announceTunnelAccess = async () => { - console.log('[Broker] Public: ' + tunnelUrl) + console.log('[Server] Public: ' + tunnelUrl) console.log('') console.log('Open in browser:') diff --git a/hub/src/web/brokerSession.ts b/hub/src/web/brokerSession.ts index e4e123b..7f61683 100644 --- a/hub/src/web/brokerSession.ts +++ b/hub/src/web/brokerSession.ts @@ -13,7 +13,7 @@ let brokerSecretPromise: Promise | null = null async function getBrokerSecret(): Promise { if (!brokerSecretPromise) { brokerSecretPromise = (async () => { - const configured = process.env.MAGLEV_BROKER_TOKEN?.trim() + const configured = process.env.MAGLEV_SERVER_TOKEN?.trim() || process.env.MAGLEV_BROKER_TOKEN?.trim() if (configured) { return new TextEncoder().encode(configured) } diff --git a/hub/src/web/routes/auth.ts b/hub/src/web/routes/auth.ts index 3e2eef4..f6620af 100644 --- a/hub/src/web/routes/auth.ts +++ b/hub/src/web/routes/auth.ts @@ -180,12 +180,12 @@ export function createAuthRoutes( } if (!remoteMode) { - return c.json({ error: 'Broker session login is only available in remote mode' }, 404) + return c.json({ error: 'Server session login is only available in remote mode' }, 404) } const brokerSession = await getBrokerSessionFromHeaders(c.req.raw.headers) if (!brokerSession) { - return c.json({ error: 'Broker session required' }, 401) + return c.json({ error: 'Server session required' }, 401) } const auth = await signWebJwt(jwtSecret, getCurrentHubNamespace(), { @@ -204,7 +204,7 @@ export function createAuthRoutes( return c.json({ error: 'GitHub device auth is disabled' }, 404) } if (remoteMode) { - return c.json({ error: 'GitHub device auth is managed by the broker in remote mode' }, 403) + return c.json({ error: 'GitHub device auth is managed by the server in remote mode' }, 403) } try { @@ -225,7 +225,7 @@ export function createAuthRoutes( return c.json({ error: 'GitHub device auth is disabled' }, 404) } if (remoteMode) { - return c.json({ error: 'GitHub device auth is managed by the broker in remote mode' }, 403) + return c.json({ error: 'GitHub device auth is managed by the server in remote mode' }, 403) } const json = await c.req.json().catch(() => null) diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 2141fee..7d67448 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -109,7 +109,7 @@ function createWebApp(options: { return await next() } if (!c.req.header(BROKER_SESSION_HEADER)) { - return c.text('Broker session required', 401) + return c.text('Server session required', 401) } return await next() }) From ed0ef223dd88fe3d5944eecc4aa5fc81c1087577 Mon Sep 17 00:00:00 2001 From: Balamurugan Marimuthu <246387390+bmarimuthu-nv@users.noreply.github.com> Date: Fri, 1 May 2026 20:00:49 -0700 Subject: [PATCH 3/3] Improve README onboarding structure Signed-off-by: Balamurugan Marimuthu <246387390+bmarimuthu-nv@users.noreply.github.com> --- README.md | 113 +++++++++++++++++++++++++++++------------------------- 1 file changed, 61 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 779b4da..df250fd 100644 --- a/README.md +++ b/README.md @@ -6,34 +6,6 @@

Run AI coding sessions locally, then control them from your browser or phone.

-## Start Here - -Pick the setup that matches where your sessions will run: - -| Use case | Best fit | Open the UI from | -|----------|----------|------------------| -| Local laptop or devbox | Hub and sessions on the same machine | The URL printed by `maglev hub start` | -| SSH workstation | Hub on the remote machine, browser through an SSH tunnel | The forwarded URL on your laptop | -| Slurm/HPC node | Server on a reachable login/VNC node, hub inside the allocation | The URL printed by `maglev hub start --remote` | - -## Direct vs Server Mode - -Maglev does not guess whether your browser can reach a hub. You choose the mode when starting the hub. - -Use direct mode when your browser can reach the hub URL directly, including through an SSH tunnel: - -```bash -maglev hub start --name devbox -``` - -Use server mode when the hub runs somewhere your browser cannot reach directly, such as inside a Slurm allocation or container: - -```bash -maglev hub start --name "slurm-${SLURM_JOB_ID:-manual}" --remote -``` - -In server mode, the hub still listens on a local port, but it also registers with `maglev server`. The server URL is discovered from `~/.maglev/server-url` written by `maglev server`, from saved settings, or from `--server-url `. - ## Install Fast path: install the latest prebuilt release for your machine. @@ -61,40 +33,44 @@ If `maglev` is not found after install: export PATH="$HOME/.local/bin:$PATH" ``` -## Build From Source +## Main Features -Use this path when working from a checkout, testing unreleased changes, or using a platform without a release artifact. +- Persistent session list that survives browser disconnects and Maglev backend restarts. +- Auto-connect browser sessions back to running terminals when the page or backend reconnects. +- Auto-respawn terminals when the Maglev backend restarts, with optional per-terminal startup commands. +- Code diff and review views for inspecting changes and leaving comments. +- File browser support for each session, including sessions running inside worktrees. +- Worktree support for creating isolated coding sessions from the web UI. -Prerequisites: +## User Workflows -- `git` -- `bun` -- Optional but recommended: `rg` and `difft` +Pick the setup that matches where your sessions will run: -```bash -git clone https://github.com/bmarimuthu-nv/Maglev.git maglev -cd maglev -./install.sh -maglev --version -``` +| Use case | Best fit | Open the UI from | +|----------|----------|------------------| +| Local laptop or devbox | Hub and sessions on the same machine | The URL printed by `maglev hub start` | +| SSH workstation | Hub on the remote machine, browser through an SSH tunnel | The forwarded URL on your laptop | +| Slurm/HPC node | Server on a reachable login/VNC node, hub inside the allocation | The URL printed by `maglev hub start --remote` | -`./install.sh` builds the standalone binary from source and installs `maglev` to `$HOME/.local/bin`. +### Direct vs Server Mode -Common variants: +Maglev does not guess whether your browser can reach a hub. You choose the mode when starting the hub. + +Use direct mode when your browser can reach the hub URL directly, including through an SSH tunnel: ```bash -# Install somewhere else -MAGLEV_INSTALL_DIR="$HOME/bin" ./install.sh +maglev hub start --name devbox +``` -# Force dependency reinstall before building -FORCE=1 ./install.sh +Use server mode when the hub runs somewhere your browser cannot reach directly, such as inside a Slurm allocation or container: -# Build only, without installing -bun install -bun run build:standalone +```bash +maglev hub start --name "slurm-${SLURM_JOB_ID:-manual}" --remote ``` -## Local Setup +In server mode, the hub still listens on a local port, but it also registers with `maglev server`. The server URL is discovered from `~/.maglev/server-url` written by `maglev server`, from saved settings, or from `--server-url `. + +### Local Setup Use this direct-mode setup when your browser and coding environment are on the same machine. @@ -118,7 +94,7 @@ Then open `http://localhost:3006`. Create sessions from the web UI. `maglev hub start` also starts the local runner for that hub, so you do not need to run `maglev shell` or `maglev runner start` manually for the normal flow. -## SSH Setup +### SSH Setup Use this direct-mode setup when Maglev runs on a remote workstation and your browser reaches it through an SSH tunnel. @@ -156,7 +132,7 @@ ssh -L 3006:127.0.0.1:3006 user@devbox Create sessions from the web UI after the runner appears in the machine list. -## Slurm / HPC Setup +### Slurm / HPC Setup Use this server-mode setup when sessions run on ephemeral compute nodes that your browser cannot reach directly. @@ -193,6 +169,39 @@ maglev server --public-url https://your-reachable-server.example If Linux user services are not available on the login/VNC/jump node, keep `maglev server` running in a terminal or under your site's preferred process manager. +## Build From Source + +Use this path when working from a checkout, testing unreleased changes, or using a platform without a release artifact. + +Prerequisites: + +- `git` +- `bun` +- Optional but recommended: `rg` and `difft` + +```bash +git clone https://github.com/bmarimuthu-nv/Maglev.git maglev +cd maglev +./install.sh +maglev --version +``` + +`./install.sh` builds the standalone binary from source and installs `maglev` to `$HOME/.local/bin`. + +Common variants: + +```bash +# Install somewhere else +MAGLEV_INSTALL_DIR="$HOME/bin" ./install.sh + +# Force dependency reinstall before building +FORCE=1 ./install.sh + +# Build only, without installing +bun install +bun run build:standalone +``` + ## Daily Commands ```bash