From fbdf3728270aaa9d6471c59524ecdd3d3d1ca107 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 2 Aug 2026 14:56:01 +0100 Subject: [PATCH] fix: gate chat session commands --- .../pages/chat/chat-command-executor.test.ts | 58 ++++++++++++++++++- ui/src/pages/chat/chat-command-executor.ts | 41 ++++++++++--- ui/src/pages/chat/chat-commands.test.ts | 45 ++++++++++++++ ui/src/pages/chat/chat-commands.ts | 39 +++++++++++++ 4 files changed, 173 insertions(+), 10 deletions(-) diff --git a/ui/src/pages/chat/chat-command-executor.test.ts b/ui/src/pages/chat/chat-command-executor.test.ts index 0a3dfcb5d18a..8472aa741b30 100644 --- a/ui/src/pages/chat/chat-command-executor.test.ts +++ b/ui/src/pages/chat/chat-command-executor.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; import { t } from "../../i18n/index.ts"; import type { SessionCapability, SessionPatch } from "../../lib/sessions/index.ts"; import { @@ -45,14 +46,42 @@ function executeSlashCommand( sessionKey: string, commandName: string, args: string, - context: Omit[4], "sessions"> = {}, + context: Omit< + Parameters[4], + "sessionAccessSnapshot" | "sessions" + > & { + sessionAccessSnapshot?: Parameters[4]["sessionAccessSnapshot"]; + } = {}, ) { + const { + sessionAccessSnapshot = { + client, + hello: null, + phase: "connected", + }, + ...rest + } = context; return executeSlashCommandImpl(client, sessionKey, commandName, args, { sessions: createSessionCapability(client), - ...context, + ...rest, + sessionAccessSnapshot, }); } +function restrictedSnapshot( + client: GatewayBrowserClient, + methods: string[], +): Pick { + return { + client, + phase: "connected", + hello: { + auth: { role: "operator", scopes: ["operator.read"] }, + features: { methods }, + } as ApplicationGatewaySnapshot["hello"], + }; +} + function row(key: string, overrides?: Partial): GatewaySessionRow { return { key, @@ -79,6 +108,31 @@ function expectNoRequestCall(request: ReturnType, method: string) } describe("executeSlashCommand directives", () => { + it("does not compact a session without operator.admin", async () => { + const request = vi.fn(); + const client = { request } as unknown as GatewayBrowserClient; + + const result = await executeSlashCommand(client, "main", "compact", "", { + sessionAccessSnapshot: restrictedSnapshot(client, ["sessions.compact"]), + }); + + expect(result.failed).toBe(true); + expectNoRequestCall(request, "sessions.compact"); + }); + + it("does not patch session settings without operator.admin", async () => { + const request = vi.fn(); + const client = { request } as unknown as GatewayBrowserClient; + + const result = await executeSlashCommand(client, "main", "model", "gpt-5-mini", { + sessionAccessSnapshot: restrictedSnapshot(client, ["sessions.patch"]), + chatModelCatalog: [{ id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }], + }); + + expect(result.failed).toBe(true); + expectNoRequestCall(request, "sessions.patch"); + }); + it("resolves the legacy main alias for bare /model", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.list") { diff --git a/ui/src/pages/chat/chat-command-executor.ts b/ui/src/pages/chat/chat-command-executor.ts index ed17c6cffbc5..9836bef5ba2b 100644 --- a/ui/src/pages/chat/chat-command-executor.ts +++ b/ui/src/pages/chat/chat-command-executor.ts @@ -10,6 +10,7 @@ import type { ModelCatalogEntry, SessionsListResult, } from "../../api/types.ts"; +import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; import { t } from "../../i18n/index.ts"; import { getSlashCommandCategoryLabel, @@ -32,6 +33,7 @@ import { resolveThinkingLevelInput, } from "../../lib/chat/thinking.ts"; import { formatCompactTokenCount } from "../../lib/format.ts"; +import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; import { isSessionRunActive } from "../../lib/session-run-state.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import { @@ -44,10 +46,7 @@ import { normalizeOptionalLowercaseString, } from "../../lib/string-coerce.ts"; import { generateUUID } from "../../lib/uuid.ts"; -import { - patchChatCommandSessionSettings as patchSession, - selectedGlobalScope, -} from "./chat-settings-patches.ts"; +import { patchChatCommandSessionSettings, selectedGlobalScope } from "./chat-settings-patches.ts"; type SlashCommandResult = { /** Markdown-formatted result to display in chat. */ @@ -68,6 +67,7 @@ type SlashCommandResult = { type SlashCommandContext = { sessions: SessionCapability; + sessionAccessSnapshot: Pick; chatModelCatalog?: ModelCatalogEntry[]; modelCatalog?: ModelCatalogEntry[]; sessionsResult?: SessionsListResult | null; @@ -76,6 +76,26 @@ type SlashCommandContext = { agentId?: string; }; +async function patchSession( + context: SlashCommandContext, + sessionKey: string, + patch: Parameters[2], +) { + const params = { + key: sessionKey, + ...selectedGlobalScope(sessionKey, context), + ...patch, + }; + const access = readSessionMethodAccess(context.sessionAccessSnapshot, { + method: "sessions.patch", + params, + }); + if (!access.allowed) { + throw new Error(access.reason); + } + return await patchChatCommandSessionSettings(context, sessionKey, patch); +} + function normalizeVerboseLevel(raw?: string | null): "off" | "on" | "full" | undefined { if (!raw) { return undefined; @@ -172,10 +192,15 @@ async function executeCompact( context: SlashCommandContext, ): Promise { try { - const result = await context.sessions.compact( - sessionKey, - selectedGlobalScope(sessionKey, context), - ); + const options = selectedGlobalScope(sessionKey, context); + const access = readSessionMethodAccess(context.sessionAccessSnapshot, { + method: "sessions.compact", + requiredScope: "operator.admin", + }); + if (!access.allowed) { + throw new Error(access.reason); + } + const result = await context.sessions.compact(sessionKey, options); if (result?.ok !== true) { const reason = typeof result?.reason === "string" ? result.reason.trim() : ""; return { diff --git a/ui/src/pages/chat/chat-commands.test.ts b/ui/src/pages/chat/chat-commands.test.ts index 7fc98d740c62..c05170384ae3 100644 --- a/ui/src/pages/chat/chat-commands.test.ts +++ b/ui/src/pages/chat/chat-commands.test.ts @@ -1,5 +1,7 @@ // @vitest-environment node import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; import { SLASH_COMMANDS, getSlashCommandCategoryLabel, @@ -26,6 +28,14 @@ function expectRecordFields(value: unknown, label: string, expected: Record { it("resolves localized UI command metadata", () => { const clear = SLASH_COMMANDS.find((entry) => entry.name === "clear"); @@ -245,6 +255,39 @@ describe("refreshSlashCommands", () => { }); describe("conversation reset confirmation", () => { + it.each([ + ["stop", "chat.abort"], + ["clear", "sessions.reset"], + ["compact", "sessions.compact"], + ] as const)("rejects /%s without its exact operator scope", async (command, method) => { + const request = vi.fn(); + const reset = vi.fn(); + const client = { request } as unknown as GatewayBrowserClient; + const host = { + client, + connected: true, + hello: { + auth: { role: "operator", scopes: ["operator.read"] }, + features: { methods: [method] }, + } as ApplicationGatewaySnapshot["hello"], + sessionKey: "agent:main:current", + chatRunId: command === "stop" ? "run-1" : null, + sessions: { reset }, + confirmConversationReset: vi.fn(async () => true), + lastError: null, + chatError: null, + }; + + const result = await dispatchChatSlashCommand(host as never, command, "", { + sendResetMessage: vi.fn(), + }); + + expect(result).toBe("failed"); + expect(request).not.toHaveBeenCalled(); + expect(reset).not.toHaveBeenCalled(); + expect(host.lastError).toBeTruthy(); + }); + it("propagates cancelled /new session creation", async () => { const result = await dispatchChatSlashCommand( { createChatSession: vi.fn(async () => false) } as never, @@ -323,6 +366,7 @@ describe("conversation reset confirmation", () => { const sendResetMessage = vi.fn(async () => {}); const reset = vi.fn(); const host = { + ...legacyConnectedSessionAccess(), chatRunId: null as string | null, sessionKey: "agent:main:current", confirmConversationReset: vi.fn(async () => await confirmation), @@ -357,6 +401,7 @@ describe("conversation reset confirmation", () => { const reset = vi.fn(); const result = await dispatchChatSlashCommand( { + ...legacyConnectedSessionAccess(), sessionKey: "agent:main:current", confirmConversationReset: vi.fn(async () => false), sessions: { reset }, diff --git a/ui/src/pages/chat/chat-commands.ts b/ui/src/pages/chat/chat-commands.ts index ed5631421e34..3491c1a60946 100644 --- a/ui/src/pages/chat/chat-commands.ts +++ b/ui/src/pages/chat/chat-commands.ts @@ -2,6 +2,7 @@ import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ModelCatalogEntry, SessionsListResult } from "../../api/types.ts"; +import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { buildFallbackSlashCommands, @@ -24,6 +25,7 @@ import { import { executeSlashCommand } from "./chat-command-executor.ts"; import { clearChatHistory } from "./chat-history.ts"; import { enqueuePendingRunMessage } from "./chat-queue.ts"; +import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; import { handleAbortChat } from "./run-lifecycle.ts"; import { scheduleChatScroll } from "./scroll.ts"; @@ -74,6 +76,31 @@ function setChatCommandError( host.chatError = error; } +function currentSessionAccessSnapshot( + host: ChatCommandHost, +): Pick { + return { + client: host.client ?? null, + hello: host.hello ?? null, + phase: host.connected ? "connected" : "offline", + }; +} + +function requireChatSessionAction( + host: ChatCommandHost, + action: "abort" | "compact" | "reset", +): boolean { + const access = readChatSessionActionAccess( + currentSessionAccessSnapshot(host), + Boolean(host.chatRunId), + )[action]; + if (access.allowed) { + return true; + } + setChatCommandError(host, access.reason); + return false; +} + function remoteSlashCommandCacheKey(agentId: string | undefined): string { return agentId ?? ""; } @@ -227,6 +254,9 @@ export async function dispatchChatSlashCommand( ): Promise { switch (name) { case "stop": + if (!requireChatSessionAction(host, "abort")) { + return "failed"; + } await handleAbortChat(host); return "completed"; case "new": @@ -244,12 +274,20 @@ export async function dispatchChatSlashCommand( return "completed"; } case "clear": { + if (!requireChatSessionAction(host, "reset")) { + return "failed"; + } const confirmation = await confirmConversationResetForCurrentSession(host); if (confirmation !== "confirmed") { return confirmation; } return await clearChatHistory(host); } + case "compact": + if (!requireChatSessionAction(host, "compact")) { + return "failed"; + } + break; case "export-session": await host.exportCurrentChat?.(); return "completed"; @@ -280,6 +318,7 @@ export async function dispatchChatSlashCommand( try { result = await executeSlashCommand(targetClient, targetSessionKey, name, args, { sessions: host.sessions, + sessionAccessSnapshot: currentSessionAccessSnapshot(host), chatModelCatalog: host.chatModelCatalog, sessionsResult: host.sessionsResult, sessionsResultAgentId: host.sessionsResultAgentId,