fix: gate chat session mutations exactly

This commit is contained in:
Shakker
2026-08-02 06:54:37 +01:00
parent 467c77df81
commit a45feb3915
15 changed files with 264 additions and 27 deletions
@@ -10,6 +10,7 @@ function createContext(configSnapshot: ConfigSnapshot | null): ApplicationContex
return {
gateway: {
snapshot: {
client: {} as GatewayBrowserClient,
phase: "connected",
hello: {
auth: { role: "operator", scopes: ["operator.admin", "operator.write"] },
@@ -232,6 +233,34 @@ describe("ChatComposerCapabilityHost", () => {
expect(props.webSearchBaseEnabled).toBe(false);
});
it("blocks tool override patches without exact sessions.patch access", async () => {
const host = new ChatComposerCapabilityHost(vi.fn());
const context = createContext({ runtimeConfig: {} });
context.gateway.snapshot.hello = {
auth: { role: "operator", scopes: ["operator.write"] },
features: { methods: ["tools.effective"] },
} as NonNullable<typeof context.gateway.snapshot.hello>;
const request = vi.fn();
const state = createState();
state.client = { request } as unknown as GatewayBrowserClient;
const session = { key: "main" } as GatewaySessionRow;
const props = host.props(context, state, session, "main");
expect(props.mutationBlockedReason).toBeTruthy();
const result = await (
host as unknown as {
patch: (
context: ApplicationContext,
state: ChatPageHost,
next: { skills: Record<string, boolean> },
) => Promise<{ ok: true } | { ok: false; error: string }>;
}
).patch(context, state, { skills: { release: true } });
expect(result).toEqual({ ok: false, error: props.mutationBlockedReason });
expect(request).not.toHaveBeenCalled();
});
it("keeps Everywhere selectable while a missing session row blocks session submit", async () => {
const host = new ChatComposerCapabilityHost(vi.fn());
const context = createContext({ runtimeConfig: {} });
@@ -340,7 +369,7 @@ describe("ChatComposerCapabilityHost", () => {
const host = new ChatComposerCapabilityHost(notify);
const context = createContext({ runtimeConfig: {} });
context.gateway.snapshot.hello = {
features: { methods: ["tools.effective"] },
features: { methods: ["sessions.patch", "tools.effective"] },
} as NonNullable<typeof context.gateway.snapshot.hello>;
let stateReads = 0;
Object.defineProperty(context.sessions, "state", {
@@ -27,6 +27,7 @@ import {
summarizeMcpServers,
} from "../../lib/config/mcp-servers.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
import {
scopedAgentListParamsForSession,
scopedAgentParamsForSession,
@@ -300,8 +301,12 @@ export class ChatComposerCapabilityHost {
if (!state.connected || !state.client) {
return { ok: false, error: t("chat.composer.menu.offlineBlocked") };
}
if (!readGatewayOperatorAccess(context.gateway.snapshot).canWrite) {
return { ok: false, error: t("chat.composer.menu.readOnlyBlocked") };
const access = readSessionMethodAccess(context.gateway.snapshot, {
method: "sessions.patch",
params: { key: state.sessionKey, toolOverrides: next },
});
if (!access.allowed) {
return { ok: false, error: access.reason };
}
const sessionKey = state.sessionKey;
if (this.patchTokens.has(sessionKey)) {
@@ -595,12 +600,16 @@ export class ChatComposerCapabilityHost {
const toolsEffectiveError =
effectiveToolsKey !== null && this.effectiveToolsErrorKey === effectiveToolsKey;
const capabilitiesReady = gatewayAvailable && session !== undefined && runtimeConfig !== null;
const toolPatchAccess = readSessionMethodAccess(context.gateway.snapshot, {
method: "sessions.patch",
params: { key: state.sessionKey, toolOverrides: null },
});
const mutationBlockedReason = !gatewayAvailable
? t("chat.composer.menu.offlineBlocked")
: !capabilitiesReady
? t("common.loading")
: !access.canWrite
? t("chat.composer.menu.readOnlyBlocked")
: !toolPatchAccess.allowed
? toolPatchAccess.reason
: this.patchTokens.has(state.sessionKey)
? t("chat.composer.menu.savingBlocked")
: null;
+23
View File
@@ -221,6 +221,29 @@ describe("renderChatComposer controls", () => {
expect(onAbort).toHaveBeenCalledOnce();
});
it("disables an archived-session action with its mutation reason", () => {
const onAction = vi.fn();
const reason = "Operator write access is required.";
const { container } = renderComposer({
canSend: false,
disabledBanner: {
kind: "composer-replacement",
text: "This session is archived. Unarchive it to continue the conversation.",
actionLabel: "Unarchive",
disabledReason: reason,
onAction,
},
});
const action = container.querySelector<HTMLButtonElement>(
".agent-chat__disabled-banner button",
);
expect(action?.disabled).toBe(true);
expect(action?.title).toBe(reason);
action?.click();
expect(onAction).not.toHaveBeenCalled();
});
it("keeps the disabled composer mounted for a catalog read-only state", () => {
const { container } = renderComposer({
canSend: false,
+30 -4
View File
@@ -9,7 +9,7 @@ import type {
} from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import { hasOperatorWriteAccess, hasOperatorAdminAccess } from "../../app/operator-access.ts";
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
import { icons } from "../../components/icons.ts";
import { listSessionCreators } from "../../components/session-owner-chip.ts";
import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts";
@@ -122,6 +122,18 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
method: "session.members.remove",
requiredScope: "operator.write",
});
const renameAccess = row
? readSessionMethodAccess(this.context.gateway.snapshot, {
method: "sessions.patch",
params: { key: row.key, label: null },
})
: null;
const renameDisabledReason =
this.state?.connected !== true || !renameAccess
? t("sessionsView.actionRequiresConnection")
: renameAccess.allowed
? undefined
: renameAccess.reason;
return renderChatPaneHeader({
paneId: this.paneId,
narrow: this.narrow,
@@ -148,9 +160,7 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
platform: this.headerPlatform,
canReveal,
copiedAction: this.headerCopiedAction,
canRename:
this.state?.connected === true &&
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
renameDisabledReason,
terminalAction: renderCatalogTerminalButton(this.state, this.catalogSession),
discussionAction: this.renderSessionDiscussionAction(),
diffAction: renderSessionDiffToggle(sessionWorkspace),
@@ -260,6 +270,14 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
}
protected beginHeaderRename(row: GatewaySessionRow): void {
const access = readSessionMethodAccess(this.context.gateway.snapshot, {
method: "sessions.patch",
params: { key: row.key, label: null },
});
if (!access.allowed) {
this.publishHeaderError(access.reason);
return;
}
const customLabel = row.label?.trim() || null;
this.headerRenameSessionKey = row.key;
this.headerRenameInitialLabel = customLabel;
@@ -294,6 +312,14 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
if (!key || !state || unchangedDerivedTitle || unchangedLabel) {
return;
}
const access = readSessionMethodAccess(this.context.gateway.snapshot, {
method: "sessions.patch",
params: { key, label },
});
if (!access.allowed) {
this.publishHeaderError(access.reason);
return;
}
void patchChatSessionLabel(state, this.context.sessions, key, label).catch((error: unknown) =>
this.publishHeaderError(error),
);
+27 -4
View File
@@ -16,6 +16,7 @@ import {
projectSessionObserverDigest,
resolveChatPaneObserverRunId,
} from "../../lib/observer-digest.ts";
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
import { buildAgentMainSessionKey } from "../../lib/sessions/session-key.ts";
import { renderBoardSessionSurface } from "./board-session-surface.ts";
import { clearChatHistory } from "./chat-history.ts";
@@ -79,6 +80,14 @@ export class ChatPane extends ChatPaneHeader {
return html`<main class="app-shell app-shell--booting" aria-busy="true"></main>`;
}
const selectedSession = selectedChatSessionRow(state);
const runtimePatchAccess = readSessionMethodAccess(this.context.gateway.snapshot, {
method: "sessions.patch",
params: { key: state.sessionKey, model: null },
});
const unarchiveAccess = readSessionMethodAccess(this.context.gateway.snapshot, {
method: "sessions.patch",
params: { key: state.sessionKey, archived: false },
});
const projectedObserverDigest = projectSessionObserverDigest(
selectedSession?.key ?? state.sessionKey,
selectedSession?.observerDigest,
@@ -336,7 +345,12 @@ export class ChatPane extends ChatPaneHeader {
kind: "composer-replacement",
text: t("chat.archivedSessionDisabled"),
actionLabel: t("common.unarchive"),
onAction: () => void this.restoreArchivedSession(state.sessionKey),
disabledReason: unarchiveAccess.allowed ? undefined : unarchiveAccess.reason,
onAction: () => {
if (unarchiveAccess.allowed) {
void this.restoreArchivedSession(state.sessionKey);
}
},
}
: modelSetupRequired
? createChatModelSetupBanner(() => this.context.navigate("model-setup"))
@@ -395,17 +409,26 @@ export class ChatPane extends ChatPaneHeader {
modelSelectionRuntimeId: selectedSession?.agentRuntime?.id,
modelSwitching: Boolean(state.chatModelSwitchPromises[state.sessionKey]),
modelsLoading: state.chatModelsLoading,
mutationDisabledReason: runtimePatchAccess.allowed
? undefined
: runtimePatchAccess.reason,
sending: state.chatSending,
sessionKey: state.sessionKey,
sessionsResult: state.sessionsResult,
stream: state.chatStream,
onRequestUpdate: () => state.requestUpdate?.(),
onFastModeSelect: (next, targetSessionKey) =>
switchChatFastMode(state, next, targetSessionKey),
runtimePatchAccess.allowed
? switchChatFastMode(state, next, targetSessionKey)
: Promise.resolve(false),
onModelSelect: (next, targetSessionKey) =>
switchChatModel(state, next, targetSessionKey),
runtimePatchAccess.allowed
? switchChatModel(state, next, targetSessionKey)
: Promise.resolve(false),
onThinkingSelect: (next, targetSessionKey) =>
switchChatThinkingLevel(state, next, targetSessionKey),
runtimePatchAccess.allowed
? switchChatThinkingLevel(state, next, targetSessionKey)
: Promise.resolve(false),
},
onboarding: state.onboarding,
settings: state.settings,
+11
View File
@@ -12,6 +12,7 @@ import { selectApplicationSession } from "../../app/agent-selection.ts";
import { clampText } from "../../lib/format.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
import {
scopedSessionPullRequestKey,
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
@@ -338,6 +339,16 @@ export abstract class ChatPaneSession extends ChatPaneSharing {
if (!scope || scope.state.sessionKey !== sessionKey) {
return;
}
const access = readSessionMethodAccess(scope.context.gateway.snapshot, {
method: "sessions.patch",
params: { key: sessionKey, archived: false },
});
if (!access.allowed) {
scope.state.lastError = access.reason;
scope.state.chatError = access.reason;
scope.state.requestUpdate?.();
return;
}
const agentId = parseAgentSessionKey(sessionKey)?.agentId ?? resolveChatAgentId(scope.state);
let failure: string | null = null;
try {
@@ -391,6 +391,53 @@ describe.each(mutations)("chat pane $name mutation connection ownership", (mutat
},
);
it.each(["resolve", "reject"] as const)(
"drops a stale same-key mutation when the replaced session later %s",
async (completion) => {
const response = createDeferred<unknown>();
const request = vi.fn((method: string) => {
if (method !== mutation.method) {
throw new Error(`unexpected request: ${method}`);
}
return response.promise;
});
const sessions = {
refreshReplacement: vi.fn(),
} as unknown as SessionCapability;
const { pane: testPane, state } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions,
});
const pane = testPane as SharingPane;
const stale = sessionRow();
const pending = mutation.invoke(pane, stale);
expect(request).toHaveBeenCalledWith(
mutation.method,
expect.objectContaining({ sessionKey: stale.key }),
);
const replacement = { ...stale, sessionId: "session-replacement" };
state.sessionsResult = sharingSessionsResult(replacement);
const cacheKey = pane.sessionSharingCacheKey(replacement.key);
const replacementState: ChatSessionSharingState = {
loading: false,
result: sharingResult(replacement),
};
pane.sessionSharingStates = new Map([[cacheKey, replacementState]]);
if (completion === "resolve") {
response.resolve({});
} else {
response.reject(new Error("stale mutation failed"));
}
await pending;
expect(request).toHaveBeenCalledTimes(1);
expect(sessions.refreshReplacement).not.toHaveBeenCalled();
expect(pane.sessionSharingStates.get(cacheKey)).toBe(replacementState);
},
);
it("preserves the current connection failure in the sharing cache", async () => {
const request = vi.fn(async (method: string) => {
if (method === mutation.method) {
+20 -5
View File
@@ -162,7 +162,10 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
}
try {
await scope.client.request("session.visibility.set", params);
if (!this.isConnectionScopeCurrent(scope)) {
if (
!this.isConnectionScopeCurrent(scope) ||
!this.currentSessionSharingRow(scope, currentRow)
) {
return;
}
await scope.sessions.refreshReplacement(agentId);
@@ -172,7 +175,10 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
}
await this.loadSessionSharing(refreshedRow, true);
} catch (error) {
if (!this.isConnectionScopeCurrent(scope)) {
if (
!this.isConnectionScopeCurrent(scope) ||
!this.currentSessionSharingRow(scope, currentRow)
) {
return;
}
this.setSessionSharingState(cacheKey, {
@@ -211,16 +217,25 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
}
try {
await scope.client.request(method, params);
if (!this.isConnectionScopeCurrent(scope)) {
if (
!this.isConnectionScopeCurrent(scope) ||
!this.currentSessionSharingRow(scope, currentRow)
) {
return;
}
await this.loadSessionSharing(currentRow, true);
if (!this.isConnectionScopeCurrent(scope)) {
if (
!this.isConnectionScopeCurrent(scope) ||
!this.currentSessionSharingRow(scope, currentRow)
) {
return;
}
await scope.sessions.refreshReplacement(agentId);
} catch (error) {
if (!this.isConnectionScopeCurrent(scope)) {
if (
!this.isConnectionScopeCurrent(scope) ||
!this.currentSessionSharingRow(scope, currentRow)
) {
return;
}
this.setSessionSharingState(cacheKey, {
@@ -36,6 +36,7 @@ export type TestChatPane = HTMLElement & {
connectedCallback: () => void;
connectionGeneration: number;
createSession: () => Promise<boolean>;
restoreArchivedSession: (sessionKey: string) => Promise<void>;
disconnectedCallback: () => void;
acceptTaskSuggestion: (suggestion: TaskSuggestion) => Promise<void>;
handleDocumentKeydown: (event: KeyboardEvent) => void;
+32
View File
@@ -5023,6 +5023,38 @@ describe("chat model controls", () => {
expect(onModelSelect).toHaveBeenCalledWith(modelOption?.dataset.chatModelOption, "main");
});
it("disables runtime overrides with the exact mutation reason", () => {
const { state } = createChatHeaderState({
model: "gpt-5.5",
modelProvider: "openai",
models: [
{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai" },
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" },
],
});
const onFastModeSelect = vi.fn(async () => true);
const onModelSelect = vi.fn(async () => true);
const onThinkingSelect = vi.fn(async () => true);
const reason = "Operator admin access is required.";
const container = renderModelControls(state, {
mutationDisabledReason: reason,
onFastModeSelect,
onModelSelect,
onThinkingSelect,
});
const modelSelect = getChatModelSelect(container);
expect(modelSelect.getAttribute("aria-disabled")).toBe("true");
expect(modelSelect.getAttribute("title")).toBe(reason);
modelSelect.click();
container.querySelector<HTMLButtonElement>("[data-chat-speed-toggle]")?.click();
getThinkingSlider(container)?.dispatchEvent(new Event("change", { bubbles: true }));
expect(onFastModeSelect).not.toHaveBeenCalled();
expect(onModelSelect).not.toHaveBeenCalled();
expect(onThinkingSelect).not.toHaveBeenCalled();
});
it("marks the inherited default muted and resets an override from the provenance row", () => {
const { state } = createChatHeaderState({
model: null,
@@ -24,6 +24,7 @@ import type {
type ChatComposerDisabledBannerContent = {
text: string;
actionLabel: string;
disabledReason?: string;
onAction: () => void;
};
@@ -115,7 +115,13 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
? html`
<div class="agent-chat__disabled-banner callout info callout--action" role="status">
<span class="callout__content">${props.disabledBanner.text}</span>
<button type="button" class="btn btn--xs" @click=${props.disabledBanner.onAction}>
<button
type="button"
class="btn btn--xs"
?disabled=${Boolean(props.disabledBanner.disabledReason)}
title=${props.disabledBanner.disabledReason ?? nothing}
@click=${props.disabledBanner.onAction}
>
${props.disabledBanner.actionLabel}
</button>
${props.disabledBanner.kind === "composer-replacement" && showAbortableUi
@@ -42,6 +42,7 @@ export type ChatModelControlsProps = {
modelSelectionRuntimeId?: string;
modelSwitching: boolean;
modelsLoading?: boolean;
mutationDisabledReason?: string;
showFastMode?: boolean;
sending: boolean;
sessionKey: string;
@@ -254,17 +255,20 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
busy ||
props.modelSwitching ||
(props.modelsLoading && selectOptions.length === 0) ||
!props.gatewayAvailable;
!props.gatewayAvailable ||
Boolean(props.mutationDisabledReason);
const thinkingDisabled =
!props.connected ||
busy ||
props.modelSwitching ||
!props.gatewayAvailable ||
(thinking.options.length === 0 && thinking.currentOverride === "");
(thinking.options.length === 0 && thinking.currentOverride === "") ||
Boolean(props.mutationDisabledReason);
return renderChatModelReasoningSelect({
defaultModelLabel: formatCombinedPickerModelLabel(pickerDefaultLabel),
disabled,
fastMode,
disabledReason: props.mutationDisabledReason,
fastMode: { ...fastMode, disabled: fastMode.disabled || disabled },
modelSelectionLocked: props.modelSelectionLocked === true,
modelOptions,
onRequestUpdate: props.onRequestUpdate,
@@ -369,6 +373,7 @@ function renderChatModelReasoningSelect(params: {
defaultModelLabel: string;
fastMode: ChatFastModeSelectState;
disabled: boolean;
disabledReason?: string;
modelSelectionLocked: boolean;
modelOptions: ChatModelProviderOption[];
selectedModelValue: string;
@@ -388,6 +393,7 @@ function renderChatModelReasoningSelect(params: {
const {
defaultModelLabel,
disabled,
disabledReason,
fastMode,
modelSelectionLocked,
modelOptions,
@@ -641,6 +647,7 @@ function renderChatModelReasoningSelect(params: {
"chat.selectors.thinkingLevel",
)}: ${triggerTitle}"
aria-disabled=${disabled ? "true" : "false"}
title=${disabledReason ?? triggerTitle}
@click=${(event: MouseEvent) => {
if (disabled) {
event.preventDefault();
@@ -85,7 +85,7 @@ function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
platform: "darwin",
canReveal: true,
copiedAction: null,
canRename: true,
renameDisabledReason: undefined,
terminalAction: nothing,
discussionAction: nothing,
diffAction: nothing,
@@ -330,11 +330,14 @@ describe("chat pane header", () => {
});
it("keeps read-only gateway session titles static", () => {
const { container } = mount({ canRename: false });
const { container } = mount({ renameDisabledReason: "Operator write access is required." });
expect(container.querySelector(".chat-pane__session-title-button")).toBeNull();
expect(container.querySelector(".chat-pane__session-title")?.textContent).toContain(
"Session title",
);
expect(container.querySelector(".chat-pane__session-title")?.getAttribute("title")).toBe(
"Operator write access is required.",
);
});
it("shows copied feedback on the workspace chip", () => {
@@ -43,7 +43,7 @@ type ChatPaneHeaderProps = {
platform: string | null;
canReveal: boolean;
copiedAction: ChatPaneHeaderAction | null;
canRename: boolean;
renameDisabledReason?: string;
terminalAction: TemplateResult | typeof nothing;
discussionAction: TemplateResult | typeof nothing;
diffAction: TemplateResult | typeof nothing;
@@ -299,8 +299,12 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
}}
@blur=${props.onCommitRename}
/>`
: props.catalog || !props.session || !props.canRename
? html`<span class="chat-pane__session-title" title=${props.title}>${props.title}</span>`
: props.catalog || !props.session || props.renameDisabledReason
? html`<span
class="chat-pane__session-title"
title=${props.renameDisabledReason ?? props.title}
>${props.title}</span
>`
: html`<button
class="chat-pane__session-title chat-pane__session-title-button"
type="button"