diff --git a/src/core/dashboard-url.ts b/src/core/dashboard-url.ts index ce1b02358..bcf1421de 100644 --- a/src/core/dashboard-url.ts +++ b/src/core/dashboard-url.ts @@ -1,4 +1,8 @@ -import { platformMachineBaseUrl, publicReverseProxyBaseUrl } from '../platform/binding.js'; +import { + platformMachineBaseUrl, + publicReverseProxyBaseUrl, + readPlatformBinding, +} from '../platform/binding.js'; import { isRemoteAccessEnabled } from '../global-config.js'; export interface DashboardUrls { @@ -48,9 +52,7 @@ export function formatUrlHost(host: string): string { */ export function buildDashboardUrls(opts: { host: string; port: number | string; token?: string }): DashboardUrls { const localOrigin = `http://${formatUrlHost(String(opts.host))}:${opts.port}`; - // 对外基址:中心平台优先(远程访问开 + 已绑定),否则自建反代基址 BOTMUX_PUBLIC_URL。 - const platformBase = isRemoteAccessEnabled() ? platformMachineBaseUrl() : null; - const remoteBase = platformBase ?? publicReverseProxyBaseUrl(); + const remoteBase = remotePublicBase(); const primaryOrigin = remoteBase ?? localOrigin; const suffix = opts.token ? `/?t=${opts.token}` : '/'; return { @@ -63,3 +65,63 @@ export function buildDashboardUrls(opts: { host: string; port: number | string; export function buildDashboardUrl(opts: { host: string; port: number | string; token?: string }): string { return buildDashboardUrls(opts).url; } + +/** + * The remote public base for dashboard-family links, or null when neither the + * central platform (远程访问 on + bound) nor a self-hosted reverse proxy + * (`BOTMUX_PUBLIC_URL`) applies — callers then fall back to local `host:port`. + * Single source for the platform/public flip shared by {@link buildDashboardUrls} + * and {@link buildV3RunDetailUrl}, so dashboard links and v3 card deep-links + * flip to the platform together under the one 远程访问 switch. + * + * 对外基址:中心平台优先(远程访问开 + 已绑定),否则自建反代基址 BOTMUX_PUBLIC_URL。 + */ +function remotePublicBase(): string | null { + const platformBase = isRemoteAccessEnabled() ? platformMachineBaseUrl() : null; + return platformBase ?? publicReverseProxyBaseUrl(); +} + +/** + * Build the token-free deep link to a v3 run detail page (`…/#/v3/`), + * applying the same 远程访问 flip as {@link buildDashboardUrls}: central-platform + * machine subdomain first (远程访问 on + bound), then a self-hosted reverse proxy + * (`BOTMUX_PUBLIC_URL`), else the local `http://:` form. + * + * Workflow / gate / blocked cards advertise this as「Web 详情(需登录)」. Routing it + * through the platform base is what lets a REMOTE recipient actually reach the + * SPA: the page then hits the same-origin management API, gets a 401 carrying + * `X-Botmux-Login-Url`, and offers the one-click platform owner login (see + * {@link buildPlatformDashboardLoginUrl}). The prior local-only form was + * unreachable off-LAN, so that login flow could never trigger for remote users. + * + * No token is appended: v3 run projections stay behind the dashboard auth gate + * and are reached only after the owner login sets the cookie. `runId` is + * URL-encoded. + */ +export function buildV3RunDetailUrl(runId: string, opts: { host: string; port: number | string }): string { + const origin = remotePublicBase() ?? `http://${formatUrlHost(String(opts.host))}:${opts.port}`; + return `${origin}/#/v3/${encodeURIComponent(runId)}`; +} + +/** + * Build the platform owner-login URL advertised by an unauthenticated + * Dashboard response. The SPA replaces only the hash-route `next` value, so + * the server never exposes the Dashboard token or machine tunnel credential. + */ +export function buildPlatformDashboardLoginUrl(): string | undefined { + if (!isRemoteAccessEnabled()) return undefined; + const binding = readPlatformBinding(); + const machineId = binding?.machineId.trim(); + if (!binding || !machineId) return undefined; + try { + const platform = new URL(binding.platformUrl); + if (!['http:', 'https:'].includes(platform.protocol) || platform.username || platform.password) { + return undefined; + } + const loginUrl = new URL(`/open/${encodeURIComponent(machineId)}`, platform); + loginUrl.searchParams.set('next', '/#/'); + return loginUrl.toString(); + } catch { + return undefined; + } +} diff --git a/src/dashboard.ts b/src/dashboard.ts index a1e96d425..7f8acc4eb 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -66,7 +66,11 @@ import { checkCliAvailability } from './setup/cli-availability.js'; import { invalidWorkingDirs } from './utils/working-dir.js'; import { invalidateGlobalConfigCache, mergeDashboardConfig, mergeGlobalConfig, readGlobalConfig, type MaintenanceConfig, type RepoPickerMode, type WhiteboardConfig } from './global-config.js'; import { hostLocalTimeZone, scheduleTimeZone } from './utils/timezone.js'; -import { buildDashboardUrls, type DashboardUrls } from './core/dashboard-url.js'; +import { + buildDashboardUrls, + buildPlatformDashboardLoginUrl, + type DashboardUrls, +} from './core/dashboard-url.js'; import { resolveBotmuxDataDir } from './core/data-dir.js'; import { dashboardSecretPath } from './core/dashboard-secret.js'; import { getGitRepoInfo } from './core/session-row-enrichment.js'; @@ -2706,7 +2710,12 @@ const server = createServer(async (req, res) => { const authed = !!presentedToken && presentedToken === activeToken && !!activeToken; if (decision.kind === 'deny401') { - res.writeHead(401, { 'content-type': 'text/html; charset=utf-8' }); + const loginUrl = buildPlatformDashboardLoginUrl(); + res.writeHead(401, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + ...(loginUrl ? { 'x-botmux-login-url': loginUrl } : {}), + }); res.end('

Token expired

Run botmux dashboard to get a fresh URL.

'); return; } diff --git a/src/dashboard/web/app.tsx b/src/dashboard/web/app.tsx index b6aae07af..23615f030 100644 --- a/src/dashboard/web/app.tsx +++ b/src/dashboard/web/app.tsx @@ -36,6 +36,7 @@ import { dashboardClientShellRedirect, readDashboardClientShell, } from './client-shell.js'; +import { dashboardLoginHref } from './auth-login.js'; type OwnerAvatar = { avatarUrl: string; name?: string }; type TopbarAttentionNotice = { count: number; time: string; bot: string; reason: string }; @@ -169,6 +170,7 @@ const routeState = createDashboardRouteState(); const OWNER_AVATAR_KEY = 'botmux.ownerAvatar.v1'; const BUSY_STATUSES = new Set(['working', 'analyzing', 'active', 'starting']); const AUTH_EXPIRED_EVENT = 'botmux:auth-expired'; +let authLoginBaseUrl: string | undefined; function icon(children: ReactNode): ReactNode { return ; @@ -480,8 +482,13 @@ function TopbarStatusMenu(props: { summary: TopbarStatusSummary; autoOpen?: bool ); } -function AuthExpiredOverlay(props: { open: boolean; onClose(): void }): React.JSX.Element | null { +function AuthExpiredOverlay(props: { + open: boolean; + loginUrl?: string; + onClose(): void; +}): React.JSX.Element | null { if (!props.open) return null; + const canLogin = !!props.loginUrl; return (
{ if (event.target === event.currentTarget) props.onClose(); }} >
-

访问链接已失效

-

当前链接/访问已失效,请使用最新授权链接重新进入(运行 botmux dashboard 获取)。

- +

{canLogin ? '登录 Dashboard' : '访问链接已失效'}

+

{canLogin + ? '当前浏览器尚未登录。点击后将通过 Botmux 平台校验机器 owner 权限,并返回当前页面;无权限账号仍会被拒绝。' + : '当前链接/访问已失效,请使用最新授权链接重新进入(运行 botmux dashboard 获取)。'}

+
+ {props.loginUrl ? ( + + 一键登录 + + ) : null} + +
); @@ -1128,7 +1157,11 @@ function DashboardShell(): React.JSX.Element { - + ); } @@ -1144,7 +1177,11 @@ function setLocale(locale: DashboardLocale): void { // ── Auth-expiry overlay ────────────────────────────────────────────────────── let expiredShown = false; -export function showAuthExpiredOverlay(): void { +export function showAuthExpiredOverlay(loginUrl?: string): void { + const hasLoginUrl = !!dashboardLoginHref(loginUrl, location.hash); + const loginUrlChanged = hasLoginUrl && authLoginBaseUrl !== loginUrl; + if (hasLoginUrl) authLoginBaseUrl = loginUrl; + if (expiredShown && loginUrlChanged) renderShell(); if (expiredShown) return; expiredShown = true; window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)); @@ -1174,9 +1211,10 @@ window.fetch = async function patchedFetch( ): ReturnType { const res = await origFetch(...args); if (res.status === 401) { + const loginUrl = res.headers.get('x-botmux-login-url') ?? undefined; const method = (args[1]?.method ?? 'GET').toUpperCase(); const isRead = method === 'GET' || method === 'HEAD'; - if (isRead && !publicReadOnly) showAuthExpiredOverlay(); + if (loginUrl || (isRead && !publicReadOnly)) showAuthExpiredOverlay(loginUrl); else showReadOnlyToast(); } return res; diff --git a/src/dashboard/web/auth-login.ts b/src/dashboard/web/auth-login.ts new file mode 100644 index 000000000..24bb4faf0 --- /dev/null +++ b/src/dashboard/web/auth-login.ts @@ -0,0 +1,18 @@ +/** Build the platform SSO jump while preserving only the current SPA hash. */ +export function dashboardLoginHref( + platformLoginUrl: string | undefined, + hash: string, +): string | undefined { + if (!platformLoginUrl) return undefined; + try { + const url = new URL(platformLoginUrl); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return undefined; + const route = hash.startsWith('#/') && hash.length <= 4_096 && !/[\u0000-\u001f\u007f]/.test(hash) + ? hash + : '#/'; + url.searchParams.set('next', `/${route}`); + return url.toString(); + } catch { + return undefined; + } +} diff --git a/src/dashboard/web/style.css b/src/dashboard/web/style.css index c4c2d819e..1e3ca4afa 100644 --- a/src/dashboard/web/style.css +++ b/src/dashboard/web/style.css @@ -4208,6 +4208,30 @@ td code, justify-self: center; } +.auth-expired-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} + +.auth-login-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 18px; + border-radius: var(--radius-lg); + background: var(--accent); + color: var(--on-accent); + font-size: 13px; + text-decoration: none; +} + +.auth-login-link:hover { + filter: brightness(.96); +} + .checkbox-row { display: flex; flex-wrap: wrap; diff --git a/src/im/lark/v3-blocked-card.ts b/src/im/lark/v3-blocked-card.ts index c021eec33..1a459c72f 100644 --- a/src/im/lark/v3-blocked-card.ts +++ b/src/im/lark/v3-blocked-card.ts @@ -9,7 +9,7 @@ */ import { config } from '../../config.js'; -import { formatUrlHost } from '../../core/dashboard-url.js'; +import { buildV3RunDetailUrl } from '../../core/dashboard-url.js'; export const V3_BLOCKED_RETRY_ACTION = 'v3_blocked_retry'; /** 运行时 human-ask 选项按钮的 action(与「重试」同卡不同 namespace)。 */ @@ -76,7 +76,7 @@ export function v3BlockedCardNonce(runId: string, nodeId: string, attemptId: str } function v3RunDetailUrl(runId: string): string { - return `http://${formatUrlHost(config.dashboard.externalHost)}:${config.dashboard.port}/#/v3/${encodeURIComponent(runId)}`; + return buildV3RunDetailUrl(runId, { host: config.dashboard.externalHost, port: config.dashboard.port }); } export function buildV3BlockedCard(input: V3BlockedCardInput): string { diff --git a/src/im/lark/v3-gate-card.ts b/src/im/lark/v3-gate-card.ts index e8f5a45f0..8eb6efe63 100644 --- a/src/im/lark/v3-gate-card.ts +++ b/src/im/lark/v3-gate-card.ts @@ -9,7 +9,7 @@ */ import { config } from '../../config.js'; -import { formatUrlHost } from '../../core/dashboard-url.js'; +import { buildV3RunDetailUrl } from '../../core/dashboard-url.js'; import { DEFAULT_HUMAN_GATE_OPTIONS } from '../../workflows/v3/dag.js'; import { splitV3HostGatePrompt } from '../../workflows/v3/host-bindings.js'; @@ -55,9 +55,11 @@ export function v3GateCardNonce(runId: string, waitId: string): string { return `v3gate:${runId}:${waitId}`; } -/** v3 run 在 dashboard 的详情页 URL(跟 v0.2 的 #/workflows 对称,走 #/v3)。 */ +/** v3 run 在 dashboard 的详情页 URL(跟 v0.2 的 #/workflows 对称,走 #/v3)。 + * 远程访问开+已绑定时走平台子域,否则 BOTMUX_PUBLIC_URL / 本地——详见 + * {@link buildV3RunDetailUrl},让远程用户点卡片够得着 SPA 才能触发一键登录。 */ export function v3RunDetailUrl(runId: string): string { - return `http://${formatUrlHost(config.dashboard.externalHost)}:${config.dashboard.port}/#/v3/${encodeURIComponent(runId)}`; + return buildV3RunDetailUrl(runId, { host: config.dashboard.externalHost, port: config.dashboard.port }); } export function buildV3GateCard(input: V3GateCardInput): string { diff --git a/src/im/lark/v3-loop-grant-card.ts b/src/im/lark/v3-loop-grant-card.ts index 3977e50ad..bd6a82aed 100644 --- a/src/im/lark/v3-loop-grant-card.ts +++ b/src/im/lark/v3-loop-grant-card.ts @@ -10,7 +10,7 @@ */ import { config } from '../../config.js'; -import { formatUrlHost } from '../../core/dashboard-url.js'; +import { buildV3RunDetailUrl } from '../../core/dashboard-url.js'; export const V3_LOOP_GRANT_ACTION = 'v3_loop_grant'; @@ -52,7 +52,7 @@ export function v3LoopGrantCardNonce(runId: string, loopId: string, iteration: n } function v3RunDetailUrl(runId: string): string { - return `http://${formatUrlHost(config.dashboard.externalHost)}:${config.dashboard.port}/#/v3/${encodeURIComponent(runId)}`; + return buildV3RunDetailUrl(runId, { host: config.dashboard.externalHost, port: config.dashboard.port }); } export function buildV3LoopGrantCard(input: V3LoopGrantCardInput): string { diff --git a/src/im/lark/v3-progress-card.ts b/src/im/lark/v3-progress-card.ts index 7e974a1ed..d7c726f26 100644 --- a/src/im/lark/v3-progress-card.ts +++ b/src/im/lark/v3-progress-card.ts @@ -9,7 +9,7 @@ */ import { config } from '../../config.js'; -import { formatUrlHost } from '../../core/dashboard-url.js'; +import { buildV3RunDetailUrl } from '../../core/dashboard-url.js'; import type { V3ProgressView } from '../../workflows/v3/progress-projection.js'; import type { V3RunSaveActionValue } from './v3-run-save-card.js'; @@ -28,7 +28,7 @@ export interface V3ProgressCardOptions { const MAX_INLINE_IDS = 5; export function v3ProgressRunDetailUrl(runId: string): string { - return `http://${formatUrlHost(config.dashboard.externalHost)}:${config.dashboard.port}/#/v3/${encodeURIComponent(runId)}`; + return buildV3RunDetailUrl(runId, { host: config.dashboard.externalHost, port: config.dashboard.port }); } /** Render one complete Feishu card body from the safe v3 progress projection. */ diff --git a/src/im/lark/v3-revisit-grant-card.ts b/src/im/lark/v3-revisit-grant-card.ts index d5dbeaca2..392588534 100644 --- a/src/im/lark/v3-revisit-grant-card.ts +++ b/src/im/lark/v3-revisit-grant-card.ts @@ -15,7 +15,7 @@ */ import { config } from '../../config.js'; -import { formatUrlHost } from '../../core/dashboard-url.js'; +import { buildV3RunDetailUrl } from '../../core/dashboard-url.js'; export const V3_REVISIT_GRANT_ACTION = 'v3_revisit_grant'; @@ -59,7 +59,7 @@ export function v3RevisitGrantCardNonce(runId: string, sourceNodeId: string, att } function v3RunDetailUrl(runId: string): string { - return `http://${formatUrlHost(config.dashboard.externalHost)}:${config.dashboard.port}/#/v3/${encodeURIComponent(runId)}`; + return buildV3RunDetailUrl(runId, { host: config.dashboard.externalHost, port: config.dashboard.port }); } export function buildV3RevisitGrantCard(input: V3RevisitGrantCardInput): string { diff --git a/test/dashboard-login-ui.test.ts b/test/dashboard-login-ui.test.ts new file mode 100644 index 000000000..fc3a6efd9 --- /dev/null +++ b/test/dashboard-login-ui.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { dashboardLoginHref } from '../src/dashboard/web/auth-login.js'; + +describe('dashboardLoginHref', () => { + it('preserves the current workflow hash in the platform SSO jump', () => { + expect(dashboardLoginHref( + 'https://platform.example/open/m-1?next=%2F%23%2F', + '#/workflows/run-1', + )).toBe( + 'https://platform.example/open/m-1?next=%2F%23%2Fworkflows%2Frun-1', + ); + }); + + it('falls back to the dashboard root for malformed or missing hashes', () => { + const base = 'https://platform.example/open/m-1?next=%2F%23%2F'; + expect(dashboardLoginHref(base, '')).toBe(base); + expect(dashboardLoginHref(base, '#evil')).toBe(base); + }); + + it('rejects missing, non-http, and malformed platform login URLs', () => { + expect(dashboardLoginHref(undefined, '#/workflows/run-1')).toBeUndefined(); + expect(dashboardLoginHref('javascript:alert(1)', '#/workflows/run-1')).toBeUndefined(); + expect(dashboardLoginHref('not a url', '#/workflows/run-1')).toBeUndefined(); + }); +}); + +describe('Dashboard one-click login wiring', () => { + it('advertises the token-free login URL on 401 and renders it in the existing overlay', () => { + const server = readFileSync(new URL('../src/dashboard.ts', import.meta.url), 'utf8'); + const app = readFileSync(new URL('../src/dashboard/web/app.tsx', import.meta.url), 'utf8'); + expect(server).toContain("'x-botmux-login-url': loginUrl"); + expect(server).toContain("'cache-control': 'no-store'"); + expect(app).toContain("res.headers.get('x-botmux-login-url')"); + expect(app).toContain('id="dashboard-one-click-login"'); + expect(app).toContain('一键登录'); + }); +}); diff --git a/test/dashboard-url.test.ts b/test/dashboard-url.test.ts index 10dfa47e7..0d1782926 100644 --- a/test/dashboard-url.test.ts +++ b/test/dashboard-url.test.ts @@ -9,21 +9,36 @@ vi.mock('../src/global-config.js', () => ({ vi.mock('../src/platform/binding.js', () => ({ platformMachineBaseUrl: vi.fn(() => null), publicReverseProxyBaseUrl: vi.fn(() => null), + readPlatformBinding: vi.fn(() => null), })); -import { buildDashboardUrl, buildDashboardUrls, formatUrlHost } from '../src/core/dashboard-url.js'; +import { + buildDashboardUrl, + buildDashboardUrls, + buildPlatformDashboardLoginUrl, + buildV3RunDetailUrl, + formatUrlHost, +} from '../src/core/dashboard-url.js'; import { isRemoteAccessEnabled } from '../src/global-config.js'; -import { platformMachineBaseUrl, publicReverseProxyBaseUrl } from '../src/platform/binding.js'; +import { + platformMachineBaseUrl, + publicReverseProxyBaseUrl, + readPlatformBinding, +} from '../src/platform/binding.js'; const setRemote = (on: boolean) => vi.mocked(isRemoteAccessEnabled).mockReturnValue(on); const setPlatform = (base: string | null) => vi.mocked(platformMachineBaseUrl).mockReturnValue(base); const setPublic = (base: string | null) => vi.mocked(publicReverseProxyBaseUrl).mockReturnValue(base); +const setBinding = (binding: ReturnType) => ( + vi.mocked(readPlatformBinding).mockReturnValue(binding) +); describe('buildDashboardUrl', () => { beforeEach(() => { setRemote(false); setPlatform(null); setPublic(null); + setBinding(null); }); it('builds a local host:port URL with token when remote access is off', () => { @@ -165,3 +180,108 @@ describe('formatUrlHost', () => { } }); }); + +describe('buildPlatformDashboardLoginUrl', () => { + beforeEach(() => { + setRemote(false); + setBinding(null); + }); + + it('builds a token-free platform owner-login URL with a safe root fallback', () => { + setRemote(true); + setBinding({ + platformUrl: 'https://platform.example', + machineId: 'm-1', + machineToken: 'machine-secret', + }); + expect(buildPlatformDashboardLoginUrl()).toBe( + 'https://platform.example/open/m-1?next=%2F%23%2F', + ); + expect(buildPlatformDashboardLoginUrl()).not.toContain('machine-secret'); + }); + + it('is unavailable unless remote access and a platform binding are both present', () => { + setBinding({ platformUrl: 'https://platform.example', machineId: 'm-1', machineToken: 'secret' }); + expect(buildPlatformDashboardLoginUrl()).toBeUndefined(); + setRemote(true); + setBinding(null); + expect(buildPlatformDashboardLoginUrl()).toBeUndefined(); + }); + + it('rejects a non-http platform binding and safely encodes the machine id path segment', () => { + setRemote(true); + setBinding({ platformUrl: 'file:///tmp/platform', machineId: 'm/1', machineToken: 'secret' }); + expect(buildPlatformDashboardLoginUrl()).toBeUndefined(); + setBinding({ platformUrl: 'https://platform.example/base', machineId: 'm/1', machineToken: 'secret' }); + expect(buildPlatformDashboardLoginUrl()).toContain('/open/m%2F1?'); + }); +}); + +describe('buildV3RunDetailUrl', () => { + // Mirrors the buildDashboardUrls flip so a REMOTE recipient tapping a v3 + // card's「Web 详情」can actually reach the SPA (→ 401 → one-click login), + // instead of the old always-LAN link that was unreachable off-LAN. + beforeEach(() => { + setRemote(false); + setPlatform(null); + setPublic(null); + }); + + const opts = { host: '1.2.3.4', port: 7891 }; + + it('stays local host:port when remote access is off', () => { + expect(buildV3RunDetailUrl('run-1', opts)).toBe('http://1.2.3.4:7891/#/v3/run-1'); + }); + + it('stays local when remote access is on but the host is not bound', () => { + setRemote(true); + setPlatform(null); + expect(buildV3RunDetailUrl('run-1', opts)).toBe('http://1.2.3.4:7891/#/v3/run-1'); + }); + + it('stays local when bound but remote access is off (switch gates it)', () => { + setRemote(false); + setPlatform('https://m-deadbeef.botmux.example'); + expect(buildV3RunDetailUrl('run-1', opts)).toBe('http://1.2.3.4:7891/#/v3/run-1'); + }); + + it('routes through the platform machine subdomain when remote access is on and bound', () => { + setRemote(true); + setPlatform('https://m-deadbeef.botmux.example'); + expect(buildV3RunDetailUrl('run-1', opts)).toBe( + 'https://m-deadbeef.botmux.example/#/v3/run-1', + ); + }); + + it('routes through BOTMUX_PUBLIC_URL when set and no platform (self-hosted nginx)', () => { + setPublic('https://botmux.example.com'); + expect(buildV3RunDetailUrl('run-1', opts)).toBe('https://botmux.example.com/#/v3/run-1'); + }); + + it('lets the platform subdomain win over BOTMUX_PUBLIC_URL when both apply', () => { + setRemote(true); + setPlatform('https://m-deadbeef.botmux.example'); + setPublic('https://botmux.example.com'); + expect(buildV3RunDetailUrl('run-1', opts)).toBe( + 'https://m-deadbeef.botmux.example/#/v3/run-1', + ); + }); + + it('never appends a token and URL-encodes the runId (both local and platform)', () => { + expect(buildV3RunDetailUrl('run with space', opts)).toBe( + 'http://1.2.3.4:7891/#/v3/run%20with%20space', + ); + setRemote(true); + setPlatform('https://m-deadbeef.botmux.example'); + const url = buildV3RunDetailUrl('run with space', opts); + expect(url).toBe('https://m-deadbeef.botmux.example/#/v3/run%20with%20space'); + expect(url).not.toContain('?t='); + expect(url).not.toContain('token'); + }); + + it('brackets an IPv6 literal host in the local form', () => { + expect(buildV3RunDetailUrl('run-1', { host: '::1', port: 7891 })).toBe( + 'http://[::1]:7891/#/v3/run-1', + ); + }); +});