mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix: gate remaining session actions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user