Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 additions & 4 deletions src/core/dashboard-url.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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/<runId>`),
* 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://<externalHost>:<port>` 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;
}
}
13 changes: 11 additions & 2 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('<h1>Token expired</h1><p>Run <code>botmux dashboard</code> to get a fresh URL.</p>');
return;
}
Expand Down
52 changes: 45 additions & 7 deletions src/dashboard/web/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 <svg viewBox="0 0 16 16" aria-hidden="true">{children}</svg>;
Expand Down Expand Up @@ -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 (
<div
id="auth-expired-overlay"
Expand All @@ -490,9 +497,31 @@ function AuthExpiredOverlay(props: { open: boolean; onClose(): void }): React.JS
onClick={event => { if (event.target === event.currentTarget) props.onClose(); }}
>
<div className="auth-expired-dialog" role="dialog" aria-modal="true" aria-labelledby="auth-expired-title">
<h2 id="auth-expired-title">访问链接已失效</h2>
<p>当前链接/访问已失效,请使用最新授权链接重新进入(运行 botmux dashboard 获取)。</p>
<button id="auth-expired-dismiss" type="button" className="primary" onClick={props.onClose}>知道了</button>
<h2 id="auth-expired-title">{canLogin ? '登录 Dashboard' : '访问链接已失效'}</h2>
<p>{canLogin
? '当前浏览器尚未登录。点击后将通过 Botmux 平台校验机器 owner 权限,并返回当前页面;无权限账号仍会被拒绝。'
: '当前链接/访问已失效,请使用最新授权链接重新进入(运行 botmux dashboard 获取)。'}</p>
<div className="auth-expired-actions">
{props.loginUrl ? (
<a
id="dashboard-one-click-login"
className="auth-login-link primary"
href={props.loginUrl}
target="_top"
rel="noopener"
>
一键登录
</a>
) : null}
<button
id="auth-expired-dismiss"
type="button"
className={canLogin ? 'secondary' : 'primary'}
onClick={props.onClose}
>
{canLogin ? '暂不登录' : '知道了'}
</button>
</div>
</div>
</div>
);
Expand Down Expand Up @@ -1128,7 +1157,11 @@ function DashboardShell(): React.JSX.Element {
</div>
</div>
</div>
<AuthExpiredOverlay open={authExpiredOpen} onClose={closeAuthExpired} />
<AuthExpiredOverlay
open={authExpiredOpen}
loginUrl={dashboardLoginHref(authLoginBaseUrl, location.hash)}
onClose={closeAuthExpired}
/>
</>
);
}
Expand All @@ -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));
Expand Down Expand Up @@ -1174,9 +1211,10 @@ window.fetch = async function patchedFetch(
): ReturnType<typeof fetch> {
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;
Expand Down
18 changes: 18 additions & 0 deletions src/dashboard/web/auth-login.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
24 changes: 24 additions & 0 deletions src/dashboard/web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/im/lark/v3-blocked-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)。 */
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions src/im/lark/v3-gate-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/im/lark/v3-loop-grant-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/im/lark/v3-progress-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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. */
Expand Down
4 changes: 2 additions & 2 deletions src/im/lark/v3-revisit-grant-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
Loading