From 83f006a11de7ff647796d4df64ebee4fdac126ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 22:06:43 +0100 Subject: [PATCH] fix(workboard): skip read-only lifecycle writes --- ui/src/ui/app-render.ts | 6 +++++- ui/src/ui/app-settings.ts | 13 +++++++++++++ ui/src/ui/controllers/workboard.test.ts | 20 ++++++++++++++++++++ ui/src/ui/controllers/workboard.ts | 3 ++- ui/src/ui/views/workboard.ts | 2 ++ 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index e10e830ed61..26ffa9d18b5 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -22,7 +22,7 @@ import { dismissChatError, switchChatSession, } from "./app-render.helpers.ts"; -import { warnQueryToken } from "./app-settings.ts"; +import { hasOperatorWriteAccess, warnQueryToken } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts"; import { @@ -2203,6 +2203,10 @@ export function renderApp(state: AppViewState) { host: state, client: state.client, connected: state.connected, + canWrite: hasOperatorWriteAccess( + (state.hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ?? + null, + ), pluginEnabled: isPluginEnabledInConfigSnapshot(state.configSnapshot, "workboard", { enabledByDefault: false, }), diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index a759e1d2cc1..f3631331379 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -795,6 +795,19 @@ export function hasOperatorReadAccess( }); } +export function hasOperatorWriteAccess( + auth: { role?: string; scopes?: readonly string[] } | null, +): boolean { + if (!auth?.scopes) { + return true; + } + return roleScopesAllow({ + role: auth.role ?? "operator", + requestedScopes: ["operator.write"], + allowedScopes: auth.scopes, + }); +} + export function hasMissingSkillDependencies( missing: Record | null | undefined, ): boolean { diff --git a/ui/src/ui/controllers/workboard.test.ts b/ui/src/ui/controllers/workboard.test.ts index 151c1487f98..a98ff8fca18 100644 --- a/ui/src/ui/controllers/workboard.test.ts +++ b/ui/src/ui/controllers/workboard.test.ts @@ -458,6 +458,26 @@ describe("workboard controller", () => { expect(state.cards.find((card) => card.id === "card-review")?.status).toBe("review"); }); + it("skips lifecycle writeback for read-only workboard clients", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [{ ...sampleCard, sessionKey: sampleSession.key }]; + const client = createClient(() => { + throw new Error("write denied"); + }); + + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [sampleSession], + canWrite: false, + }); + + expect(client.request).not.toHaveBeenCalled(); + expect(state.error).toBeNull(); + }); + it("resyncs cards manually moved back to an active lifecycle column", async () => { const host = {}; const state = getWorkboardState(host); diff --git a/ui/src/ui/controllers/workboard.ts b/ui/src/ui/controllers/workboard.ts index 265ce43e212..408fc3fe8a3 100644 --- a/ui/src/ui/controllers/workboard.ts +++ b/ui/src/ui/controllers/workboard.ts @@ -536,10 +536,11 @@ export async function syncWorkboardLifecycle(params: { host: WorkboardHost; client: GatewayBrowserClient | null; sessions: readonly GatewaySessionRow[]; + canWrite?: boolean; requestUpdate?: () => void; }) { const state = getWorkboardState(params.host); - if (!params.client || !state.loaded) { + if (!params.client || !state.loaded || params.canWrite === false) { return; } const syncKeys = getLifecycleSyncKeys(params.host); diff --git a/ui/src/ui/views/workboard.ts b/ui/src/ui/views/workboard.ts index 1d191b7eca8..db96cc33f14 100644 --- a/ui/src/ui/views/workboard.ts +++ b/ui/src/ui/views/workboard.ts @@ -26,6 +26,7 @@ type WorkboardProps = { host: object; client: GatewayBrowserClient | null; connected: boolean; + canWrite?: boolean; pluginEnabled: boolean; agentsList: AgentsListResult | null; sessions: GatewaySessionRow[]; @@ -729,6 +730,7 @@ export function renderWorkboard(props: WorkboardProps) { host: props.host, client: props.client, sessions: props.sessions, + canWrite: props.canWrite, requestUpdate: props.onRequestUpdate, }); }