mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
fix: preserve read-only session navigation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user