fix: gate remaining session actions

This commit is contained in:
Shakker
2026-08-02 10:32:53 +01:00
parent a45feb3915
commit af2f9c4cdc
4 changed files with 164 additions and 12 deletions
+18 -5
View File
@@ -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,
+49 -7
View File
@@ -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,
@@ -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<ApplicationGatewaySnapshot, "client" | "hello" | "phase"> {
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" });
});
});
@@ -0,0 +1,34 @@
import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts";
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
export function readChatSessionActionAccess(
snapshot: Pick<ApplicationGatewaySnapshot, "client" | "hello" | "phase"> | 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",
}),
};
}