fix: enforce chat session creation access

This commit is contained in:
Shakker
2026-08-02 13:40:53 +01:00
parent a737e95234
commit 31f64aeae0
4 changed files with 111 additions and 6 deletions
+27
View File
@@ -302,6 +302,33 @@ describe("chat pane board shell", () => {
expect(sessions.create).not.toHaveBeenCalled();
});
it("rechecks reset scope after board confirmation", async () => {
const reset = vi.fn(async () => "completed" as const);
const sessions = {
create: vi.fn(async () => "agent:main:new"),
reset,
} as unknown as SessionCapability;
const pane = createTestPane(sessions);
pane.context.gateway.snapshot.hello = {
auth: { role: "operator", scopes: ["operator.admin"] },
features: { methods: ["sessions.reset"] },
} as ApplicationContext["gateway"]["snapshot"]["hello"];
pane.boardProvider = mockBoardProvider("agent:main:current");
const pending = pane.createSession();
await Promise.resolve();
pane.context.gateway.snapshot.hello = {
auth: { role: "operator", scopes: ["operator.write"] },
features: { methods: ["sessions.reset"] },
} as ApplicationContext["gateway"]["snapshot"]["hello"];
pane.settleResetConfirmation(true);
await expect(pending).resolves.toBe(false);
expect(reset).not.toHaveBeenCalled();
expect(pane.state.lastError).toContain("operator.admin");
expect(pane.state.chatError).toBe(pane.state.lastError);
});
it("does not reset when a run starts during confirmation", async () => {
const reset = vi.fn(async () => "completed" as const);
const sessions = {
+34 -6
View File
@@ -19,8 +19,10 @@ import {
import { t } from "../../i18n/index.ts";
import { resolveAsciiShortcutKey } from "../../lib/keyboard-shortcuts.ts";
import { resolveChatPaneObserverRunId } from "../../lib/observer-digest.ts";
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
import { sessionPullRequestsForGateway } from "../../lib/session-pull-requests.ts";
import { parseCatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
import { resolveSessionCreateParams } from "../../lib/sessions/create.ts";
import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
import {
areUiSessionKeysEquivalent,
@@ -172,6 +174,27 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
const client = state.client;
const previousSessionKey = state.sessionKey;
const preservesBoard = this.resolveBoardView().hasBoard;
const createParams = {
currentSessionKey: previousSessionKey,
agentId:
scopedAgentParamsForSession(state, previousSessionKey).agentId ??
resolveAgentIdFromSessionKey(previousSessionKey),
};
const createRequestParams = {
...resolveSessionCreateParams(createParams.currentSessionKey, createParams.agentId),
};
const readCreateAccess = () =>
readSessionMethodAccess(context.gateway.snapshot, {
method: preservesBoard ? "sessions.reset" : "sessions.create",
...(preservesBoard
? { requiredScope: "operator.admin" as const }
: { params: createRequestParams }),
});
const publishCreateAccessError = (reason: string) => {
state.lastError = reason;
state.chatError = reason;
state.requestUpdate?.();
};
const connectionGeneration = this.connectionGeneration;
const isCurrent = () =>
this.isConnected &&
@@ -196,6 +219,11 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
state.requestUpdate?.();
return false;
}
const initialAccess = readCreateAccess();
if (!initialAccess.allowed) {
publishCreateAccessError(initialAccess.reason);
return false;
}
if (
!(await this.confirmConversationReset()) ||
!isCurrent() ||
@@ -209,6 +237,11 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
state.requestUpdate?.();
return false;
}
const currentAccess = readCreateAccess();
if (!currentAccess.allowed) {
publishCreateAccessError(currentAccess.reason);
return false;
}
state.lastError = null;
state.chatError = null;
@@ -232,12 +265,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
}
return resetResult !== "failed";
}
const nextSessionKey = await sessions.create({
currentSessionKey: previousSessionKey,
agentId:
scopedAgentParamsForSession(state, previousSessionKey).agentId ??
resolveAgentIdFromSessionKey(previousSessionKey),
});
const nextSessionKey = await sessions.create(createParams);
if (!isCurrent()) {
return false;
}
@@ -11,6 +11,43 @@ import { createBackgroundTasksProps } from "./components/chat-background-tasks.t
import { createSessionWorkspaceProps } from "./components/chat-session-workspace.ts";
describe("chat pane session access", () => {
it("refuses ordinary session creation without operator.write", async () => {
const sessions = {
create: vi.fn(async () => "agent:main:new"),
} as unknown as SessionCapability;
const client = {} as GatewayBrowserClient;
const { pane, state } = createTestChatPane({ client, sessions });
pane.context.gateway.snapshot.hello = {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.create"] },
} as ApplicationContext["gateway"]["snapshot"]["hello"];
await expect(pane.createSession()).resolves.toBe(false);
expect(sessions.create).not.toHaveBeenCalled();
expect(state.lastError).toContain("operator.write");
expect(state.chatError).toBe(state.lastError);
});
it("requires operator.admin when session creation inherits an incognito parent", async () => {
const sessions = {
create: vi.fn(async () => "agent:main:new"),
} as unknown as SessionCapability;
const client = {} as GatewayBrowserClient;
const { pane, state } = createTestChatPane({ client, sessions });
state.sessionKey = "agent:main:dashboard:incognito-current";
pane.context.gateway.snapshot.hello = {
auth: { role: "operator", scopes: ["operator.write"] },
features: { methods: ["sessions.create"] },
} as ApplicationContext["gateway"]["snapshot"]["hello"];
await expect(pane.createSession()).resolves.toBe(false);
expect(sessions.create).not.toHaveBeenCalled();
expect(state.lastError).toContain("operator.admin");
expect(state.chatError).toBe(state.lastError);
});
it("refuses header rename when sessions.patch is unavailable or lacks write scope", () => {
for (const hello of [
{
+13
View File
@@ -536,6 +536,13 @@ describe("chat pane keyboard shortcuts", () => {
});
describe("chat pane session creation lifecycle", () => {
function advertiseSessionCreate(pane: TestChatPane) {
pane.context.gateway.snapshot.hello = {
auth: { role: "operator", scopes: ["operator.write"] },
features: { methods: ["sessions.create"] },
} as typeof pane.context.gateway.snapshot.hello;
}
it("drops a created session after a same-client reconnect", async () => {
const created = createDeferred<string | null>();
const sessions = {
@@ -545,8 +552,10 @@ describe("chat pane session creation lifecycle", () => {
const { pane, state } = createTestChatPane({ client, sessions });
const navigate = vi.fn();
pane.onPaneSessionChange = navigate;
advertiseSessionCreate(pane);
const pending = pane.createSession();
await vi.waitFor(() => expect(sessions.create).toHaveBeenCalledOnce());
state.connected = false;
pane.connectionGeneration += 1;
state.connectionEpoch = pane.connectionGeneration;
@@ -567,8 +576,10 @@ describe("chat pane session creation lifecycle", () => {
const client = {} as GatewayBrowserClient;
const { pane, requestUpdate, state } = createTestChatPane({ client, sessions });
const replacementSessions = {} as SessionCapability;
advertiseSessionCreate(pane);
const pending = pane.createSession();
await vi.waitFor(() => expect(sessions.create).toHaveBeenCalledOnce());
state.sessionsError = "stale sessions.create failure";
pane.context = createSessionContext(client, replacementSessions);
created.resolve(null);
@@ -586,8 +597,10 @@ describe("chat pane session creation lifecycle", () => {
} as unknown as SessionCapability;
const client = {} as GatewayBrowserClient;
const { pane, requestUpdate, state } = createTestChatPane({ client, sessions });
advertiseSessionCreate(pane);
const pending = pane.createSession();
await vi.waitFor(() => expect(sessions.create).toHaveBeenCalledOnce());
state.sessionsError = "stale sessions.create failure";
Object.defineProperty(pane, "isConnected", {
configurable: true,