From f44eb4419f019f2a00f7a65eaf33c7a42f80ccf3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 4 Aug 2026 10:53:48 +0100 Subject: [PATCH] fix: gate agent management actions --- ui/src/pages/agents/agents-page.test.ts | 1 + ui/src/pages/agents/agents-page.ts | 74 +++++++++++++++++-- .../pages/agents/agents-view.test-helpers.ts | 7 ++ ui/src/pages/agents/memory/dreaming.test.ts | 13 ++++ ui/src/pages/agents/memory/dreaming.ts | 28 ++++++- ui/src/pages/agents/memory/memory-panel.ts | 50 ++++++++++++- .../pages/agents/memory/view.browser.test.ts | 8 ++ ui/src/pages/agents/memory/view.test.ts | 8 ++ ui/src/pages/agents/memory/view.ts | 49 +++++++++--- ui/src/pages/agents/panels-overview.ts | 8 +- ui/src/pages/agents/panels-status-files.ts | 9 ++- .../panels-tools-skills.browser.test.ts | 2 + ui/src/pages/agents/panels-tools-skills.ts | 13 +++- ui/src/pages/agents/view.test.ts | 37 ++++++++++ ui/src/pages/agents/view.ts | 18 ++++- 15 files changed, 293 insertions(+), 32 deletions(-) diff --git a/ui/src/pages/agents/agents-page.test.ts b/ui/src/pages/agents/agents-page.test.ts index a3497efe4baf..9bb075e08884 100644 --- a/ui/src/pages/agents/agents-page.test.ts +++ b/ui/src/pages/agents/agents-page.test.ts @@ -184,6 +184,7 @@ describe("AgentsPage gateway lifecycle", () => { setPageGateway(page, client, false); page.agentsSelectedId = "main"; page.context = { + gateway: gateway(snapshot(client)), agents: { state: { agentsLoading: false, agentsError: null, agentsList }, refreshList, diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts index 1e0f92d879b1..b578e0a50dc2 100644 --- a/ui/src/pages/agents/agents-page.ts +++ b/ui/src/pages/agents/agents-page.ts @@ -37,6 +37,10 @@ import { runCronJob, type CronState, } from "../../lib/cron/index.ts"; +import { + canCallGatewayMethod, + type GatewayMethodOperatorScope, +} from "../../lib/gateway-methods.ts"; import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { normalizeStringEntries } from "../../lib/string-coerce.ts"; import { GatewayPageController } from "../../lit/gateway-page-controller.ts"; @@ -291,6 +295,10 @@ class AgentsPage } } + private canCall(method: string, requiredScope: GatewayMethodOperatorScope): boolean { + return canCallGatewayMethod(this.context?.gateway?.snapshot, method, requiredScope); + } + private syncAgentState(agents = this.context.agents) { const agentState = agents.state; this.agentsList = agentState.agentsList ? selectableAgentsList(agentState.agentsList) : null; @@ -668,6 +676,9 @@ class AgentsPage } private saveIdentityDraft() { + if (!this.canCall("agents.update", "operator.admin")) { + return; + } const client = this.client; const agentId = this.resolveSelectedAgentId(); if (!client || !agentId || this.identitySaving) { @@ -757,6 +768,9 @@ class AgentsPage } private saveAgentConfig() { + if (!this.canCall("config.set", "operator.admin")) { + return; + } const client = this.client; const generation = this.requestGeneration; const agents = this.context.agents; @@ -782,6 +796,9 @@ class AgentsPage } private saveSelectedAgentFile(agentId: string, name: string, content: string) { + if (!this.canCall("agents.files.set", "operator.admin")) { + return; + } const client = this.client; const generation = this.requestGeneration; const agents = this.context.agents; @@ -800,6 +817,9 @@ class AgentsPage } private runCronJobNow(jobId: string) { + if (!this.canCall("cron.run", "operator.admin")) { + return; + } if (!this.cron.cronJobs.some((entry) => entry.id === jobId)) { return; } @@ -811,6 +831,13 @@ class AgentsPage const agentsState = this.context.agents.state; const selectedAgentId = this.resolveSelectedAgentId(); const config = currentConfigObject(configState); + const access = { + canCreateAgent: this.canCall("openclaw.chat", "operator.admin"), + canUpdateConfig: this.canCall("config.set", "operator.admin"), + canUpdateIdentity: this.canCall("agents.update", "operator.admin"), + canWriteFiles: this.canCall("agents.files.set", "operator.admin"), + canRunCron: this.canCall("cron.run", "operator.admin"), + }; return html`
@@ -822,6 +849,7 @@ class AgentsPage
${renderSettingsWorkspace( renderAgents({ + access, basePath: this.context.basePath, authToken: this.controlUiAuthToken(), loading: agentsState.agentsLoading, @@ -898,7 +926,11 @@ class AgentsPage onRefresh: () => this.refreshAgents(), onSelectAgent: (agentId) => navigateToAgent(this.context, agentId, selectedAgentId, this.agentsPanel), - onCreateAgent: () => this.context.navigate("custodian", { search: "?intent=new-agent" }), + onCreateAgent: () => { + if (this.canCall("openclaw.chat", "operator.admin")) { + this.context.navigate("custodian", { search: "?intent=new-agent" }); + } + }, onSelectPanel: (panel) => navigateToAgentPanel(this.context, selectedAgentId, this.agentsPanel, panel), onLoadFiles: (agentId) => void this.loadAgentFiles(agentId, true), @@ -927,6 +959,9 @@ class AgentsPage } }, onToolsProfileChange: (agentId, profile, clearAllow) => { + if (!this.canCall("config.set", "operator.admin")) { + return; + } const path = this.toolsPath(agentId, Boolean(profile || clearAllow)); if (!path) { return; @@ -941,6 +976,9 @@ class AgentsPage } }, onToolsOverridesChange: (agentId, alsoAllow, deny) => { + if (!this.canCall("config.set", "operator.admin")) { + return; + } const path = this.toolsPath(agentId, alsoAllow.length > 0 || deny.length > 0); if (!path) { return; @@ -958,8 +996,16 @@ class AgentsPage }, onConfigReload: () => this.reloadConfig(), onConfigSave: () => this.saveAgentConfig(), - onIdentityFieldChange: (field, value) => setIdentityDraftField(this, field, value), - onIdentityAvatarSelect: (file) => selectIdentityAvatar(this, file), + onIdentityFieldChange: (field, value) => { + if (this.canCall("agents.update", "operator.admin")) { + setIdentityDraftField(this, field, value); + } + }, + onIdentityAvatarSelect: (file) => { + if (this.canCall("agents.update", "operator.admin")) { + selectIdentityAvatar(this, file); + } + }, onIdentitySave: () => this.saveIdentityDraft(), onChannelsRefresh: () => void this.context.channels.refresh(false), onOpenMemoryImport: () => this.context.navigate("memory-import"), @@ -978,6 +1024,9 @@ class AgentsPage } }, onAgentSkillToggle: (agentId, skillName, enabled) => { + if (!this.canCall("config.set", "operator.admin")) { + return; + } const target = this.context.runtimeConfig.agentEntry(agentId, { ensure: true }); if (!target || !skillName.trim()) { return; @@ -994,25 +1043,40 @@ class AgentsPage this.context.runtimeConfig.patchForm([...target.path, "skills"], [...next]); }, onAgentSkillsClear: (agentId) => { + if (!this.canCall("config.set", "operator.admin")) { + return; + } const target = this.context.runtimeConfig.agentEntry(agentId); if (target) { this.context.runtimeConfig.removeFormValue([...target.path, "skills"]); } }, onAgentSkillsDisableAll: (agentId) => { + if (!this.canCall("config.set", "operator.admin")) { + return; + } const target = this.context.runtimeConfig.agentEntry(agentId, { ensure: true }); if (target) { this.context.runtimeConfig.patchForm([...target.path, "skills"], []); } }, onModelChange: (agentId, modelId) => { + if (!this.canCall("config.set", "operator.admin")) { + return; + } stageAgentPrimaryModel(this.context.runtimeConfig, agentId, modelId); void refreshVisibleToolsEffectiveForCurrentSession(this); }, onModelCatalogRetry: () => this.ensureModelCatalog(), - onModelFallbacksChange: (agentId, fallbacks) => - stageAgentModelFallbacks(this.context.runtimeConfig, agentId, fallbacks), + onModelFallbacksChange: (agentId, fallbacks) => { + if (this.canCall("config.set", "operator.admin")) { + stageAgentModelFallbacks(this.context.runtimeConfig, agentId, fallbacks); + } + }, onSetDefault: (agentId) => { + if (!this.canCall("config.set", "operator.admin")) { + return; + } void (async () => { await this.context.runtimeConfig.ensureLoaded(); await setDefaultAgent(this.context.runtimeConfig, agentId, () => diff --git a/ui/src/pages/agents/agents-view.test-helpers.ts b/ui/src/pages/agents/agents-view.test-helpers.ts index feae76e659cd..a687785113bd 100644 --- a/ui/src/pages/agents/agents-view.test-helpers.ts +++ b/ui/src/pages/agents/agents-view.test-helpers.ts @@ -6,6 +6,13 @@ export function createAgentViewTestProps( overrides: Partial = {}, ): AgentsViewProps { return { + access: { + canCreateAgent: true, + canUpdateConfig: true, + canUpdateIdentity: true, + canWriteFiles: true, + canRunCron: true, + }, basePath: "", authToken: null, loading: false, diff --git a/ui/src/pages/agents/memory/dreaming.test.ts b/ui/src/pages/agents/memory/dreaming.test.ts index 97fe3979032a..6a85b91205b1 100644 --- a/ui/src/pages/agents/memory/dreaming.test.ts +++ b/ui/src/pages/agents/memory/dreaming.test.ts @@ -1298,6 +1298,19 @@ describe("dreaming controller", () => { expect(state.dreamDiaryActionLoading).toBe(false); }); + it("does not run a write action with read-only operator access", async () => { + const { state, request } = createState(); + state.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: ["operator.read"] }, + features: { methods: ["doctor.memory.backfillDreamDiary"] }, + }; + + await expect(backfillDreamDiary(state)).resolves.toBe(false); + expect(request).not.toHaveBeenCalled(); + }); + it("runs dream diary actions and reloads state for the selected agent", async () => { const { state, request } = createState(); state.selectedAgentId = "fishing-bot"; diff --git a/ui/src/pages/agents/memory/dreaming.ts b/ui/src/pages/agents/memory/dreaming.ts index 7b4694f5008b..778ba1edb39a 100644 --- a/ui/src/pages/agents/memory/dreaming.ts +++ b/ui/src/pages/agents/memory/dreaming.ts @@ -10,7 +10,11 @@ import type { ConfigSnapshot } from "../../../api/types.ts"; import { t } from "../../../i18n/index.ts"; import { copyToClipboard } from "../../../lib/clipboard.ts"; import type { RuntimeConfigCapability } from "../../../lib/config/index.ts"; -import { isGatewayMethodAdvertised } from "../../../lib/gateway-methods.ts"; +import { + canCallGatewayMethod, + isGatewayMethodAdvertised, + type GatewayMethodOperatorScope, +} from "../../../lib/gateway-methods.ts"; import { isPluginEnabledInConfigSnapshot } from "../../../lib/plugin-activation.ts"; const MEMORY_WIKI_PLUGIN_ID = "memory-wiki"; @@ -189,6 +193,24 @@ function canCallMemoryWikiMethod(state: DreamingState, method: string): boolean return isMemoryWikiEnabled(state); } +export function canCallDreamingMethod( + state: DreamingState, + method: string, + requiredScope: GatewayMethodOperatorScope, + options?: { requireAdvertisement?: boolean }, +): boolean { + return canCallGatewayMethod( + { + client: state.client, + hello: state.hello, + phase: state.connected ? "connected" : "offline", + }, + method, + requiredScope, + options, + ); +} + function buildDreamDiaryActionSuccessMessage( method: | "doctor.memory.backfillDreamDiary" @@ -456,7 +478,7 @@ async function runDreamDiaryAction( reloadDiary?: boolean; }, ): Promise { - if (!state.client || !state.connected || state.dreamDiaryActionLoading) { + if (!canCallDreamingMethod(state, method, "operator.write") || state.dreamDiaryActionLoading) { return false; } state.dreamDiaryActionLoading = true; @@ -542,7 +564,7 @@ async function writeDreamingPatch( config: DreamingConfigCapability, patch: Record, ): Promise { - if (state.dreamingModeSaving) { + if (state.dreamingModeSaving || !canCallDreamingMethod(state, "config.patch", "operator.admin")) { return false; } diff --git a/ui/src/pages/agents/memory/memory-panel.ts b/ui/src/pages/agents/memory/memory-panel.ts index 0e1ed9f31584..aef74d7e5f81 100644 --- a/ui/src/pages/agents/memory/memory-panel.ts +++ b/ui/src/pages/agents/memory/memory-panel.ts @@ -20,6 +20,7 @@ import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../../lit/subscriptions-controller.ts"; import { backfillDreamDiary, + canCallDreamingMethod, copyDreamingArchivePath, createDreamingState, dedupeDreamDiary, @@ -312,6 +313,7 @@ class AgentMemoryPanel extends OpenClawLightDomElement { private setEnabled(enabled: boolean, dreamingOn: boolean) { if ( + !canCallDreamingMethod(this.dreaming, "config.patch", "operator.admin") || this.dreaming.dreamingModeSaving || this.toggleConfirmLoading || this.toggleConfirmOpen || @@ -335,7 +337,11 @@ class AgentMemoryPanel extends OpenClawLightDomElement { private async confirmToggle() { const enabled = this.pendingEnabled; - if (enabled == null || this.toggleConfirmLoading) { + if ( + enabled == null || + this.toggleConfirmLoading || + !canCallDreamingMethod(this.dreaming, "config.patch", "operator.admin") + ) { return; } this.toggleConfirmLoading = true; @@ -408,7 +414,12 @@ class AgentMemoryPanel extends OpenClawLightDomElement { } private async resetEnabledOverride(configured: ReturnType) { - if (!configured.overridden || this.dreaming.dreamingModeSaving || this.toggleConfirmOpen) { + if ( + !configured.overridden || + this.dreaming.dreamingModeSaving || + this.toggleConfirmOpen || + !canCallDreamingMethod(this.dreaming, "config.patch", "operator.admin") + ) { return; } this.dreaming.dreamingStatusError = null; @@ -478,10 +489,11 @@ class AgentMemoryPanel extends OpenClawLightDomElement { const dreamingStatus = configuredDreaming.engineOff ? null : dreaming.dreamingStatus; const dreamingOn = dreamingStatus?.enabled ?? configuredDreaming.enabled; const loading = dreaming.dreamingStatusLoading || dreaming.dreamingModeSaving; + const canUpdateConfig = canCallDreamingMethod(dreaming, "config.patch", "operator.admin"); const defaultState = renderSettingsDefaultState({ value: t("common.enabled"), overridden: configuredDreaming.overridden, - disabled: loading, + disabled: loading || !canUpdateConfig, onReset: () => void this.resetEnabledOverride(configuredDreaming), }); const refreshLoading = dreaming.dreamingStatusLoading || dreaming.dreamDiaryLoading; @@ -506,7 +518,7 @@ class AgentMemoryPanel extends OpenClawLightDomElement { ${defaultState.action} diff --git a/ui/src/pages/agents/panels-overview.ts b/ui/src/pages/agents/panels-overview.ts index e6476bb74bd5..a838c3b1f83e 100644 --- a/ui/src/pages/agents/panels-overview.ts +++ b/ui/src/pages/agents/panels-overview.ts @@ -43,6 +43,8 @@ export function renderAgentOverview(params: { identityDraft: AgentIdentityDraft; identitySaving: boolean; identityError: string | null; + canUpdateConfig: boolean; + canUpdateIdentity: boolean; configLoading: boolean; configSaving: boolean; configDirty: boolean; @@ -102,7 +104,7 @@ export function renderAgentOverview(params: { const fallbackChips = modelFallbacks ?? []; const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; const skillCount = skillFilter?.length ?? null; - const disabled = !configForm || configLoading || configSaving; + const disabled = !params.canUpdateConfig || !configForm || configLoading || configSaving; const thinkingDefault = agent.thinkingDefault ?? "-"; const identityDraft = params.identityDraft; @@ -119,7 +121,7 @@ export function renderAgentOverview(params: { const identityInvalid = (identityDraft.name !== null && !identityDraft.name.trim()) || (identityDraft.emoji !== null && !identityDraft.emoji.trim()); - const identityBusy = params.identitySaving; + const identityBusy = params.identitySaving || !params.canUpdateIdentity; const handleAvatarFileSelect = (e: Event) => { const input = e.target as HTMLInputElement; @@ -270,7 +272,7 @@ export function renderAgentOverview(params: {