diff --git a/src/dashboard/public-redact.ts b/src/dashboard/public-redact.ts index 8b67774fb..4c2172723 100644 --- a/src/dashboard/public-redact.ts +++ b/src/dashboard/public-redact.ts @@ -61,12 +61,17 @@ export function redactSchedulesForPublic(schedules: unknown[]): unknown[] { }); } -/** Branch names often carry issue/customer identifiers. `/api/sessions` and +/** Branch names often carry issue/customer identifiers. `riffAccessUrl` is the + * Riff AIO Sandbox **write** capability — a bearer URL whose unique subdomain is + * itself the credential (riff-backend.ts:hashUrlForLog), so an anonymous read-only + * visitor must never receive it (they'd gain write access to the sandbox). Read + * access on the dashboard goes through the local worker log terminal (webPort), + * which stays; only the sandbox write URL is stripped. `/api/sessions` and * `/events` are both public-read surfaces, so keep one non-mutating projection * for their shared session row shape. */ export function redactSessionForPublic(session: unknown): unknown { if (!session || typeof session !== 'object' || Array.isArray(session)) return session; - const { gitBranch: _gitBranch, ...rest } = session as Record; + const { gitBranch: _gitBranch, riffAccessUrl: _riffAccessUrl, ...rest } = session as Record; return rest; } @@ -88,7 +93,7 @@ export function redactSessionEventForPublic(type: string, body: unknown): unknow && typeof eventBody.patch === 'object' && !Array.isArray(eventBody.patch) ) { - const { gitBranch: _gitBranch, ...patch } = eventBody.patch as Record; + const { gitBranch: _gitBranch, riffAccessUrl: _riffAccessUrl, ...patch } = eventBody.patch as Record; return { ...eventBody, patch }; } return body; diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 3c0c45d09..f4a648374 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -317,6 +317,8 @@ const zh: DashboardMessages = { 'sessions.locating': '发送中...', 'sessions.cooldown': '冷却 {seconds}s', 'sessions.openTerminal': '终端', + 'sessions.openReadonlyTerminal': '打开只读终端', + 'sessions.openWritableTerminal': '打开可操作 Web 终端', 'sessions.writeLink': '操作链接', 'sessions.writeLinkHint': '获取可操作终端链接(带写权限 token,新标签打开)', 'sessions.writeLinkFail': '获取操作链接失败', @@ -2315,6 +2317,8 @@ const en: DashboardMessages = { 'sessions.locating': 'Sending...', 'sessions.cooldown': 'Cooldown {seconds}s', 'sessions.openTerminal': 'Terminal', + 'sessions.openReadonlyTerminal': 'Open read-only terminal', + 'sessions.openWritableTerminal': 'Open interactive web terminal', 'sessions.writeLink': 'Write Link', 'sessions.writeLinkHint': 'Get a writable terminal link (carries a write-access token; opens in a new tab)', 'sessions.writeLinkFail': 'Failed to get write link', diff --git a/src/dashboard/web/session-terminal.ts b/src/dashboard/web/session-terminal.ts index 0d95fe250..17eb61486 100644 --- a/src/dashboard/web/session-terminal.ts +++ b/src/dashboard/web/session-terminal.ts @@ -9,9 +9,12 @@ function currentLocation(): SessionTerminalLocation | null { } export function sessionTerminalHref(s: any, loc: SessionTerminalLocation | null = currentLocation()): string | null { - // riff:dashboard 行内按钮保留 AIO Sandbox 直达(飞书卡片侧 Web终端=日志页、 - // 操作链接=AIO;dashboard 只有一个终端入口,直达沙箱更有用)。 - if (s?.riffAccessUrl) return s.riffAccessUrl; + // riff:只读入口对齐飞书卡片语义 —— 「Web终端=日志页」走本地 worker 端口的 + // 只读日志视图,而不是 riffAccessUrl。riffAccessUrl 是 AIO Sandbox 的**可写** + // capability(bearer URL,见 riff-backend.ts:hashUrlForLog「the unique subdomain + // IS the write capability」),只能经鉴权的 /write-link(🔑「操作链接=AIO」)下发。 + // 若在此短路返回它,只读图标会打开可写沙箱、且匿名只读面板也会拿到写能力 —— + // 故这里一律走 webPort 分支,让读/写入口与卡片侧一一对应。 if (!s?.webPort || !loc) return null; // On the central HTTPS machine domain, terminals must go through the same // origin `/s/` reverse proxy. Exposing a raw port would produce a diff --git a/src/dashboard/web/sessions-kanban.tsx b/src/dashboard/web/sessions-kanban.tsx index e417a6992..1d0ada3da 100644 --- a/src/dashboard/web/sessions-kanban.tsx +++ b/src/dashboard/web/sessions-kanban.tsx @@ -45,6 +45,7 @@ export interface SessionsKanbanIcons { details: string; feishu: string; history: string; + key: string; lock: string; restart: string; terminal: string; @@ -79,6 +80,7 @@ export interface SessionsKanbanCallbacks { onNeedTeamBoard: (team: SessionsKanbanTeam) => void; onNeedTeams: () => void; onOpenTerminal?: (row: any) => void; + onOpenWritableTerminal?: (row: any, button: HTMLButtonElement) => void; onRename: (row: any, title: string) => void; onRestart: (row: any, button: HTMLButtonElement) => void; onTeamScope: (scope: { chats: number; sessions: number } | null) => void; @@ -431,10 +433,18 @@ function KanbanCard(props: { callbacks.onOpenTerminal?.(row)} /> ) : null} + {row.webPort && callbacks.onOpenWritableTerminal ? ( + callbacks.onOpenWritableTerminal?.(row, button)} + /> + ) : null} {row.feishuChatLink ? ( - {readOnly ? ( - event.stopPropagation()} - dangerouslySetInnerHTML={rawHtml(ICON.terminal)} - /> - ) : ( + + event.stopPropagation()} + dangerouslySetInnerHTML={rawHtml(ICON.terminal)} + /> + {canOpenWritable ? (