diff --git a/src/shared/session-method-scopes.ts b/src/shared/session-method-scopes.ts index 3189c53f32ce..35ca9e177e9a 100644 --- a/src/shared/session-method-scopes.ts +++ b/src/shared/session-method-scopes.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { isIncognitoSessionKey } from "./incognito-session-key.js"; export type SessionMutationOperatorScope = "operator.write" | "operator.admin"; @@ -21,10 +22,6 @@ const SESSIONS_DELETE_WRITE_SCOPE_FIELDS: ReadonlySet = new Set([ "archivedOnly", ]); -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function resolveSessionsPatchRequiredScope(params: unknown): SessionMutationOperatorScope { if (!isRecord(params)) { // Malformed params cannot mutate anything; let the handler return the diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index edbb670ee089..abb0af76f4f4 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -102,12 +102,10 @@ export abstract class ChatPaneHeader extends ChatPaneContext { ? 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); + // Sharing was introduced behind this advertised method. Keep the control + // hidden for older Gateways that omit method metadata. + const sharingMethodsSupported = + isGatewayMethodAdvertised(sharingSnapshot, "session.visibility.set") === true; const sharingReadAccess = readSessionMethodAccess(sharingSnapshot, { method: "session.members.list", requiredScope: "operator.read", diff --git a/ui/src/pages/chat/chat-pane-session.ts b/ui/src/pages/chat/chat-pane-session.ts index 6c80a6fac9c0..4a9440304509 100644 --- a/ui/src/pages/chat/chat-pane-session.ts +++ b/ui/src/pages/chat/chat-pane-session.ts @@ -310,15 +310,21 @@ export abstract class ChatPaneSession extends ChatPaneSharing { (row.status === "failed" || row.status === "timeout") && (row.lastReadAt == null || failureAt > row.lastReadAt); const agentStatusActive = Boolean(row.agentStatus && row.agentStatus.expiresAt > Date.now()); - if ( - !this.unreadPatchGuard.shouldPatch( - state.sessionKey, - row.unread === true || unreadFailure || agentStatusActive, - ) - ) { + const unread = row.unread === true || unreadFailure || agentStatusActive; + if (!unread) { + this.unreadPatchGuard.shouldPatch(state.sessionKey, false); return; } const agentId = parseAgentSessionKey(row.key)?.agentId ?? resolveChatAgentId(state); + const access = readSessionMethodAccess(this.context.gateway.snapshot, { + method: "sessions.patch", + params: { key: row.key, unread: false, agentId }, + }); + // Read-only navigation must remain silent: absence of mutation access is + // not an operation failure and should not latch the unread retry guard. + if (!access.allowed || !this.unreadPatchGuard.shouldPatch(state.sessionKey, true)) { + return; + } const guardKey = state.sessionKey; void this.context.sessions.patch(row.key, { unread: false }, { agentId }).catch(() => { // Unlatch so later unread snapshots retry; the session capability diff --git a/ui/src/pages/chat/chat-pane.read-marker.test.ts b/ui/src/pages/chat/chat-pane.read-marker.test.ts index 1048cbc287c8..08dd795f4840 100644 --- a/ui/src/pages/chat/chat-pane.read-marker.test.ts +++ b/ui/src/pages/chat/chat-pane.read-marker.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import { createTestChatPane } from "./chat-pane.test-support.ts"; @@ -52,4 +53,40 @@ describe("chat pane read markers", () => { { agentId: "main" }, ); }); + + it.each([ + { + name: "read-only scope", + methods: ["sessions.patch"], + scopes: ["operator.read"], + }, + { + name: "unadvertised sessions.patch", + methods: ["sessions.create"], + scopes: ["operator.write"], + }, + ])("does not mutate unread state with $name", ({ methods, scopes }) => { + const patch = vi.fn().mockResolvedValue(null); + const { pane, state } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: { patch } as unknown as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + auth: { role: "operator", scopes }, + features: { methods }, + } as ApplicationGatewaySnapshot["hello"]; + const row = { + key: "agent:main:current", + kind: "direct" as const, + updatedAt: 20, + unread: true, + }; + + pane.markSessionRead(row); + pane.markSessionRead(row); + + expect(patch).not.toHaveBeenCalled(); + expect(state.chatError).toBeNull(); + expect(state.lastError).toBeNull(); + }); }); diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index b506b881c096..00ad322f4e92 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -108,7 +108,7 @@ export type TestChatPane = HTMLElement & { renderPaneHeader: ( workspace: ReturnType, tasks: ReturnType, - row: undefined, + row: GatewaySessionRow | undefined, catalog: boolean, agentWorkspace: undefined, workspaceGit: boolean, diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 58d8d556ad61..aa5b14c82d6d 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -259,6 +259,38 @@ describe("chat pane header state", () => { expect(patch).not.toHaveBeenCalled(); }); + it("keeps sharing hidden when legacy Gateways omit method metadata", () => { + const { pane, state } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + auth: { role: "operator", scopes: ["operator.write"] }, + } as ApplicationContext["gateway"]["snapshot"]["hello"]; + const session = { + key: "agent:main:current", + kind: "direct", + updatedAt: 0, + sharingRole: "owner", + visibility: "shared", + } satisfies GatewaySessionRow; + const container = document.createElement("div"); + + render( + pane.renderPaneHeader( + createSessionWorkspaceProps(state), + createBackgroundTasksProps(state, { onOpenSession: () => {} }), + session, + false, + undefined, + false, + ), + container, + ); + + expect(container.querySelector(".chat-pane__sharing-menu")).toBeNull(); + }); + it("copies the resolved workspace path and branch", async () => { const { pane } = createTestChatPane({ client: {} as GatewayBrowserClient,