diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6415911943..798cafe0cb30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Control UI operator session permissions:** honor Gateway-advertised operator scopes for new-thread creation, thread management, checkpoints, and sharing controls while preserving read-only navigation and legacy Gateway compatibility. Fixes #117786. Thanks @shakkernerd. - **Control UI archived session deletion:** send archive-gated delete requests from Sessions-page row and mixed-selection actions so write-scoped operators can remove archived threads while active-session deletion remains admin-only. Thanks @shakkernerd. - **Control UI command recovery:** keep delayed detached and immediate command failures scoped to their submitting session, preserving failed drafts and attachments for that pane without overwriting the active session. Fixes #116846. Thanks @shakkernerd. - **Microsoft Teams message-tool replies:** keep automatic live previews from duplicating a message already delivered to the current Teams conversation, while preserving distinct follow-up text and cross-conversation sends. Fixes #116397. (#116398) Thanks @a-tokyo. diff --git a/ui/src/e2e/session-ownership.e2e.test.ts b/ui/src/e2e/session-ownership.e2e.test.ts index 0af36dd22958..7e12249a7578 100644 --- a/ui/src/e2e/session-ownership.e2e.test.ts +++ b/ui/src/e2e/session-ownership.e2e.test.ts @@ -198,6 +198,7 @@ describeControlUiE2e("Control UI session ownership", () => { page = currentPage; await installMockGateway(currentPage, { sessionKey: "agent:main:ada", + featureMethods: ["chat.metadata", "chat.startup", "sessions.create"], historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }], methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-ada"]) }, }); @@ -279,6 +280,7 @@ describeControlUiE2e("Control UI session ownership", () => { page = currentPage; const gateway = await installMockGateway(currentPage, { allowedSessionVisibilities: ["shared", "draft"], + featureMethods: ["chat.metadata", "chat.startup", "sessions.create"], hasMultipleSessionSharingIdentities: true, methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-bob"]), @@ -324,7 +326,15 @@ describeControlUiE2e("Control UI session ownership", () => { Object.assign(ownerSession, { sharingRole: "owner" }); const gateway = await installMockGateway(currentPage, { sessionKey: "agent:main:ada", - featureMethods: ["chat.metadata", "chat.startup", "session.visibility.set"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "session.visibility.set", + "session.members.list", + "session.members.add", + "session.members.remove", + ], + operatorScopes: ["operator.write"], historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }], methodResponses: { "sessions.list": sessions, @@ -359,6 +369,66 @@ describeControlUiE2e("Control UI session ownership", () => { expect(await gateway.getRequests("session.visibility.set")).toHaveLength(1); }); + it("lets a read-scoped owner inspect sharing but blocks mutations", async () => { + const context = await browser.newContext({ viewport: { height: 800, width: 1200 } }); + const currentPage = await context.newPage(); + page = currentPage; + const sessions = draftSessionsList(); + const ownerSession = sessions.sessions[0]; + if (!ownerSession) { + throw new Error("expected owner draft fixture"); + } + Object.assign(ownerSession, { sharingRole: "owner" }); + const gateway = await installMockGateway(currentPage, { + sessionKey: "agent:main:ada", + featureMethods: [ + "chat.metadata", + "chat.startup", + "session.visibility.set", + "session.members.list", + "session.members.add", + "session.members.remove", + ], + operatorScopes: ["operator.read"], + historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }], + methodResponses: { + "sessions.list": sessions, + "session.members.list": { + sessionKey: "agent:main:ada", + members: [], + identities: [{ type: "human", id: "profile-bob", label: "Bob" }], + role: "owner", + allowedVisibilities: ["shared", "draft"], + }, + }, + }); + + await currentPage.goto(`${server?.baseUrl ?? ""}chat`); + await currentPage.getByText("Ready.", { exact: true }).waitFor(); + await currentPage.getByLabel("Thread sharing").click(); + await gateway.waitForRequest("session.members.list"); + const dropdown = currentPage.locator(".chat-pane__sharing-menu"); + const publish = dropdown.locator('wa-dropdown-item[value="visibility:shared"]'); + await publish.waitFor(); + expect(await publish.getAttribute("disabled")).not.toBeNull(); + + await dropdown.evaluate((element) => { + element.dispatchEvent( + new CustomEvent("wa-select", { + detail: { item: { value: "visibility:shared" } }, + }), + ); + element.dispatchEvent( + new CustomEvent("wa-select", { + detail: { item: { value: "member:profile-bob" } }, + }), + ); + }); + + expect(await gateway.getRequests("session.visibility.set")).toHaveLength(0); + expect(await gateway.getRequests("session.members.add")).toHaveLength(0); + }); + it("clears a selected draft mode when sharing policy becomes unavailable", async () => { const context = await browser.newContext({ viewport: { height: 800, width: 1200 } }); const currentPage = await context.newPage(); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index df060c7f34f9..5b67486d5fa6 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -667,6 +667,7 @@ export const en: TranslationMap = { automationPrefix: "Automation:", actionRequiresConnection: "Connect to the Gateway to change threads.", actionUnavailable: "This Gateway does not support this thread action.", + actionRequiresRead: "This action requires operator.read access.", actionRequiresWrite: "This action requires operator.write access.", actionRequiresAdmin: "This action requires operator.admin access.", deletePreservedWorktrees: diff --git a/ui/src/lib/session-method-access.test.ts b/ui/src/lib/session-method-access.test.ts index 25d571db2314..178f63a88632 100644 --- a/ui/src/lib/session-method-access.test.ts +++ b/ui/src/lib/session-method-access.test.ts @@ -52,6 +52,30 @@ describe("readSessionMethodAccess", () => { ).toBe(true); }); + it("allows read, write, and admin scopes to satisfy read-scoped actions", () => { + for (const scope of ["operator.read", "operator.write", "operator.admin"]) { + expect( + readSessionMethodAccess(snapshot({ methods: ["session.members.list"], scopes: [scope] }), { + method: "session.members.list", + requiredScope: "operator.read", + }).allowed, + ).toBe(true); + } + }); + + it("rejects a read-scoped action without a compatible operator scope", () => { + expect( + readSessionMethodAccess( + snapshot({ methods: ["session.members.list"], scopes: ["operator.approvals"] }), + { method: "session.members.list", requiredScope: "operator.read" }, + ), + ).toMatchObject({ + allowed: false, + cause: "missing-scope", + requiredScope: "operator.read", + }); + }); + it("preserves legacy snapshots without advertised auth scopes", () => { expect( readSessionMethodAccess(snapshot({ includeAuth: false }), { diff --git a/ui/src/lib/session-method-access.ts b/ui/src/lib/session-method-access.ts index 13df57290d48..767435dd8cc1 100644 --- a/ui/src/lib/session-method-access.ts +++ b/ui/src/lib/session-method-access.ts @@ -7,11 +7,13 @@ import type { ApplicationGatewaySnapshot } from "../app/gateway.ts"; import { t } from "../i18n/index.ts"; import { isGatewayMethodAdvertised } from "./gateway-methods.ts"; +export type SessionMethodOperatorScope = "operator.read" | SessionMutationOperatorScope; + export type SessionMethodAccess = - | { allowed: true; requiredScope: SessionMutationOperatorScope } + | { allowed: true; requiredScope: SessionMethodOperatorScope } | { allowed: false; - requiredScope: SessionMutationOperatorScope; + requiredScope: SessionMethodOperatorScope; reason: string; cause: "disconnected" | "method-unavailable" | "missing-scope"; }; @@ -19,12 +21,12 @@ export type SessionMethodAccess = type SessionMethodAccessRequest = { method: string; params?: unknown; - requiredScope?: SessionMutationOperatorScope; + requiredScope?: SessionMethodOperatorScope; }; function sessionMethodAccessReason( cause: Exclude["cause"], - requiredScope: SessionMutationOperatorScope, + requiredScope: SessionMethodOperatorScope, ): string { if (cause === "disconnected") { return t("sessionsView.actionRequiresConnection"); @@ -35,7 +37,9 @@ function sessionMethodAccessReason( return t( requiredScope === "operator.admin" ? "sessionsView.actionRequiresAdmin" - : "sessionsView.actionRequiresWrite", + : requiredScope === "operator.write" + ? "sessionsView.actionRequiresWrite" + : "sessionsView.actionRequiresRead", ); } diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index d7d6a431652a..edbb670ee089 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -17,6 +17,7 @@ import { hasSessionPresenceViewers } from "../../components/viewer-facepile.ts"; import { t } from "../../i18n/index.ts"; import { copyToClipboard } from "../../lib/clipboard.ts"; import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { renderBoardDockMenu, renderBoardFaceToggle } from "./board-session-surface.ts"; import { ChatPaneContext } from "./chat-pane-context.ts"; @@ -100,6 +101,29 @@ export abstract class ChatPaneHeader extends ChatPaneContext { : branchSwitchWorking ? t("chat.sessionHeader.branchSwitchUnavailable") : null; + const sharingSnapshot = this.context.gateway.snapshot; + const sharingMethodsSupported = [ + "session.visibility.set", + "session.members.list", + "session.members.add", + "session.members.remove", + ].some((method) => isGatewayMethodAdvertised(sharingSnapshot, method) !== false); + const sharingReadAccess = readSessionMethodAccess(sharingSnapshot, { + method: "session.members.list", + requiredScope: "operator.read", + }); + const sharingVisibilityAccess = readSessionMethodAccess(sharingSnapshot, { + method: "session.visibility.set", + requiredScope: "operator.write", + }); + const sharingMemberAddAccess = readSessionMethodAccess(sharingSnapshot, { + method: "session.members.add", + requiredScope: "operator.write", + }); + const sharingMemberRemoveAccess = readSessionMethodAccess(sharingSnapshot, { + method: "session.members.remove", + requiredScope: "operator.write", + }); return renderChatPaneHeader({ paneId: this.paneId, narrow: this.narrow, @@ -156,20 +180,29 @@ export abstract class ChatPaneHeader extends ChatPaneContext { this.syncChatSidebarForDock(face === "dashboard" ? board.dock : "hidden"); this.persistBoardSessionView({ face }); }), - sharingControl: - isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.visibility.set") === true - ? renderChatSessionSharing({ - session: row, - state: row - ? this.sessionSharingStates.get(this.sessionSharingCacheKey(row.key)) - : undefined, - onOpen: () => row && void this.loadSessionSharing(row), - onVisibilityChange: (visibility) => - row && void this.setSessionVisibility(row, visibility), - onMemberChange: (identityId, member) => - row && void this.setSessionMember(row, identityId, member), - }) - : nothing, + sharingControl: sharingMethodsSupported + ? renderChatSessionSharing({ + session: row, + state: row + ? this.sessionSharingStates.get(this.sessionSharingCacheKey(row.key)) + : undefined, + openDisabledReason: sharingReadAccess.allowed ? undefined : sharingReadAccess.reason, + visibilityDisabledReason: sharingVisibilityAccess.allowed + ? undefined + : sharingVisibilityAccess.reason, + memberAddDisabledReason: sharingMemberAddAccess.allowed + ? undefined + : sharingMemberAddAccess.reason, + memberRemoveDisabledReason: sharingMemberRemoveAccess.allowed + ? undefined + : sharingMemberRemoveAccess.reason, + onOpen: () => row && void this.loadSessionSharing(row), + onVisibilityChange: (visibility) => + row && void this.setSessionVisibility(row, visibility), + onMemberChange: (identityId, member) => + row && void this.setSessionMember(row, identityId, member), + }) + : nothing, boardDockAction: renderBoardDockMenu( board.hasBoard && !board.activeTabReadOnly && board.provider.canMutate, board.face, diff --git a/ui/src/pages/chat/chat-pane-sharing.test.ts b/ui/src/pages/chat/chat-pane-sharing.test.ts index 75c083cc65ea..55fafb05996b 100644 --- a/ui/src/pages/chat/chat-pane-sharing.test.ts +++ b/ui/src/pages/chat/chat-pane-sharing.test.ts @@ -18,12 +18,49 @@ import type { ChatPageHost } from "./chat-state-host.ts"; import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts"; type SharingPane = TestChatPane & { + loadSessionSharing: (row: GatewaySessionRow, force?: boolean) => Promise; sessionSharingCacheKey: (sessionKey: string) => string; sessionSharingStates: Map; setSessionMember: (row: GatewaySessionRow, identityId: string, member: boolean) => Promise; setSessionVisibility: (row: GatewaySessionRow, visibility: SessionVisibility) => Promise; }; +const SHARING_METHODS = [ + "session.visibility.set", + "session.members.list", + "session.members.add", + "session.members.remove", +]; + +function setSharingAuthorization( + pane: SharingPane, + params: { + methods?: string[]; + phase?: "connected" | "reconnecting"; + scopes?: string[]; + } = {}, +): void { + const snapshot = pane.context.gateway.snapshot; + snapshot.phase = params.phase ?? "connected"; + snapshot.hello = { + ...snapshot.hello, + auth: { + role: "operator", + scopes: params.scopes ?? ["operator.admin"], + }, + features: { + ...snapshot.hello?.features, + methods: params.methods ?? SHARING_METHODS, + }, + } as typeof snapshot.hello; +} + +function createSharingTestChatPane(params: Parameters[0]) { + const result = createTestChatPane(params); + setSharingAuthorization(result.pane as SharingPane); + return result; +} + type Deferred = { promise: Promise; reject: (error: unknown) => void; @@ -68,6 +105,7 @@ function replaceConnection( ): void { pane.connectionGeneration += 1; pane.context = createSessionContext(client, sessions); + setSharingAuthorization(pane); pane.state = state; state.client = client; state.connected = true; @@ -108,6 +146,126 @@ const mutations = [ }, ] as const; +describe("chat pane sharing authorization", () => { + it("allows read-scoped owners to load sharing data but not mutate it", async () => { + const row = sessionRow(); + const request = vi.fn(async (method: string) => { + if (method === "session.members.list") { + return sharingResult(row); + } + throw new Error(`unexpected request: ${method}`); + }); + const sessions = { + refreshReplacement: vi.fn(), + } as unknown as SessionCapability; + const { pane: testPane } = createSharingTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions, + }); + const pane = testPane as SharingPane; + setSharingAuthorization(pane, { scopes: ["operator.read"] }); + + await pane.loadSessionSharing(row); + await pane.setSessionVisibility(row, "shared"); + await pane.setSessionMember(row, "identity-alice", true); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith( + "session.members.list", + expect.objectContaining({ sessionKey: row.key }), + ); + expect(sessions.refreshReplacement).not.toHaveBeenCalled(); + }); + + it("allows write and admin scoped owners to mutate sharing", async () => { + for (const scope of ["operator.write", "operator.admin"]) { + const row = sessionRow(); + const request = vi.fn(async (method: string) => { + if (method === "session.members.list") { + return sharingResult(row); + } + return {}; + }); + const sessions = { + refreshReplacement: vi.fn(async () => undefined), + } as unknown as SessionCapability; + const { pane: testPane } = createSharingTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions, + }); + const pane = testPane as SharingPane; + setSharingAuthorization(pane, { scopes: [scope] }); + + await pane.setSessionVisibility(row, "shared"); + await pane.setSessionMember(row, "identity-alice", true); + + expect(request).toHaveBeenCalledWith( + "session.visibility.set", + expect.objectContaining({ sessionKey: row.key }), + ); + expect(request).toHaveBeenCalledWith( + "session.members.add", + expect.objectContaining({ sessionKey: row.key }), + ); + } + }); + + it("refuses sharing requests for non-managers and disconnected snapshots", async () => { + for (const authorization of [ + { row: { ...sessionRow(), sharingRole: "member" as const } }, + { row: sessionRow(), phase: "reconnecting" as const }, + ]) { + const request = vi.fn(); + const sessions = { + refreshReplacement: vi.fn(), + } as unknown as SessionCapability; + const { pane: testPane } = createSharingTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions, + }); + const pane = testPane as SharingPane; + setSharingAuthorization(pane, { + phase: authorization.phase, + scopes: ["operator.admin"], + }); + + await pane.loadSessionSharing(authorization.row); + await pane.setSessionVisibility(authorization.row, "shared"); + await pane.setSessionMember(authorization.row, "identity-alice", true); + + expect(request).not.toHaveBeenCalled(); + expect(sessions.refreshReplacement).not.toHaveBeenCalled(); + } + }); + + it("refuses explicitly unadvertised sharing methods", async () => { + const row = sessionRow(); + const request = vi.fn(async () => sharingResult(row)); + const sessions = { + refreshReplacement: vi.fn(), + } as unknown as SessionCapability; + const { pane: testPane } = createSharingTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions, + }); + const pane = testPane as SharingPane; + setSharingAuthorization(pane, { + methods: ["session.members.list"], + scopes: ["operator.admin"], + }); + + await pane.loadSessionSharing(row); + await pane.setSessionVisibility(row, "shared"); + await pane.setSessionMember(row, "identity-alice", true); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith( + "session.members.list", + expect.objectContaining({ sessionKey: row.key }), + ); + }); +}); + describe.each(mutations)("chat pane $name mutation connection ownership", (mutation) => { it.each(["resolve", "reject"] as const)( "drops a stale mutation when the previous connection later %s", @@ -122,7 +280,7 @@ describe.each(mutations)("chat pane $name mutation connection ownership", (mutat const oldSessions = { refreshReplacement: vi.fn(), } as unknown as SessionCapability; - const { pane: testPane, state } = createTestChatPane({ + const { pane: testPane, state } = createSharingTestChatPane({ client: { request: oldRequest } as unknown as GatewayBrowserClient, sessions: oldSessions, }); @@ -159,7 +317,7 @@ describe.each(mutations)("chat pane $name mutation connection ownership", (mutat const sessions = { refreshReplacement: vi.fn(), } as unknown as SessionCapability; - const { pane: testPane } = createTestChatPane({ + const { pane: testPane } = createSharingTestChatPane({ client: { request } as unknown as GatewayBrowserClient, sessions, }); @@ -190,7 +348,7 @@ describe("chat pane sharing mutation phase ownership", () => { const oldSessions = { refreshReplacement: vi.fn(() => refreshed.promise), } as unknown as SessionCapability; - const { pane: testPane, state } = createTestChatPane({ + const { pane: testPane, state } = createSharingTestChatPane({ client: { request } as unknown as GatewayBrowserClient, sessions: oldSessions, }); @@ -232,7 +390,7 @@ describe("chat pane sharing mutation phase ownership", () => { const oldSessions = { refreshReplacement: vi.fn(async () => undefined), } as unknown as SessionCapability; - const { pane: testPane, state } = createTestChatPane({ + const { pane: testPane, state } = createSharingTestChatPane({ client: { request } as unknown as GatewayBrowserClient, sessions: oldSessions, }); @@ -277,7 +435,7 @@ describe("chat pane sharing mutation phase ownership", () => { const oldSessions = { refreshReplacement: vi.fn(() => refreshed.promise), } as unknown as SessionCapability; - const { pane: testPane, state } = createTestChatPane({ + const { pane: testPane, state } = createSharingTestChatPane({ client: { request } as unknown as GatewayBrowserClient, sessions: oldSessions, }); @@ -313,7 +471,7 @@ describe("chat pane current sharing mutation refresh order", () => { calls.push("sessions.refreshReplacement"); }), } as unknown as SessionCapability; - const { pane: testPane } = createTestChatPane({ + const { pane: testPane } = createSharingTestChatPane({ client: { request } as unknown as GatewayBrowserClient, sessions, }); @@ -346,7 +504,7 @@ describe("chat pane current sharing mutation refresh order", () => { calls.push("sessions.refreshReplacement"); }), } as unknown as SessionCapability; - const { pane: testPane } = createTestChatPane({ + const { pane: testPane } = createSharingTestChatPane({ client: { request } as unknown as GatewayBrowserClient, sessions, }); diff --git a/ui/src/pages/chat/chat-pane-sharing.ts b/ui/src/pages/chat/chat-pane-sharing.ts index f250b201440c..f3c7df683d2e 100644 --- a/ui/src/pages/chat/chat-pane-sharing.ts +++ b/ui/src/pages/chat/chat-pane-sharing.ts @@ -15,6 +15,7 @@ import type { import { hasMultiplePresenceIdentities } from "../../components/viewer-facepile.ts"; import { t } from "../../i18n/index.ts"; import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; import { areUiSessionKeysEquivalent, @@ -28,7 +29,10 @@ import { } from "./chat-pane-shared.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; import { resolveChatAgentId } from "./chat-state-route.ts"; -import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts"; +import { + canManageChatSessionSharing, + type ChatSessionSharingState, +} from "./components/chat-session-sharing.ts"; export abstract class ChatPaneSharing extends ChatPaneBase { protected setSessionSharingState(cacheKey: string, state: ChatSessionSharingState): void { @@ -48,7 +52,15 @@ export abstract class ChatPaneSharing extends ChatPaneBase { protected async loadSessionSharing(row: GatewaySessionRow, force = false): Promise { const state = this.state; - if (!state?.connected || !state.client) { + if ( + !state?.connected || + !state.client || + !canManageChatSessionSharing(row) || + !readSessionMethodAccess(this.context.gateway.snapshot, { + method: "session.members.list", + requiredScope: "operator.read", + }).allowed + ) { return; } const cacheKey = this.sessionSharingCacheKey(row.key); @@ -86,17 +98,26 @@ export abstract class ChatPaneSharing extends ChatPaneBase { visibility: SessionVisibility, ): Promise { const scope = this.captureConnectionScope(); - if (!scope || visibility === row.visibility) { + if (!scope || visibility === row.visibility || !canManageChatSessionSharing(row)) { return; } const agentId = this.sessionSharingAgentId(row.key); const cacheKey = this.sessionSharingCacheKey(row.key); + const params = { + sessionKey: row.key, + visibility, + ...(agentId ? { agentId } : {}), + }; + if ( + !readSessionMethodAccess(scope.context.gateway.snapshot, { + method: "session.visibility.set", + requiredScope: "operator.write", + }).allowed + ) { + return; + } try { - await scope.client.request("session.visibility.set", { - sessionKey: row.key, - visibility, - ...(agentId ? { agentId } : {}), - }); + await scope.client.request("session.visibility.set", params); if (!this.isConnectionScopeCurrent(scope)) { return; } @@ -123,17 +144,27 @@ export abstract class ChatPaneSharing extends ChatPaneBase { member: boolean, ): Promise { const scope = this.captureConnectionScope(); - if (!scope) { + if (!scope || !canManageChatSessionSharing(row)) { return; } const agentId = this.sessionSharingAgentId(row.key); const cacheKey = this.sessionSharingCacheKey(row.key); + const method = member ? "session.members.add" : "session.members.remove"; + const params = { + sessionKey: row.key, + identityId, + ...(agentId ? { agentId } : {}), + }; + if ( + !readSessionMethodAccess(scope.context.gateway.snapshot, { + method, + requiredScope: "operator.write", + }).allowed + ) { + return; + } try { - await scope.client.request(member ? "session.members.add" : "session.members.remove", { - sessionKey: row.key, - identityId, - ...(agentId ? { agentId } : {}), - }); + await scope.client.request(method, params); if (!this.isConnectionScopeCurrent(scope)) { return; } diff --git a/ui/src/pages/chat/components/chat-session-sharing.test.ts b/ui/src/pages/chat/components/chat-session-sharing.test.ts index 8394134f158f..fd30fab519ab 100644 --- a/ui/src/pages/chat/components/chat-session-sharing.test.ts +++ b/ui/src/pages/chat/components/chat-session-sharing.test.ts @@ -127,4 +127,98 @@ describe("chat session sharing menu", () => { expect(onVisibilityChange).toHaveBeenCalledWith("shared"); expect(root.querySelectorAll('wa-dropdown-item[value="visibility:shared"]')).toHaveLength(1); }); + + it("keeps read-only owner controls visible but refuses disabled synthetic actions", () => { + const onOpen = vi.fn(); + const onVisibilityChange = vi.fn(); + const onMemberChange = vi.fn(); + const root = mount( + renderChatSessionSharing({ + session: { + key: "agent:main:main", + kind: "direct", + updatedAt: 1, + visibility: "shared", + sharingRole: "owner", + }, + state: { + loading: false, + result: { + sessionKey: "agent:main:main", + members: [{ identityId: "alice", addedAt: 1 }], + identities: [ + { type: "human", id: "alice", label: "Alice" }, + { type: "human", id: "bob", label: "Bob" }, + ], + role: "owner", + allowedVisibilities: ["shared", "read-only"], + }, + }, + visibilityDisabledReason: "Requires write", + memberAddDisabledReason: "Requires write", + memberRemoveDisabledReason: "Requires write", + onOpen, + onVisibilityChange, + onMemberChange, + }), + ); + const dropdown = root.querySelector("wa-dropdown"); + expect(dropdown).not.toBeNull(); + expect(root.querySelector('wa-dropdown-item[value="visibility:read-only"]')?.title).toBe( + "Requires write", + ); + expect( + root.querySelector('wa-dropdown-item[value="member:alice"]')?.hasAttribute("disabled"), + ).toBe(true); + expect( + root.querySelector('wa-dropdown-item[value="member:bob"]')?.hasAttribute("disabled"), + ).toBe(true); + + dropdown?.dispatchEvent(new CustomEvent("wa-show")); + dropdown?.dispatchEvent( + new CustomEvent("wa-select", { + detail: { item: { value: "visibility:read-only" } }, + }), + ); + dropdown?.dispatchEvent( + new CustomEvent("wa-select", { + detail: { item: { value: "member:alice" } }, + }), + ); + dropdown?.dispatchEvent( + new CustomEvent("wa-select", { + detail: { item: { value: "member:bob" } }, + }), + ); + + expect(onOpen).toHaveBeenCalledOnce(); + expect(onVisibilityChange).not.toHaveBeenCalled(); + expect(onMemberChange).not.toHaveBeenCalled(); + }); + + it("disables opening when sharing reads are unavailable", () => { + const onOpen = vi.fn(); + const root = mount( + renderChatSessionSharing({ + session: { + key: "agent:main:main", + kind: "direct", + updatedAt: 1, + visibility: "shared", + sharingRole: "owner", + }, + state: undefined, + openDisabledReason: "Connect to the Gateway", + onOpen, + onVisibilityChange: vi.fn(), + onMemberChange: vi.fn(), + }), + ); + + expect(root.querySelector(".chat-pane__sharing-trigger")?.disabled).toBe( + true, + ); + root.querySelector("wa-dropdown")?.dispatchEvent(new CustomEvent("wa-show")); + expect(onOpen).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/pages/chat/components/chat-session-sharing.ts b/ui/src/pages/chat/components/chat-session-sharing.ts index ec0cf9c89b60..cd94cd488d10 100644 --- a/ui/src/pages/chat/components/chat-session-sharing.ts +++ b/ui/src/pages/chat/components/chat-session-sharing.ts @@ -17,6 +17,10 @@ export type ChatSessionSharingState = { type ChatSessionSharingProps = { session: GatewaySessionRow | undefined; state: ChatSessionSharingState | undefined; + openDisabledReason?: string; + visibilityDisabledReason?: string; + memberAddDisabledReason?: string; + memberRemoveDisabledReason?: string; onOpen: () => void; onVisibilityChange: (visibility: SessionVisibility) => void; onMemberChange: (identityId: string, member: boolean) => void; @@ -36,13 +40,19 @@ function sharingIcon(visibility: SessionVisibility): TemplateResult { return visibility === "shared" ? icons.users : icons.lock; } +export function canManageChatSessionSharing( + session: Pick, +): boolean { + return session.sharingRole === "admin" || session.sharingRole === "owner"; +} + export function renderChatSessionSharing(props: ChatSessionSharingProps) { const session = props.session; if (!session) { return nothing; } const visibility = session.visibility ?? "shared"; - const canManage = session.sharingRole === "admin" || session.sharingRole === "owner"; + const canManage = canManageChatSessionSharing(session); if (!canManage) { return visibility === "draft" ? html` { + if (!props.openDisabledReason) { + props.onOpen(); + } + }} @wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => { const value = event.detail.item.value; if (value?.startsWith("visibility:")) { - props.onVisibilityChange(value.slice("visibility:".length) as SessionVisibility); + if (!props.visibilityDisabledReason) { + props.onVisibilityChange(value.slice("visibility:".length) as SessionVisibility); + } return; } if (value?.startsWith("member:")) { const identityId = value.slice("member:".length); - props.onMemberChange(identityId, !members.has(identityId)); + const member = !members.has(identityId); + const disabledReason = member + ? props.memberAddDisabledReason + : props.memberRemoveDisabledReason; + if (!disabledReason) { + props.onMemberChange(identityId, member); + } } }} > @@ -79,14 +101,21 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) { class="btn btn--ghost btn--icon chat-icon-btn chat-pane__sharing-trigger" type="button" aria-label=${t("chat.sessionSharing.menu")} - title=${t("chat.sessionSharing.current", { + ?disabled=${Boolean(props.openDisabledReason)} + title=${props.openDisabledReason ?? + t("chat.sessionSharing.current", { visibility: t(VISIBILITY_LABEL_KEYS[visibility]), })} > ${sharingIcon(visibility)} ${canPublish - ? html` + ? html` ${t("chat.sessionSharing.publishDraft")} @@ -97,7 +126,11 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) { .filter((option) => !canPublish || option !== "shared") .map( (option) => html` - + ${t(VISIBILITY_LABEL_KEYS[option])} ${option === visibility ? html`` @@ -110,9 +143,16 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) { ${props.state?.loading ? html`` : identities.length > 0 - ? identities.map( - (identity) => html` - + ? identities.map((identity) => { + const disabledReason = members.has(identity.id) + ? props.memberRemoveDisabledReason + : props.memberAddDisabledReason; + return html` + ${identity.label ?? identity.id} ${members.has(identity.id) ? html`` : nothing} - `, - ) + `; + }) : html``} ${props.state?.error ? html`