fix: preserve read-only session navigation

This commit is contained in:
Shakker
2026-08-02 07:41:11 +01:00
parent 75d90de00a
commit 467c77df81
6 changed files with 87 additions and 17 deletions
+1 -4
View File
@@ -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<string> = new Set([
"archivedOnly",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
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
+4 -6
View File
@@ -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",
+12 -6
View File
@@ -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
@@ -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();
});
});
+1 -1
View File
@@ -108,7 +108,7 @@ export type TestChatPane = HTMLElement & {
renderPaneHeader: (
workspace: ReturnType<typeof createSessionWorkspaceProps>,
tasks: ReturnType<typeof createBackgroundTasksProps>,
row: undefined,
row: GatewaySessionRow | undefined,
catalog: boolean,
agentWorkspace: undefined,
workspaceGit: boolean,
+32
View File
@@ -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,