From af2f9c4cdc578a0c844a99dfa8e686eea7d7b075 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 2 Aug 2026 10:32:53 +0100 Subject: [PATCH] fix: gate remaining session actions --- ui/src/pages/chat/chat-pane-header.ts | 23 +++++-- ui/src/pages/chat/chat-pane-render.ts | 56 ++++++++++++++--- .../chat/chat-session-action-access.test.ts | 63 +++++++++++++++++++ .../pages/chat/chat-session-action-access.ts | 34 ++++++++++ 4 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 ui/src/pages/chat/chat-session-action-access.test.ts create mode 100644 ui/src/pages/chat/chat-session-action-access.ts diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index c25aa69a76ac..16bea3180725 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -22,6 +22,7 @@ import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { renderBoardDockMenu, renderBoardFaceToggle } from "./board-session-surface.ts"; import { ChatPaneContext } from "./chat-pane-context.ts"; import { headerPlatformByClient } from "./chat-pane-shared.ts"; +import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; import { patchChatSessionLabel } from "./chat-state-route.ts"; import { renderCatalogTerminalButton } from "./components/catalog-terminal-button.ts"; import { @@ -94,10 +95,12 @@ export abstract class ChatPaneHeader extends ChatPaneContext { sessionKey: this.state.sessionKey, }) : false; - const branchSwitchDisabledReason = !hasOperatorAdminAccess( - this.context.gateway.snapshot.hello?.auth ?? null, - ) - ? t("chat.sessionHeader.branchSwitchRequiresAdmin") + const branchSwitchAccess = readChatSessionActionAccess( + this.context.gateway.snapshot, + Boolean(this.state?.chatRunId), + ).branchSwitch; + const branchSwitchDisabledReason = !branchSwitchAccess.allowed + ? branchSwitchAccess.reason : branchSwitchWorking ? t("chat.sessionHeader.branchSwitchUnavailable") : null; @@ -236,7 +239,17 @@ export abstract class ChatPaneHeader extends ChatPaneContext { this.handleHeaderMenuAction(action, row, workspace.root, branch); } }, - onBranchSelect: (leafEntryId) => void this.switchToBranch(leafEntryId), + onBranchSelect: (leafEntryId) => { + const access = readChatSessionActionAccess( + this.context.gateway.snapshot, + Boolean(this.state?.chatRunId), + ).branchSwitch; + if (!access.allowed) { + this.publishHeaderError(access.reason); + return; + } + void this.switchToBranch(leafEntryId); + }, onOpenSplitView: this.onOpenSplitView, onSplitDown: this.onSplitDown, onSplitRight: this.onSplitRight, diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index bb62904e7731..4e6c88cb2092 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -40,6 +40,7 @@ import { } from "./chat-pane-state.ts"; import { dismissRealtimeTalkError } from "./chat-realtime.ts"; import { activeChatRunStartupStatus } from "./chat-run-startup.ts"; +import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; import { switchChatFastMode, switchChatModel, switchChatThinkingLevel } from "./chat-session.ts"; import { refreshChatCommands, refreshPageChat } from "./chat-state-refresh.ts"; import { @@ -88,6 +89,21 @@ export class ChatPane extends ChatPaneHeader { method: "sessions.patch", params: { key: state.sessionKey, archived: false }, }); + const sessionActionAccess = readChatSessionActionAccess( + this.context.gateway.snapshot, + Boolean(state.chatRunId), + ); + const requireSessionAction = (action: keyof typeof sessionActionAccess): boolean => { + const access = readChatSessionActionAccess( + this.context.gateway.snapshot, + Boolean(state.chatRunId), + )[action]; + if (access.allowed) { + return true; + } + this.publishHeaderError(access.reason); + return false; + }; const projectedObserverDigest = projectSessionObserverDigest( selectedSession?.key ?? state.sessionKey, selectedSession?.observerDigest, @@ -513,7 +529,13 @@ export class ChatPane extends ChatPaneHeader { : suggestionViewer ? void this.addCurrentSessionSuggestion() : void state.handleSendChat(), - onCompact: () => void state.handleSendChat("/compact"), + onCompact: sessionActionAccess.compact.allowed + ? () => { + if (requireSessionAction("compact")) { + void state.handleSendChat("/compact"); + } + } + : undefined, onOpenSessionCheckpoints: () => { const search = new URLSearchParams({ session: state.sessionKey }); if (selectedSessionArchived) { @@ -537,9 +559,14 @@ export class ChatPane extends ChatPaneHeader { state.chatError = message; state.requestUpdate?.(); }, - onAbort: sessionParticipationBlocked - ? undefined - : () => void state.handleAbortChat({ preserveDraft: true }), + onAbort: + sessionParticipationBlocked || !sessionActionAccess.abort.allowed + ? undefined + : () => { + if (requireSessionAction("abort")) { + void state.handleAbortChat({ preserveDraft: true }); + } + }, onQueueRemove: state.removeQueuedMessage, onQueueRetry: (id) => void state.retryQueuedChatMessage(id), onQueueSteer: sessionParticipationBlocked @@ -557,10 +584,25 @@ export class ChatPane extends ChatPaneHeader { state.chatReplyTarget = target; state.requestUpdate?.(); }, - onRewindMessage: (entryId) => this.rewindToMessage(entryId), - onForkMessage: (entryId) => this.forkFromMessage(entryId), + onRewindMessage: sessionActionAccess.rewind.allowed + ? (entryId) => (requireSessionAction("rewind") ? this.rewindToMessage(entryId) : false) + : undefined, + onForkMessage: sessionActionAccess.fork.allowed + ? (entryId) => { + if (requireSessionAction("fork")) { + return this.forkFromMessage(entryId); + } + return undefined; + } + : undefined, onNewSession: () => void this.createSession(), - onClearHistory: () => void clearChatHistory(state), + onClearHistory: sessionActionAccess.reset.allowed + ? () => { + if (requireSessionAction("reset")) { + void clearChatHistory(state); + } + } + : undefined, agentsList: state.agentsList, currentAgentId, ...chatProps, diff --git a/ui/src/pages/chat/chat-session-action-access.test.ts b/ui/src/pages/chat/chat-session-action-access.test.ts new file mode 100644 index 000000000000..810c03d94673 --- /dev/null +++ b/ui/src/pages/chat/chat-session-action-access.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; +import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; + +function snapshot(params: { + methods: string[]; + scopes: string[]; +}): Pick { + return { + client: {} as ApplicationGatewaySnapshot["client"], + phase: "connected", + hello: { + auth: { role: "operator", scopes: params.scopes }, + features: { methods: params.methods }, + } as ApplicationGatewaySnapshot["hello"], + }; +} + +describe("readChatSessionActionAccess", () => { + const methods = [ + "sessions.compact", + "chat.abort", + "sessions.abort", + "sessions.rewind", + "sessions.fork", + "sessions.reset", + "sessions.branches.switch", + ]; + + it("maps write and admin actions to their exact scopes", () => { + const write = readChatSessionActionAccess( + snapshot({ methods, scopes: ["operator.write"] }), + true, + ); + expect(write.abort.allowed).toBe(true); + expect(write.fork.allowed).toBe(true); + expect(write.compact.allowed).toBe(false); + expect(write.rewind.allowed).toBe(false); + expect(write.reset.allowed).toBe(false); + expect(write.branchSwitch.allowed).toBe(false); + + const admin = readChatSessionActionAccess( + snapshot({ methods, scopes: ["operator.admin"] }), + true, + ); + expect(Object.values(admin).every((access) => access.allowed)).toBe(true); + }); + + it("selects the active-run abort method and rejects explicit method absence", () => { + expect( + readChatSessionActionAccess( + snapshot({ methods: ["chat.abort"], scopes: ["operator.write"] }), + true, + ).abort.allowed, + ).toBe(true); + expect( + readChatSessionActionAccess( + snapshot({ methods: ["chat.abort"], scopes: ["operator.write"] }), + false, + ).abort, + ).toMatchObject({ allowed: false, cause: "method-unavailable" }); + }); +}); diff --git a/ui/src/pages/chat/chat-session-action-access.ts b/ui/src/pages/chat/chat-session-action-access.ts new file mode 100644 index 000000000000..868847a781d5 --- /dev/null +++ b/ui/src/pages/chat/chat-session-action-access.ts @@ -0,0 +1,34 @@ +import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; +import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; + +export function readChatSessionActionAccess( + snapshot: Pick | null | undefined, + hasLocalRun: boolean, +) { + return { + compact: readSessionMethodAccess(snapshot, { + method: "sessions.compact", + requiredScope: "operator.admin", + }), + abort: readSessionMethodAccess(snapshot, { + method: hasLocalRun ? "chat.abort" : "sessions.abort", + requiredScope: "operator.write", + }), + rewind: readSessionMethodAccess(snapshot, { + method: "sessions.rewind", + requiredScope: "operator.admin", + }), + fork: readSessionMethodAccess(snapshot, { + method: "sessions.fork", + requiredScope: "operator.write", + }), + reset: readSessionMethodAccess(snapshot, { + method: "sessions.reset", + requiredScope: "operator.admin", + }), + branchSwitch: readSessionMethodAccess(snapshot, { + method: "sessions.branches.switch", + requiredScope: "operator.admin", + }), + }; +}