diff --git a/ui/src/pages/chat/chat-pane-board.test.ts b/ui/src/pages/chat/chat-pane-board.test.ts index 26ae42a93fca..f5c88782fe69 100644 --- a/ui/src/pages/chat/chat-pane-board.test.ts +++ b/ui/src/pages/chat/chat-pane-board.test.ts @@ -302,6 +302,33 @@ describe("chat pane board shell", () => { expect(sessions.create).not.toHaveBeenCalled(); }); + it("rechecks reset scope after board confirmation", async () => { + const reset = vi.fn(async () => "completed" as const); + const sessions = { + create: vi.fn(async () => "agent:main:new"), + reset, + } as unknown as SessionCapability; + const pane = createTestPane(sessions); + pane.context.gateway.snapshot.hello = { + auth: { role: "operator", scopes: ["operator.admin"] }, + features: { methods: ["sessions.reset"] }, + } as ApplicationContext["gateway"]["snapshot"]["hello"]; + pane.boardProvider = mockBoardProvider("agent:main:current"); + + const pending = pane.createSession(); + await Promise.resolve(); + pane.context.gateway.snapshot.hello = { + auth: { role: "operator", scopes: ["operator.write"] }, + features: { methods: ["sessions.reset"] }, + } as ApplicationContext["gateway"]["snapshot"]["hello"]; + pane.settleResetConfirmation(true); + + await expect(pending).resolves.toBe(false); + expect(reset).not.toHaveBeenCalled(); + expect(pane.state.lastError).toContain("operator.admin"); + expect(pane.state.chatError).toBe(pane.state.lastError); + }); + it("does not reset when a run starts during confirmation", async () => { const reset = vi.fn(async () => "completed" as const); const sessions = { diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 4f420cc0b68e..a7c67eb69e14 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -19,8 +19,10 @@ import { import { t } from "../../i18n/index.ts"; import { resolveAsciiShortcutKey } from "../../lib/keyboard-shortcuts.ts"; import { resolveChatPaneObserverRunId } from "../../lib/observer-digest.ts"; +import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; import { sessionPullRequestsForGateway } from "../../lib/session-pull-requests.ts"; import { parseCatalogSessionKey } from "../../lib/sessions/catalog-key.ts"; +import { resolveSessionCreateParams } from "../../lib/sessions/create.ts"; import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; import { areUiSessionKeysEquivalent, @@ -172,6 +174,27 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { const client = state.client; const previousSessionKey = state.sessionKey; const preservesBoard = this.resolveBoardView().hasBoard; + const createParams = { + currentSessionKey: previousSessionKey, + agentId: + scopedAgentParamsForSession(state, previousSessionKey).agentId ?? + resolveAgentIdFromSessionKey(previousSessionKey), + }; + const createRequestParams = { + ...resolveSessionCreateParams(createParams.currentSessionKey, createParams.agentId), + }; + const readCreateAccess = () => + readSessionMethodAccess(context.gateway.snapshot, { + method: preservesBoard ? "sessions.reset" : "sessions.create", + ...(preservesBoard + ? { requiredScope: "operator.admin" as const } + : { params: createRequestParams }), + }); + const publishCreateAccessError = (reason: string) => { + state.lastError = reason; + state.chatError = reason; + state.requestUpdate?.(); + }; const connectionGeneration = this.connectionGeneration; const isCurrent = () => this.isConnected && @@ -196,6 +219,11 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { state.requestUpdate?.(); return false; } + const initialAccess = readCreateAccess(); + if (!initialAccess.allowed) { + publishCreateAccessError(initialAccess.reason); + return false; + } if ( !(await this.confirmConversationReset()) || !isCurrent() || @@ -209,6 +237,11 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { state.requestUpdate?.(); return false; } + const currentAccess = readCreateAccess(); + if (!currentAccess.allowed) { + publishCreateAccessError(currentAccess.reason); + return false; + } state.lastError = null; state.chatError = null; @@ -232,12 +265,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { } return resetResult !== "failed"; } - const nextSessionKey = await sessions.create({ - currentSessionKey: previousSessionKey, - agentId: - scopedAgentParamsForSession(state, previousSessionKey).agentId ?? - resolveAgentIdFromSessionKey(previousSessionKey), - }); + const nextSessionKey = await sessions.create(createParams); if (!isCurrent()) { return false; } diff --git a/ui/src/pages/chat/chat-pane-session-access.test.ts b/ui/src/pages/chat/chat-pane-session-access.test.ts index 4e9b9fa16a21..4fca63aeb513 100644 --- a/ui/src/pages/chat/chat-pane-session-access.test.ts +++ b/ui/src/pages/chat/chat-pane-session-access.test.ts @@ -11,6 +11,43 @@ import { createBackgroundTasksProps } from "./components/chat-background-tasks.t import { createSessionWorkspaceProps } from "./components/chat-session-workspace.ts"; describe("chat pane session access", () => { + it("refuses ordinary session creation without operator.write", async () => { + const sessions = { + create: vi.fn(async () => "agent:main:new"), + } as unknown as SessionCapability; + const client = {} as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions }); + pane.context.gateway.snapshot.hello = { + auth: { role: "operator", scopes: ["operator.read"] }, + features: { methods: ["sessions.create"] }, + } as ApplicationContext["gateway"]["snapshot"]["hello"]; + + await expect(pane.createSession()).resolves.toBe(false); + + expect(sessions.create).not.toHaveBeenCalled(); + expect(state.lastError).toContain("operator.write"); + expect(state.chatError).toBe(state.lastError); + }); + + it("requires operator.admin when session creation inherits an incognito parent", async () => { + const sessions = { + create: vi.fn(async () => "agent:main:new"), + } as unknown as SessionCapability; + const client = {} as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions }); + state.sessionKey = "agent:main:dashboard:incognito-current"; + pane.context.gateway.snapshot.hello = { + auth: { role: "operator", scopes: ["operator.write"] }, + features: { methods: ["sessions.create"] }, + } as ApplicationContext["gateway"]["snapshot"]["hello"]; + + await expect(pane.createSession()).resolves.toBe(false); + + expect(sessions.create).not.toHaveBeenCalled(); + expect(state.lastError).toContain("operator.admin"); + expect(state.chatError).toBe(state.lastError); + }); + it("refuses header rename when sessions.patch is unavailable or lacks write scope", () => { for (const hello of [ { diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index c796e105c2c4..53a98a002689 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -536,6 +536,13 @@ describe("chat pane keyboard shortcuts", () => { }); describe("chat pane session creation lifecycle", () => { + function advertiseSessionCreate(pane: TestChatPane) { + pane.context.gateway.snapshot.hello = { + auth: { role: "operator", scopes: ["operator.write"] }, + features: { methods: ["sessions.create"] }, + } as typeof pane.context.gateway.snapshot.hello; + } + it("drops a created session after a same-client reconnect", async () => { const created = createDeferred(); const sessions = { @@ -545,8 +552,10 @@ describe("chat pane session creation lifecycle", () => { const { pane, state } = createTestChatPane({ client, sessions }); const navigate = vi.fn(); pane.onPaneSessionChange = navigate; + advertiseSessionCreate(pane); const pending = pane.createSession(); + await vi.waitFor(() => expect(sessions.create).toHaveBeenCalledOnce()); state.connected = false; pane.connectionGeneration += 1; state.connectionEpoch = pane.connectionGeneration; @@ -567,8 +576,10 @@ describe("chat pane session creation lifecycle", () => { const client = {} as GatewayBrowserClient; const { pane, requestUpdate, state } = createTestChatPane({ client, sessions }); const replacementSessions = {} as SessionCapability; + advertiseSessionCreate(pane); const pending = pane.createSession(); + await vi.waitFor(() => expect(sessions.create).toHaveBeenCalledOnce()); state.sessionsError = "stale sessions.create failure"; pane.context = createSessionContext(client, replacementSessions); created.resolve(null); @@ -586,8 +597,10 @@ describe("chat pane session creation lifecycle", () => { } as unknown as SessionCapability; const client = {} as GatewayBrowserClient; const { pane, requestUpdate, state } = createTestChatPane({ client, sessions }); + advertiseSessionCreate(pane); const pending = pane.createSession(); + await vi.waitFor(() => expect(sessions.create).toHaveBeenCalledOnce()); state.sessionsError = "stale sessions.create failure"; Object.defineProperty(pane, "isConnected", { configurable: true,