diff --git a/src/core/ask-broker.ts b/src/core/ask-broker.ts index 837fefd8c..cd8404f91 100644 --- a/src/core/ask-broker.ts +++ b/src/core/ask-broker.ts @@ -441,6 +441,51 @@ export function getAskSnapshot(askId: string): PendingAsk | undefined { return a ? snapshot(a) : undefined; } +/** List unsettled asks for Desktop / dashboard aggregation (read-only snapshots). */ +export function listPendingAsks(): PendingAsk[] { + gcSettled(); + const out: PendingAsk[] = []; + for (const ask of pending.values()) { + if (!ask.settled) out.push(snapshot(ask)); + } + return out; +} + +/** + * Desktop / trusted-host answer path. Bypasses canTalk (no Feishu openId) — + * caller must be authenticated as the local dashboard/desktop operator. + */ +export function submitAskFromDesktop(args: { + askId: string; + /** Selected option keys per question (same shape as submitAsk selections). */ + selections: ReadonlyArray>; + by?: string; +}): AskClickOutcome { + gcSettled(); + const ask = pending.get(args.askId); + if (!ask) return 'stale'; + if (ask.settled) return 'already_settled'; + + const answers = args.selections; + for (let i = 0; i < ask.questions.length; i++) { + const q = ask.questions[i]!; + const sel = answers[i] ?? []; + if (!q.multiSelect && sel.length !== 1) return 'stale'; + for (const key of sel) { + if (!q.options.some((o) => o.key === key)) return 'stale'; + } + } + + settle(args.askId, { + kind: 'answered', + answers, + by: args.by ?? 'desktop', + comment: null, + timedOut: false, + }); + return 'accepted'; +} + /** Read a pending ask by id — for tests only. Returns a snapshot; mutating it * has no effect on broker state. */ export function _getPending(askId: string): PendingAsk | undefined { diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 2f22e5f4c..35cebd7d7 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -95,6 +95,7 @@ import { validateTriggerRequest, type TriggerResponse } from '../services/trigge import { resolveCliSelection, selectionKeyForBot } from '../setup/cli-selection.js'; import { checkCliAvailability } from '../setup/cli-availability.js'; import { enrichHistorySenders, type HistoryBotInfo } from '../dashboard/history-senders.js'; +import { listPendingAsks, submitAskFromDesktop } from './ask-broker.js'; let exactChatGrantHandler: typeof applyExactChatGrantRequest = applyExactChatGrantRequest; /** Test seam: replace the exact-grant service without touching live Feishu/config state. */ @@ -491,6 +492,49 @@ ipcRoute('POST', DEVICE_ISOLATION_COMMIT_PATH, (req, res) => ipcRoute('POST', DEVICE_ISOLATION_RELEASE_PATH, (req, res) => handleDeviceIsolationActivationRoute(req, res, releaseDeviceIsolationActivation)); +// ─── Pending asks (trusted Desktop/dashboard operator only) ───────────────── + +ipcRoute('GET', '/api/asks/pending', (req, res) => { + if (!isTrustedHostIpcRequest(req)) { + return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' }); + } + const asks = listPendingAsks().map((ask) => ({ + askId: ask.askId, + sessionId: ask.sessionId, + larkAppId: ask.larkAppId, + chatId: ask.chatId, + rootMessageId: ask.rootMessageId, + questions: ask.questions, + deadlineAt: ask.deadlineAt, + createdAt: ask.createdAt, + })); + return jsonRes(res, 200, { asks }); +}); + +ipcRoute('POST', '/api/asks/answer', async (req, res) => { + if (!isTrustedHostIpcRequest(req)) { + return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' }); + } + let body: { askId?: string; selections?: string[][]; by?: string }; + try { + body = await readJsonBody(req); + } catch { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + if (!body.askId || !Array.isArray(body.selections)) { + return jsonRes(res, 400, { ok: false, error: 'askId_and_selections_required' }); + } + const outcome = submitAskFromDesktop({ + askId: body.askId, + selections: body.selections, + by: typeof body.by === 'string' ? body.by : 'desktop', + }); + if (outcome !== 'accepted') { + return jsonRes(res, 409, { ok: false, error: outcome }); + } + return jsonRes(res, 200, { ok: true, outcome }); +}); + ipcRoute('GET', '/api/sessions', (_req, res) => { // Active first (live state), closed appended (historical) const active = listActiveSessions().map(composeRowFromActive); diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index c5d1d0312..f0e45ab6d 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -81,6 +81,14 @@ export interface SessionRow { /** Riff AIO Sandbox web terminal link. When set, the dashboard "Web终端" * button opens this URL directly instead of building a local port link. */ riffAccessUrl?: string; + /** Presentation enrichment stamped by the central dashboard read-model: + * bot avatar URL from the live daemon descriptor. + * Absent on older daemons — consumers must fall back. */ + botAvatarUrl?: string; + /** Repo top-level dir name of workingDir, when it is a git repo. */ + repoName?: string; + /** Current branch of workingDir; absent for detached HEAD / non-repo. */ + gitBranch?: string; } export function feishuChatLink(chatId: string, brand: Brand = 'feishu'): string { diff --git a/src/core/session-cwd.ts b/src/core/session-cwd.ts index a3d5126ae..cd57d3d22 100644 --- a/src/core/session-cwd.ts +++ b/src/core/session-cwd.ts @@ -5,6 +5,7 @@ */ import type { DaemonSession } from './types.js'; import * as sessionStore from '../services/session-store.js'; +import { dashboardEventBus } from './dashboard-events.js'; /** * 重钉一个话题会话的工作目录(daemon 记录 = 唯一事实源): @@ -20,4 +21,11 @@ export function repinSessionWorkingDir(ds: DaemonSession, resolvedPath: string): // 两条改 cwd 的路径都必须清。 ds.session.riffRepoDirs = undefined; sessionStore.updateSession(ds.session); + dashboardEventBus.publish({ + type: 'session.update', + body: { + sessionId: ds.session.sessionId, + patch: { workingDir: resolvedPath }, + }, + }); } diff --git a/src/core/session-row-enrichment.ts b/src/core/session-row-enrichment.ts new file mode 100644 index 000000000..53018d23c --- /dev/null +++ b/src/core/session-row-enrichment.ts @@ -0,0 +1,122 @@ +// src/core/session-row-enrichment.ts +// +// Git presentation metadata for dashboard session rows. Resolution happens in +// the central dashboard read-model (not in an HTTP request handler), so REST +// snapshots and SSE updates share one row shape. + +import { execFile } from 'node:child_process'; +import { basename } from 'node:path'; + +// ── Git repo info (per-cwd TTL cache) ───────────────────────────────────── + +export type GitRepoInfo = { + /** basename of the repo top-level dir. */ + repoName: string; + /** Current branch; null for detached HEAD. */ + branch: string | null; +}; + +export interface GitRepoResolveOptions { + /** Bypass a possibly stale positive cache at a known turn boundary. */ + force?: boolean; +} + +const GIT_INFO_OK_TTL_MS = 60_000; +const GIT_INFO_MISS_TTL_MS = 300_000; +const GIT_TIMEOUT_MS = 1_500; +/** Concurrent git probes across all callers (a 99-session first poll must not + * fork-bomb the host). */ +const GIT_MAX_CONCURRENT_PROBES = 8; + +const gitInfoCache = new Map(); +/** Dedup so a poll burst spawns at most one git probe per cwd. */ +const gitInfoInflight = new Map>(); +let gitProbeSequence = 0; + +let gitProbesRunning = 0; +const gitProbeQueue: Array<() => void> = []; + +async function withGitProbeSlot(fn: () => Promise): Promise { + if (gitProbesRunning >= GIT_MAX_CONCURRENT_PROBES) { + await new Promise((resolve) => gitProbeQueue.push(resolve)); + } + gitProbesRunning++; + try { + return await fn(); + } finally { + gitProbesRunning--; + gitProbeQueue.shift()?.(); + } +} + +function runGit(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', + args, + { timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 }, + (err, stdout) => (err ? reject(err) : resolve(stdout)), + ); + }); +} + +async function probeGitRepoInfo(cwd: string): Promise { + // One probe: line 1 = top-level path, line 2 = branch ('HEAD' when detached). + const out = await runGit(['-C', cwd, 'rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD']); + const [top = '', branchRaw = ''] = out.split('\n').map((l) => l.trim()); + if (!top) return null; + return { + repoName: basename(top) || top, + branch: branchRaw && branchRaw !== 'HEAD' ? branchRaw : null, + }; +} + +/** Resolve repoName/branch for a session cwd; null when not a git repo. Never throws. */ +export async function getGitRepoInfo( + cwd: string, + options: GitRepoResolveOptions = {}, +): Promise { + const dir = cwd.trim(); + if (!dir) return null; + const now = Date.now(); + const hit = gitInfoCache.get(dir); + if ( + !options.force + && hit + && now - hit.at < (hit.info ? GIT_INFO_OK_TTL_MS : GIT_INFO_MISS_TTL_MS) + ) { + return hit.info; + } + const inflight = gitInfoInflight.get(dir); + if (!options.force && inflight) return inflight; + const sequence = ++gitProbeSequence; + const p = (async (): Promise => { + try { + return await withGitProbeSlot(() => probeGitRepoInfo(dir)); + } catch { + return null; + } + })(); + gitInfoInflight.set(dir, p); + try { + const info = await p; + const current = gitInfoCache.get(dir); + // A force refresh started later must remain authoritative even if an older + // probe happens to finish after it. + if (!current || sequence >= current.sequence) { + gitInfoCache.set(dir, { at: Date.now(), info, sequence }); + } + return info; + } finally { + if (gitInfoInflight.get(dir) === p) gitInfoInflight.delete(dir); + } +} + +/** Test hook: clear the resolver cache. */ +export function clearSessionRowEnrichmentCaches(): void { + gitInfoCache.clear(); +} diff --git a/src/dashboard.ts b/src/dashboard.ts index 28b849c3e..efede5beb 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -19,6 +19,11 @@ import { } from './dashboard/auth.js'; import { DaemonRegistry, botsRosterSignature } from './dashboard/registry.js'; import { Aggregator, subscribeDaemon } from './dashboard/aggregator.js'; +import { createSessionPresentationCoordinator } from './dashboard/session-presentation.js'; +import { + parseDashboardAskAnswerRequest, + proxyDashboardAskAnswer, +} from './dashboard/desktop-asks.js'; import { createDebugTerminalManager } from './dashboard/debug-terminal.js'; import { pickCreatorForGroup } from './dashboard/operator-selector.js'; import { buildTeamGroupCreatePayload, planGroupCreator } from './dashboard/team-group.js'; @@ -36,6 +41,8 @@ import { handleConnectorApi } from './dashboard/connector-api.js'; import { redactGroupsForPublic, redactSchedulesForPublic, + redactSessionEventForPublic, + redactSessionsForPublic, redactSettingsForPublic, } from './dashboard/public-redact.js'; import { handleWebhookRoute } from './dashboard/webhook-routes.js'; @@ -59,6 +66,7 @@ import { hostLocalTimeZone, scheduleTimeZone } from './utils/timezone.js'; import { buildDashboardUrls, 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'; import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js'; import { isLocalDevInstall, botmuxVersion, botmuxVersionAt, botmuxCliEntry, botmuxInstallRoot } from './utils/install-info.js'; import { checkNode, detectBotmuxInstalls, resolveCurrentVersion } from './utils/install-diagnostics.js'; @@ -165,7 +173,10 @@ import { startPlatformTunnelClient, type PlatformBotInfo, type PlatformTeamSyncM import { applyPlatformTeamSync, getPlatformTeamSyncRev, listPlatformTeams } from './services/platform-team-store.js'; import { getBotUnionId } from './services/bot-union-ids-store.js'; import { cleanupIdleSessions, parseIdleCleanupHours } from './dashboard/session-cleanup.js'; -import { handleDesktopCompat } from './dashboard/compat.js'; +import { + compatMachineIdForAuthenticatedRequest, + handleDesktopCompat, +} from './dashboard/compat.js'; import { isDashboardChunkJsPath, missingDashboardChunkModule } from './dashboard/stale-chunk-module.js'; import { aggregateRoleBatch, parseRoleBatchTargets } from './dashboard/roles-batch.js'; import { automateOpenPlatformSetup, vcListenerEventGateError } from './setup/open-platform-automation.js'; @@ -289,6 +300,14 @@ function verifyDashboardBinding(port: number): Promise { mkdirSync(REGISTRY_DIR, { recursive: true }); const registry = new DaemonRegistry(REGISTRY_DIR); const aggregator = new Aggregator(); +const sessionPresentation = createSessionPresentationCoordinator(aggregator, getGitRepoInfo); + +// Keep Git-derived fields in the central read-model so REST snapshots and SSE +// share one row shape. Idle/limited turn boundaries force a branch refresh +// after the CLI has had a chance to change repositories — that is one +// `git rev-parse` per session per turn, bounded by the resolver's concurrency +// cap, NOT a slow background poll. +aggregator.on(sessionPresentation.onEvent); // 调试终端(owner-only 裸 bash)。默认工作目录取当前所有 session 的工作目录去重, // 让 owner 从熟悉的目录起终端复现问题;都没有时模块内退回 homedir。 @@ -1255,7 +1274,11 @@ async function attachDaemon(d: import('./dashboard/registry.js').DaemonInfo): Pr ]); const s = await sRes.json() as { sessions: any[] }; const sch = await schRes.json() as { schedules: any[] }; - aggregator.hydrateSessions(d.larkAppId, s.sessions ?? []); + const rows = (s.sessions ?? []).map((row) => ( + d.botAvatarUrl ? { ...row, botAvatarUrl: d.botAvatarUrl } : row + )); + aggregator.hydrateSessions(d.larkAppId, rows); + for (const row of rows) sessionPresentation.schedule(d.larkAppId, row); aggregator.hydrateSchedules(sch.schedules ?? []); } catch (e: any) { logger.warn(`[dashboard] hydrate ${d.larkAppId}: ${e.message ?? e}`); @@ -1276,14 +1299,25 @@ async function attachDaemon(d: import('./dashboard/registry.js').DaemonInfo): Pr } function syncSubscriptions(): void { - const online = new Set(registry.list().map(d => d.larkAppId)); + const daemons = registry.list(); + const online = new Set(daemons.map(d => d.larkAppId)); // Attach (hydrate + subscribe) any newly-online daemon. Fire-and-forget // because the registry callback is sync and the attach is per-daemon // independent. - for (const d of registry.list()) { + for (const d of daemons) { if (!subs.has(d.larkAppId)) { void attachDaemon(d); } + // Push avatar changes in BOTH directions: gating on `d.botAvatarUrl` would + // leave the stale image on every row when a bot's avatar is cleared. + const avatar = d.botAvatarUrl ?? null; + for (const row of aggregator.getSessions()) { + if (row.larkAppId !== d.larkAppId || (row.botAvatarUrl ?? null) === avatar) continue; + aggregator.applyEvent(d.larkAppId, { + type: 'session.update', + body: { sessionId: row.sessionId, patch: { botAvatarUrl: avatar } }, + }); + } } // Close subscriptions for daemons that went offline. Cache entries are // intentionally retained — the user may still want to see the last-known @@ -2385,7 +2419,17 @@ const server = createServer(async (req, res) => { // Desktop shell compatibility probe (read-only, no token required). Keep it // outside the browser auth gate so packaged desktop apps can decide whether // this runtime speaks their dashboard protocol before loading the SPA. - if (handleDesktopCompat(req, res, url)) { + if (req.method === 'GET' && url.pathname === '/__desktop/compat') { + const presentedToken = authedToken(req, url); + const boundMachineId = activeToken && presentedToken === activeToken + ? readPlatformBinding()?.machineId + : null; + const compatMachineId = compatMachineIdForAuthenticatedRequest( + presentedToken, + activeToken, + boundMachineId, + ); + handleDesktopCompat(req, res, url, { machineId: compatMachineId ?? undefined }); return; } @@ -2645,7 +2689,51 @@ const server = createServer(async (req, res) => { ? { ...s, botName: n } : s; }); - return jsonRes(res, 200, { sessions }); + return jsonRes(res, 200, { + sessions: authed ? sessions : redactSessionsForPublic(sessions), + }); + } + + // Desktop / operator UI: aggregate pending ask-hooks across daemons. + if (req.method === 'GET' && url.pathname === '/api/asks/pending') { + const daemons = registry.list(); + const asks: unknown[] = []; + await Promise.all(daemons.map(async (d) => { + try { + const upstream = await fetchDaemonIpc(d.ipcPort, '/api/asks/pending', { + signal: AbortSignal.timeout(2_000), + }); + if (!upstream.ok) return; + const body = await upstream.json() as { asks?: unknown[] }; + for (const a of body.asks ?? []) { + asks.push({ + ...(typeof a === 'object' && a ? a : {}), + botName: d.botName, + larkAppId: d.larkAppId, + }); + } + } catch { + /* offline daemon */ + } + })); + return jsonRes(res, 200, { asks }); + } + + if (req.method === 'POST' && url.pathname === '/api/asks/answer') { + let body: unknown; + try { + body = await readJsonBody(req); + } catch { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + const parsed = parseDashboardAskAnswerRequest(body); + if (!parsed.ok) { + return jsonRes(res, 400, { ok: false, error: parsed.error }); + } + const upstream = await proxyDashboardAskAnswer(parsed.value, proxyToDaemon); + res.writeHead(upstream.status, { 'content-type': upstream.contentType }); + res.end(upstream.body); + return; } if (req.method === 'POST' && url.pathname === '/api/sessions/cleanup-idle') { let body: { olderThanHours?: unknown; sessionIds?: unknown }; @@ -4889,7 +4977,9 @@ const server = createServer(async (req, res) => { // full task object — strip the prompt AND workingDir for anonymous SSE // listeners, or the REST-side scrub would be trivially bypassed by // `/events`. - let body = ev.body; + let body = authed + ? ev.body + : redactSessionEventForPublic(ev.type, ev.body) as typeof ev.body; if (!authed && (ev.type === 'schedule.created' || ev.type === 'schedule.updated')) { const b = body as { schedule?: Record; patch?: Record; id?: string }; body = { diff --git a/src/dashboard/aggregator.ts b/src/dashboard/aggregator.ts index f45009b97..c65ad8c92 100644 --- a/src/dashboard/aggregator.ts +++ b/src/dashboard/aggregator.ts @@ -4,6 +4,19 @@ import type { DashboardEvent } from '../core/dashboard-events.js'; type Row = { sessionId: string; larkAppId: string; [k: string]: unknown }; type Sched = { id: string; [k: string]: unknown }; +const SESSION_PRESENTATION_FIELDS = ['botAvatarUrl', 'repoName', 'gitBranch'] as const; + +function mergeSpawnedRow(current: Row | undefined, incoming: Row, larkAppId: string): Row { + const next: Row = { ...incoming, larkAppId }; + if (current && current.workingDir === next.workingDir) { + for (const field of SESSION_PRESENTATION_FIELDS) { + if (next[field] === undefined && current[field] !== undefined) { + next[field] = current[field]; + } + } + } + return next; +} /** * Aggregates session and schedule state across all online daemons. @@ -17,15 +30,29 @@ export class Aggregator { private listeners = new Set<(e: DashboardEvent & { larkAppId: string }) => void>(); applyEvent(larkAppId: string, ev: DashboardEvent): void { + let emitted = ev; switch (ev.type) { case 'session.spawned': { const r = ev.body.session as Row; - this.sessions.set(r.sessionId, { ...r, larkAppId }); + const next = mergeSpawnedRow(this.sessions.get(r.sessionId), r, larkAppId); + this.sessions.set(r.sessionId, next); + emitted = { ...ev, body: { session: next } }; break; } case 'session.update': { const cur = this.sessions.get(ev.body.sessionId); - if (cur) this.sessions.set(ev.body.sessionId, { ...cur, ...ev.body.patch }); + if (cur) { + const patch = { ...ev.body.patch }; + if ( + Object.prototype.hasOwnProperty.call(patch, 'workingDir') + && patch.workingDir !== cur.workingDir + ) { + patch.repoName = null; + patch.gitBranch = null; + } + this.sessions.set(ev.body.sessionId, { ...cur, ...patch }); + emitted = { ...ev, body: { ...ev.body, patch } }; + } break; } case 'session.exited': { @@ -47,19 +74,22 @@ export class Aggregator { // schedule.fired and heartbeat are pass-through (no cache mutation) } for (const fn of this.listeners) { - try { fn({ ...ev, larkAppId } as any); } catch { /* swallow */ } + try { fn({ ...emitted, larkAppId } as any); } catch { /* swallow */ } } } /** Bulk-load on dashboard start before SSE catches up. Idempotent. */ hydrateSessions(larkAppId: string, rows: Row[]): void { - for (const r of rows) this.sessions.set(r.sessionId, { ...r, larkAppId }); + for (const r of rows) { + this.sessions.set(r.sessionId, mergeSpawnedRow(this.sessions.get(r.sessionId), r, larkAppId)); + } } hydrateSchedules(rows: Sched[]): void { for (const r of rows) this.schedules.set(r.id, r); } getSessions(): Row[] { return [...this.sessions.values()]; } + getSession(sessionId: string): Row | undefined { return this.sessions.get(sessionId); } getSchedules(): Sched[] { return [...this.schedules.values()]; } /** sessionId → owning daemon's larkAppId (used for write routing). */ @@ -137,6 +167,9 @@ export function subscribeDaemon( const data = line.slice(5).trim(); try { const body = JSON.parse(data); + if (evt === 'session.spawned' && d.botAvatarUrl && body?.session) { + body.session = { ...body.session, botAvatarUrl: d.botAvatarUrl }; + } agg.applyEvent(d.larkAppId, { type: evt, body } as any); } catch { // Skip malformed frame diff --git a/src/dashboard/compat.ts b/src/dashboard/compat.ts index 0b9ab32a4..74b4a6165 100644 --- a/src/dashboard/compat.ts +++ b/src/dashboard/compat.ts @@ -4,38 +4,120 @@ import { botmuxInstallRoot, botmuxVersion } from '../utils/install-info.js'; import { resolveEffectiveBotmuxVersion } from '../utils/version-info.js'; import { jsonRes } from './http.js'; +export interface DashboardCompatModule { + supported: boolean; + route?: string; +} + +const DASHBOARD_MODULE_SPECS = { + overview: { + supported: true, + route: '#/', + capabilities: ['overview.read'], + }, + sessions: { + supported: true, + route: '#/sessions', + capabilities: ['sessions.read', 'sessions.manage', 'sessions.terminal', 'asks.read', 'asks.answer'], + }, + groups: { supported: true, route: '#/groups', capabilities: ['groups.read', 'groups.manage'] }, + roles: { supported: true, route: '#/roles', capabilities: ['roles.read', 'roles.manage'] }, + monitoring: { supported: true, route: '#/monitoring', capabilities: ['monitoring.read'] }, + insights: { supported: true, route: '#/insights', capabilities: ['insights.read'] }, + schedules: { supported: true, route: '#/schedules', capabilities: ['schedules.read', 'schedules.manage'] }, + whiteboards: { supported: true, route: '#/whiteboards', capabilities: ['whiteboards.read', 'whiteboards.manage'] }, + office: { supported: true, route: '#/office', capabilities: ['office.read'] }, + bots: { supported: true, route: '#/bot-defaults', capabilities: ['bots.read', 'bots.configure'] }, + skills: { supported: true, route: '#/skills', capabilities: ['skills.read', 'skills.manage'] }, + plugins: { supported: true, route: '#/plugins', capabilities: ['plugins.read', 'plugins.manage'] }, + team: { supported: true, route: '#/team', capabilities: ['team.read', 'team.manage'] }, + connectors: { supported: true, route: '#/connectors', capabilities: ['connectors.read', 'connectors.manage'] }, + settings: { + supported: true, + route: '#/settings', + capabilities: ['settings.read', 'settings.manage', 'updates.read', 'updates.manage'], + }, + workflow: { + supported: false, + capabilities: ['workflow.read', 'workflow.manage'], + }, +} as const satisfies Record; + +type DashboardModuleSpec = typeof DASHBOARD_MODULE_SPECS[keyof typeof DASHBOARD_MODULE_SPECS]; +export type DashboardCompatCapability = DashboardModuleSpec['capabilities'][number]; + export interface DesktopCompatManifest { schemaVersion: 1; product: 'botmux'; runtimeVersion: string; - dashboardProtocolVersion: 1; + dashboardProtocolVersion: 2; desktopShell: { supported: true; minAppVersion?: string; }; + runtimeIdentity?: { + source: 'platform-binding'; + machineId: string; + }; features: string[]; routes: string[]; + modules: Record; + capabilities: Record; } export interface BuildCompatManifestOptions { runtimeVersion?: string; + /** + * Override the machine identity used by tests or an embedding host. + * `null` explicitly means that no reliable machine identity is available. + */ + machineId?: string | null; } -const DASHBOARD_CORE_ROUTES = [ - '#/', - '#/sessions', - '#/workflows', - '#/groups', - '#/schedules', - '#/settings', -] as const; - const DASHBOARD_COMPAT_FEATURES = [ 'desktop-shell', 'dashboard-protocol-v1', + 'dashboard-protocol-v2', + 'dashboard-modules', + 'dashboard-capabilities', ] as const; +function buildDashboardModules(): Record { + return Object.fromEntries( + Object.entries(DASHBOARD_MODULE_SPECS).map(([id, spec]) => [ + id, + { + supported: spec.supported, + ...('route' in spec ? { route: spec.route } : {}), + }, + ]), + ); +} + +function buildDashboardCapabilities(): Record { + const capabilities: Partial> = {}; + for (const spec of Object.values(DASHBOARD_MODULE_SPECS)) { + for (const capability of spec.capabilities) { + capabilities[capability] = spec.supported; + } + } + return capabilities as Record; +} + +function buildDashboardRoutes(): string[] { + return Object.values(DASHBOARD_MODULE_SPECS) + .filter((spec): spec is DashboardModuleSpec & { route: string } => + spec.supported && 'route' in spec) + .map(spec => spec.route); +} + export function buildCompatManifest(options: BuildCompatManifestOptions = {}): DesktopCompatManifest { + const machineId = normalizeMachineId(options.machineId); + return { schemaVersion: 1, product: 'botmux', @@ -43,17 +125,39 @@ export function buildCompatManifest(options: BuildCompatManifestOptions = {}): D rawVersion: botmuxVersion(), rootDir: botmuxInstallRoot(), }), - dashboardProtocolVersion: 1, - // Keep this v1 manifest static: the desktop shell uses it as a cheap - // compatibility probe before loading the full dashboard UI. + dashboardProtocolVersion: 2, desktopShell: { supported: true }, + ...(machineId + ? { runtimeIdentity: { source: 'platform-binding' as const, machineId } } + : {}), features: [...DASHBOARD_COMPAT_FEATURES], - routes: [...DASHBOARD_CORE_ROUTES], + routes: buildDashboardRoutes(), + modules: buildDashboardModules(), + capabilities: buildDashboardCapabilities(), }; } -export function handleDesktopCompat(req: IncomingMessage, res: ServerResponse, url: URL): boolean { +function normalizeMachineId(value: string | null | undefined): string | undefined { + const machineId = value?.trim(); + return machineId ? machineId : undefined; +} + +export function compatMachineIdForAuthenticatedRequest( + presentedToken: string | undefined, + activeToken: string | null | undefined, + boundMachineId: string | null | undefined, +): string | null { + if (!activeToken || presentedToken !== activeToken) return null; + return normalizeMachineId(boundMachineId) ?? null; +} + +export function handleDesktopCompat( + req: IncomingMessage, + res: ServerResponse, + url: URL, + options: BuildCompatManifestOptions = {}, +): boolean { if (req.method !== 'GET' || url.pathname !== '/__desktop/compat') return false; - jsonRes(res, 200, buildCompatManifest()); + jsonRes(res, 200, buildCompatManifest(options)); return true; } diff --git a/src/dashboard/desktop-asks.ts b/src/dashboard/desktop-asks.ts new file mode 100644 index 000000000..68c66f54c --- /dev/null +++ b/src/dashboard/desktop-asks.ts @@ -0,0 +1,74 @@ +export interface DashboardAskAnswerRequest { + askId: string; + larkAppId: string; + selections: string[][]; + by?: string; +} + +export type DashboardAskAnswerParseResult = + | { ok: true; value: DashboardAskAnswerRequest } + | { ok: false; error: 'askId_larkAppId_and_selections_required' | 'invalid_selections' }; + +export interface DashboardAskAnswerProxyResult { + status: number; + contentType: string; + body: string; +} + +export function parseDashboardAskAnswerRequest(raw: unknown): DashboardAskAnswerParseResult { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, error: 'askId_larkAppId_and_selections_required' }; + } + const body = raw as Record; + const askId = typeof body.askId === 'string' ? body.askId.trim() : ''; + const larkAppId = typeof body.larkAppId === 'string' ? body.larkAppId.trim() : ''; + if (!askId || !larkAppId || !Array.isArray(body.selections)) { + return { ok: false, error: 'askId_larkAppId_and_selections_required' }; + } + if (!body.selections.every( + selection => Array.isArray(selection) && selection.every(key => typeof key === 'string'), + )) { + return { ok: false, error: 'invalid_selections' }; + } + return { + ok: true, + value: { + askId, + larkAppId, + selections: body.selections as string[][], + ...(typeof body.by === 'string' ? { by: body.by } : {}), + }, + }; +} + +export async function proxyDashboardAskAnswer( + request: DashboardAskAnswerRequest, + proxyToDaemon: ( + larkAppId: string, + path: string, + init: RequestInit, + ) => Promise, +): Promise { + try { + const upstream = await proxyToDaemon(request.larkAppId, '/api/asks/answer', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + askId: request.askId, + selections: request.selections, + by: request.by ?? 'desktop', + }), + }); + return { + status: upstream.status, + contentType: upstream.headers.get('content-type') ?? 'application/json', + body: await upstream.text(), + }; + } catch { + return { + status: 503, + contentType: 'application/json', + body: JSON.stringify({ ok: false, error: 'daemon_unavailable' }), + }; + } +} diff --git a/src/dashboard/public-redact.ts b/src/dashboard/public-redact.ts index 6a7eeb276..8b67774fb 100644 --- a/src/dashboard/public-redact.ts +++ b/src/dashboard/public-redact.ts @@ -11,9 +11,10 @@ // - /api/groups → memberBots[].oncallChat = { chatId, workingDir } // - /api/schedules → row carries `prompt` (business instructions) + `workingDir` // - /api/settings → notifier carries recipient / delivery diagnostics -// Both `workingDir`s are repo / customer-project paths; stripping them keeps -// the board functional (name-map, timing, status) while not leaking bound dirs -// — and keeps the "/api/bots oncall config is private" boundary honest. +// - /api/sessions + /events → session rows/patches may carry `gitBranch` +// The group/schedule `workingDir`s are repo / customer-project paths; stripping +// them keeps the board functional (name-map, timing, status) while not leaking +// bound dirs — and keeps the "/api/bots oncall config is private" boundary honest. /** Project a `/api/groups` chats array down to the public, board-only fields * for anonymous visitors. Explicit ALLOW-LIST (fail-closed): a chat field that @@ -60,6 +61,39 @@ export function redactSchedulesForPublic(schedules: unknown[]): unknown[] { }); } +/** Branch names often carry issue/customer identifiers. `/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; + return rest; +} + +export function redactSessionsForPublic(sessions: unknown[]): unknown[] { + if (!Array.isArray(sessions)) return sessions; + return sessions.map(redactSessionForPublic); +} + +/** Apply the same branch-name policy to session rows delivered over SSE. */ +export function redactSessionEventForPublic(type: string, body: unknown): unknown { + if (!body || typeof body !== 'object' || Array.isArray(body)) return body; + const eventBody = body as Record; + if (type === 'session.spawned') { + return { ...eventBody, session: redactSessionForPublic(eventBody.session) }; + } + if ( + type === 'session.update' + && eventBody.patch + && typeof eventBody.patch === 'object' + && !Array.isArray(eventBody.patch) + ) { + const { gitBranch: _gitBranch, ...patch } = eventBody.patch as Record; + return { ...eventBody, patch }; + } + return body; +} + /** 匿名只读面板不展示外部 Codex 活动、目标 Bot 或投递运行态。 */ export function redactSettingsForPublic(settings: unknown): unknown { if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return settings; diff --git a/src/dashboard/session-presentation.ts b/src/dashboard/session-presentation.ts new file mode 100644 index 000000000..66228da39 --- /dev/null +++ b/src/dashboard/session-presentation.ts @@ -0,0 +1,87 @@ +import type { DashboardEvent } from '../core/dashboard-events.js'; +import type { + GitRepoInfo, + GitRepoResolveOptions, +} from '../core/session-row-enrichment.js'; +import type { Aggregator } from './aggregator.js'; + +type PresentationRow = Record; +type AggregatedEvent = DashboardEvent & { larkAppId: string }; +type ScheduleOptions = { force?: boolean }; + +function presentationString(value: unknown): string | null { + return typeof value === 'string' && value ? value : null; +} + +export function createSessionPresentationCoordinator( + aggregator: Aggregator, + resolveGit: ( + workingDir: string, + options?: GitRepoResolveOptions, + ) => Promise, +): { + schedule: (larkAppId: string, row: PresentationRow, options?: ScheduleOptions) => void; + onEvent: (event: AggregatedEvent) => void; +} { + const pending = new Map(); + const schedule = ( + larkAppId: string, + row: PresentationRow, + options: ScheduleOptions = {}, + ): void => { + const sessionId = typeof row.sessionId === 'string' ? row.sessionId : ''; + const workingDir = typeof row.workingDir === 'string' ? row.workingDir.trim() : ''; + if (!sessionId || !workingDir) return; + const token = Symbol(sessionId); + pending.set(sessionId, token); + + const lookup = options.force + ? resolveGit(workingDir, { force: true }) + : resolveGit(workingDir); + void lookup.then((info) => { + if (pending.get(sessionId) !== token) return; + const current = aggregator.getSession(sessionId); + if (!current || current.larkAppId !== larkAppId || current.workingDir !== workingDir) return; + const repoName = info?.repoName ?? null; + const gitBranch = info?.branch ?? null; + if ( + presentationString(current.repoName) === repoName + && presentationString(current.gitBranch) === gitBranch + ) { + return; + } + aggregator.applyEvent(larkAppId, { + type: 'session.update', + body: { sessionId, patch: { repoName, gitBranch } }, + }); + }).catch(() => { + // Presentation enrichment is best-effort; the canonical row remains valid. + }).finally(() => { + if (pending.get(sessionId) === token) pending.delete(sessionId); + }); + }; + + return { + schedule, + onEvent(event) { + if (event.type === 'session.spawned') { + const row = event.body.session as PresentationRow; + schedule(event.larkAppId, row, { + force: row.status === 'idle' || row.status === 'limited', + }); + return; + } + if (event.type === 'session.update') { + const workingDirChanged = Object.prototype.hasOwnProperty.call( + event.body.patch, + 'workingDir', + ); + const atTurnBoundary = event.body.patch.status === 'idle' + || event.body.patch.status === 'limited'; + if (!workingDirChanged && !atTurnBoundary) return; + const current = aggregator.getSession(event.body.sessionId); + if (current) schedule(event.larkAppId, current, { force: atTurnBoundary }); + } + }, + }; +} diff --git a/src/dashboard/web/app.tsx b/src/dashboard/web/app.tsx index 933fe5570..892c1ff0f 100644 --- a/src/dashboard/web/app.tsx +++ b/src/dashboard/web/app.tsx @@ -30,6 +30,11 @@ import { initFloatingScrollbars } from './floating-scrollbars.js'; import { initIconTooltips } from './icon-tooltip.js'; import { PLUGIN_PINS_CHANGED_EVENT } from './plugin-events.js'; import { updateAndRestartBotmux, type BotmuxUpdatePhase } from './update-action.js'; +import { + canonicalDashboardClientShellUrl, + dashboardClientShellRedirect, + readDashboardClientShell, +} from './client-shell.js'; type OwnerAvatar = { avatarUrl: string; name?: string }; type TopbarAttentionNotice = { count: number; time: string; bot: string; reason: string }; @@ -194,13 +199,16 @@ function isActiveNav(item: NavItem, hash: string): boolean { } function sidebarNavItems(): NavItem[] { - if (pinnedPluginNavItems.length === 0) return NAV_ITEMS; - const pluginIndex = NAV_ITEMS.findIndex(item => item.id === 'plugins'); - if (pluginIndex < 0) return [...NAV_ITEMS, ...pinnedPluginNavItems]; + const builtInItems = readDashboardClientShell() + ? NAV_ITEMS.filter(item => item.id !== 'workflows') + : NAV_ITEMS; + if (pinnedPluginNavItems.length === 0) return builtInItems; + const pluginIndex = builtInItems.findIndex(item => item.id === 'plugins'); + if (pluginIndex < 0) return [...builtInItems, ...pinnedPluginNavItems]; return [ - ...NAV_ITEMS.slice(0, pluginIndex + 1), + ...builtInItems.slice(0, pluginIndex + 1), ...pinnedPluginNavItems, - ...NAV_ITEMS.slice(pluginIndex + 1), + ...builtInItems.slice(pluginIndex + 1), ]; } @@ -1271,6 +1279,11 @@ async function route(): Promise { activeHash = hash; renderShell(); + const clientShellRedirect = dashboardClientShellRedirect(hash); + if (clientShellRedirect) { + window.location.replace(clientShellRedirect); + return; + } if (!isAuthed && MANAGE_ROUTES.some(r => hash.startsWith('#/' + r))) { renderAuthRequiredPage(getRouteRoot()); routeState.rerenderOnUiChange = true; @@ -1343,6 +1356,10 @@ function initOwnerAvatar(): void { } void (async () => { + const canonicalClientShellUrl = canonicalDashboardClientShellUrl(window.location.href); + if (canonicalClientShellUrl) { + window.history.replaceState(window.history.state, '', canonicalClientShellUrl); + } ui.init(); applyShellLocaleFromHash(); const host = document.getElementById('app-root'); diff --git a/src/dashboard/web/client-shell.ts b/src/dashboard/web/client-shell.ts new file mode 100644 index 000000000..638a36970 --- /dev/null +++ b/src/dashboard/web/client-shell.ts @@ -0,0 +1,106 @@ +export type DashboardClientShell = 'desktop' | 'mobile'; + +const CLIENT_SHELL_PARAM = 'botmuxClientShell'; +const CLIENT_SHELLS = new Set(['desktop', 'mobile']); + +function normalizeClientShell(value: string | null): DashboardClientShell | null { + return value && CLIENT_SHELLS.has(value as DashboardClientShell) + ? value as DashboardClientShell + : null; +} + +/** + * Upgrade a legacy hash-scoped shell marker into the durable URL query. + * Returns the replacement URL, or null when no rewrite is needed/possible. + */ +export function canonicalDashboardClientShellUrl(href: string): string | null { + try { + const url = new URL(href); + if (normalizeClientShell(url.searchParams.get(CLIENT_SHELL_PARAM))) return null; + + const queryIndex = url.hash.indexOf('?'); + if (queryIndex < 0) return null; + const hashParams = new URLSearchParams(url.hash.slice(queryIndex + 1)); + const shell = normalizeClientShell(hashParams.get(CLIENT_SHELL_PARAM)); + if (!shell) return null; + + const hashPath = url.hash.slice(0, queryIndex) || '#/'; + hashParams.delete(CLIENT_SHELL_PARAM); + const remainingHashQuery = hashParams.toString(); + url.searchParams.set(CLIENT_SHELL_PARAM, shell); + url.hash = remainingHashQuery ? `${hashPath}?${remainingHashQuery}` : hashPath; + return url.toString(); + } catch { + return null; + } +} + +/** + * Detect the restricted Desktop/Mobile dashboard shell. + * + * The query-string form is canonical because hash navigation must not clear + * the shell boundary. Reading the hash form as a compatibility fallback lets + * old one-time open links reach the shell long enough to be redirected. + */ +export function readDashboardClientShell( + search = typeof location === 'undefined' ? '' : location.search, + hash = typeof location === 'undefined' ? '' : location.hash, +): DashboardClientShell | null { + const fromSearch = normalizeClientShell( + new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) + .get(CLIENT_SHELL_PARAM), + ); + if (fromSearch) return fromSearch; + + const queryIndex = hash.indexOf('?'); + if (queryIndex < 0) return null; + return normalizeClientShell( + new URLSearchParams(hash.slice(queryIndex + 1)).get(CLIENT_SHELL_PARAM), + ); +} + +/** Workflow is deliberately outside the Botmux Desktop/Mobile integration. */ +export function isWorkflowDashboardHash(hash: string): boolean { + const path = (hash.split('?')[0] || '#/').toLowerCase(); + return ( + path === '#/workflows' || + path.startsWith('#/workflows/') || + path.startsWith('#/workflows-') || + path === '#/v3' || + path.startsWith('#/v3/') || + path.startsWith('#/v3?') || + path === '#/legacy-workflow' || + path.startsWith('#/legacy-workflow/') + ); +} + +/** Monitor Room is a web-terminal surface and is owned by the native client. */ +export function isWebTerminalDashboardHash(hash: string): boolean { + const path = (hash.split('?')[0] || '#/').toLowerCase(); + return path === '#/monitor-room' || path.startsWith('#/monitor-room/'); +} + +/** + * Embedded Desktop/Mobile dashboards must never offer web terminal actions. + * + * The native Botmux Sessions surface owns terminal attachment in those clients; + * keeping this decision beside the shell parser prevents a future UI control + * from accidentally minting a write link that the Electron boundary rejects. + */ +export function dashboardShellAllowsWebTerminal( + search = typeof location === 'undefined' ? '' : location.search, + hash = typeof location === 'undefined' ? '' : location.hash, +): boolean { + return readDashboardClientShell(search, hash) === null; +} + +/** Resolve unsupported embedded routes before the lazy page module is loaded. */ +export function dashboardClientShellRedirect( + hash: string, + search = typeof location === 'undefined' ? '' : location.search, +): '#/' | '#/sessions' | null { + if (!readDashboardClientShell(search, hash)) return null; + if (isWorkflowDashboardHash(hash)) return '#/'; + if (isWebTerminalDashboardHash(hash)) return '#/sessions'; + return null; +} diff --git a/src/dashboard/web/sessions-kanban.tsx b/src/dashboard/web/sessions-kanban.tsx index 4e9b825ac..e417a6992 100644 --- a/src/dashboard/web/sessions-kanban.tsx +++ b/src/dashboard/web/sessions-kanban.tsx @@ -78,7 +78,7 @@ export interface SessionsKanbanCallbacks { onMoveRows: (moves: SessionsKanbanMove[]) => void; onNeedTeamBoard: (team: SessionsKanbanTeam) => void; onNeedTeams: () => void; - onOpenTerminal: (row: any) => void; + onOpenTerminal?: (row: any) => void; onRename: (row: any, title: string) => void; onRestart: (row: any, button: HTMLButtonElement) => void; onTeamScope: (scope: { chats: number; sessions: number } | null) => void; @@ -427,12 +427,12 @@ function KanbanCard(props: { label={t('sessions.history.title')} onClick={() => callbacks.onHistory(row)} /> - {row.webPort ? ( + {row.webPort && callbacks.onOpenTerminal ? ( callbacks.onOpenTerminal(row)} + onClick={() => callbacks.onOpenTerminal?.(row)} /> ) : null} {row.feishuChatLink ? ( diff --git a/src/dashboard/web/sessions-page.tsx b/src/dashboard/web/sessions-page.tsx index 9d14b17dd..cc60ae569 100644 --- a/src/dashboard/web/sessions-page.tsx +++ b/src/dashboard/web/sessions-page.tsx @@ -75,6 +75,7 @@ import { type SessionTopicGroup, } from './sessions.js'; import { addMonitorRoomSessionIds, monitorRoomUrl } from './monitor-room-store.js'; +import { dashboardShellAllowsWebTerminal } from './client-shell.js'; import { CreateActionButton, DropdownMenu, LoadingState } from './dashboard-components.js'; import { filterMentionBots, @@ -242,7 +243,7 @@ function IconActionButton(props: { } function TerminalControls(props: { row: any; url: string | null }): JSX.Element | null { - if (!props.url) return null; + if (!props.url || !dashboardShellAllowsWebTerminal()) return null; const readOnly = !shouldOpenWritableTerminal(); return ( @@ -662,12 +663,15 @@ function BulkBar(props: { const busy = !!props.closeProgress || !!props.lockProgress; const lockText = props.lockProgress?.locked ? `${props.lockProgress.done}/${props.lockProgress.total}` : t('sessions.lockSelected'); const unlockText = props.lockProgress && !props.lockProgress.locked ? `${props.lockProgress.done}/${props.lockProgress.total}` : t('sessions.unlockSelected'); + const webTerminalAvailable = dashboardShellAllowsWebTerminal(); return (
- + {dashboardShellAllowsWebTerminal() ? ( + + ) : null} {ui.authed ? (