fix: gate agent management actions

This commit is contained in:
Shakker
2026-08-04 10:53:48 +01:00
committed by Shakker
parent c9c6edc2c1
commit f44eb4419f
15 changed files with 293 additions and 32 deletions
+1
View File
@@ -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,
+69 -5
View File
@@ -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`
<section class="content-header">
<div>
@@ -822,6 +849,7 @@ class AgentsPage
</section>
${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, () =>
@@ -6,6 +6,13 @@ export function createAgentViewTestProps(
overrides: Partial<AgentsViewProps> = {},
): AgentsViewProps {
return {
access: {
canCreateAgent: true,
canUpdateConfig: true,
canUpdateIdentity: true,
canWriteFiles: true,
canRunCron: true,
},
basePath: "",
authToken: null,
loading: false,
@@ -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";
+25 -3
View File
@@ -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<boolean> {
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<string, unknown>,
): Promise<boolean> {
if (state.dreamingModeSaving) {
if (state.dreamingModeSaving || !canCallDreamingMethod(state, "config.patch", "operator.admin")) {
return false;
}
+46 -4
View File
@@ -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<typeof resolveConfiguredDreaming>) {
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}
<button
class="dreams__phase-toggle ${dreamingOn ? "dreams__phase-toggle--on" : ""}"
?disabled=${loading || configuredDreaming.engineOff}
?disabled=${!canUpdateConfig || loading || configuredDreaming.engineOff}
@click=${() => this.setEnabled(!dreamingOn, dreamingOn)}
>
<span class="dreams__phase-toggle-dot"></span>
@@ -518,6 +530,36 @@ class AgentMemoryPanel extends OpenClawLightDomElement {
</div>
</section>
${renderDreaming({
access: {
canOpenConfig: canCallDreamingMethod(dreaming, "config.openFile", "operator.admin", {
requireAdvertisement: false,
}),
canBackfillDiary: canCallDreamingMethod(
dreaming,
"doctor.memory.backfillDreamDiary",
"operator.write",
),
canDedupeDreamDiary: canCallDreamingMethod(
dreaming,
"doctor.memory.dedupeDreamDiary",
"operator.write",
),
canResetDiary: canCallDreamingMethod(
dreaming,
"doctor.memory.resetDreamDiary",
"operator.write",
),
canResetGroundedShortTerm: canCallDreamingMethod(
dreaming,
"doctor.memory.resetGroundedShortTerm",
"operator.write",
),
canRepairDreamingArtifacts: canCallDreamingMethod(
dreaming,
"doctor.memory.repairDreamingArtifacts",
"operator.write",
),
},
viewState: this.viewState,
active: dreamingOn,
selectedAgentId,
@@ -23,6 +23,14 @@ describe.skipIf(!hasBrowserLayout)("dream diary browser layout", () => {
viewState.activeSubTab = "diary";
viewState.activeDiarySubTab = "dreams";
const props: Parameters<typeof renderDreaming>[0] = {
access: {
canOpenConfig: true,
canBackfillDiary: true,
canDedupeDreamDiary: true,
canResetDiary: true,
canResetGroundedShortTerm: true,
canRepairDreamingArtifacts: true,
},
viewState,
active: true,
selectedAgentId: "main",
+8
View File
@@ -103,6 +103,14 @@ function setDreamAdvancedWaitingSort(sort: DreamingViewState["advancedWaitingSor
function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
const props: DreamingProps = {
access: {
canOpenConfig: true,
canBackfillDiary: true,
canDedupeDreamDiary: true,
canResetDiary: true,
canResetGroundedShortTerm: true,
canRepairDreamingArtifacts: true,
},
viewState,
active: true,
selectedAgentId: "main",
+40 -9
View File
@@ -92,6 +92,14 @@ type DreamingPhaseInfo = {
};
type DreamingProps = {
access: {
canOpenConfig: boolean;
canBackfillDiary: boolean;
canDedupeDreamDiary: boolean;
canResetDiary: boolean;
canResetGroundedShortTerm: boolean;
canRepairDreamingArtifacts: boolean;
};
viewState: DreamingViewState;
active: boolean;
selectedAgentId: string;
@@ -805,8 +813,16 @@ function renderAdvancedSection(props: DreamingProps) {
</div>
<div class="dreams-advanced__actions">
${[
{ label: t("dreaming.scene.dedupeDiary"), onClick: props.onDedupeDreamDiary },
{ label: t("dreaming.scene.repairCache"), onClick: props.onRepairDreamingArtifacts },
{
label: t("dreaming.scene.dedupeDiary"),
onClick: props.onDedupeDreamDiary,
allowed: props.access.canDedupeDreamDiary,
},
{
label: t("dreaming.scene.repairCache"),
onClick: props.onRepairDreamingArtifacts,
allowed: props.access.canRepairDreamingArtifacts,
},
{
label: t(
props.dreamDiaryActionLoading
@@ -814,14 +830,23 @@ function renderAdvancedSection(props: DreamingProps) {
: "dreaming.scene.backfill",
),
onClick: props.onBackfillDiary,
allowed: props.access.canBackfillDiary,
},
{
label: t("dreaming.scene.reset"),
onClick: props.onResetDiary,
allowed: props.access.canResetDiary,
},
{
label: t("dreaming.scene.clearGrounded"),
onClick: props.onResetGroundedShortTerm,
allowed: props.access.canResetGroundedShortTerm,
},
{ label: t("dreaming.scene.reset"), onClick: props.onResetDiary },
{ label: t("dreaming.scene.clearGrounded"), onClick: props.onResetGroundedShortTerm },
].map(
({ label, onClick }) => html`
({ label, onClick, allowed }) => html`
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || props.dreamDiaryActionLoading}
?disabled=${!allowed || props.modeSaving || props.dreamDiaryActionLoading}
@click=${() => onClick()}
>
${label}
@@ -865,7 +890,9 @@ function renderAdvancedSection(props: DreamingProps) {
controls: html`
<button
class="btn btn--subtle btn--sm"
?disabled=${props.modeSaving || props.dreamDiaryActionLoading}
?disabled=${!props.access.canResetGroundedShortTerm ||
props.modeSaving ||
props.dreamDiaryActionLoading}
@click=${() => props.onResetGroundedShortTerm()}
>
${t("dreaming.scene.clearGrounded")}
@@ -1378,7 +1405,7 @@ function renderDiarySection(props: DreamingProps) {
<button
class="btn btn--subtle btn--sm"
?disabled=${memoryWikiUnavailable
? false
? !props.access.canOpenConfig
: props.modeSaving ||
(activeDiarySubTab === "dreams"
? props.dreamDiaryLoading
@@ -1437,7 +1464,11 @@ function renderDiarySection(props: DreamingProps) {
)}
</div>
<div class="dreams-diary__empty-actions">
<button class="btn btn--subtle btn--sm" @click=${() => props.onOpenConfig()}>
<button
class="btn btn--subtle btn--sm"
?disabled=${!props.access.canOpenConfig}
@click=${() => props.onOpenConfig()}
>
${t("dreaming.wiki.openConfig")}
</button>
</div>
+5 -3
View File
@@ -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: {
<button
type="button"
class="btn btn--sm primary"
?disabled=${configSaving || !configDirty}
?disabled=${!params.canUpdateConfig || configSaving || !configDirty}
@click=${onConfigSave}
>
${configSaving ? t("common.saving") : t("common.save")}
+6 -3
View File
@@ -305,6 +305,7 @@ export function renderAgentCron(params: {
scopedNextWakeAtMs: number | null;
loading: boolean;
error: string | null;
canRunNow: boolean;
onRefresh: () => void;
onLoadMore: () => void;
onRunNow: (jobId: string) => void;
@@ -376,7 +377,7 @@ export function renderAgentCron(params: {
})}
<button
class="btn btn--sm"
?disabled=${!job.enabled}
?disabled=${!params.canRunNow || !job.enabled}
@click=${() => params.onRunNow(job.id)}
>
${t("agents.cronPanel.runNow")}
@@ -406,6 +407,7 @@ export function renderAgentFiles(params: {
agentFileContents: Record<string, string>;
agentFileDrafts: Record<string, string>;
agentFileSaving: boolean;
canWrite: boolean;
onLoadFiles: (agentId: string) => void;
onSelectFile: (name: string) => void;
onFileDraftChange: (name: string, content: string) => void;
@@ -554,14 +556,14 @@ export function renderAgentFiles(params: {
</button>
<button
class="btn btn--sm"
?disabled=${!isDirty}
?disabled=${!params.canWrite || !isDirty}
@click=${() => params.onFileReset(activeEntry.name)}
>
${t("common.reset")}
</button>
<button
class="btn btn--sm primary"
?disabled=${params.agentFileSaving || !isDirty}
?disabled=${!params.canWrite || params.agentFileSaving || !isDirty}
@click=${() => params.onFileSave(activeEntry.name)}
>
${params.agentFileSaving ? t("common.saving") : t("common.save")}
@@ -579,6 +581,7 @@ export function renderAgentFiles(params: {
<span>${t("agents.files.content")}</span>
<textarea
class="agent-file-textarea"
?disabled=${!params.canWrite}
.value=${draft}
@input=${(e: Event) =>
params.onFileDraftChange(
@@ -10,6 +10,7 @@ installBrowserHistoryIsolation();
function createBaseParams(overrides: Partial<Parameters<typeof renderAgentTools>[0]> = {}) {
return {
agentId: "main",
canUpdateConfig: true,
configForm: {
agents: {
entries: { main: { default: true, tools: { profile: "full" } } },
@@ -481,6 +482,7 @@ describe("agents skills panel (browser)", () => {
render(
renderAgentSkills({
agentId: "main",
canUpdateConfig: true,
report: {
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
+10 -3
View File
@@ -237,6 +237,7 @@ export function renderAgentTools(params: {
toolsEffectiveResult: ToolsEffectiveResult | null;
runtimeSessionKey: string;
runtimeSessionMatchesSelectedAgent: boolean;
canUpdateConfig: boolean;
onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void;
onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void;
onConfigReload: () => void;
@@ -256,6 +257,7 @@ export function renderAgentTools(params: {
const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0;
const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0;
const editable =
params.canUpdateConfig &&
Boolean(params.configForm) &&
!params.configLoading &&
!params.configSaving &&
@@ -449,7 +451,7 @@ export function renderAgentTools(params: {
</button>
<button
class="btn btn--sm primary"
?disabled=${params.configSaving || !params.configDirty}
?disabled=${!params.canUpdateConfig || params.configSaving || !params.configDirty}
@click=${params.onConfigSave}
>
${params.configSaving ? t("common.saving") : t("common.save")}
@@ -709,6 +711,7 @@ export function renderAgentSkills(params: {
configSaving: boolean;
configDirty: boolean;
filter: string;
canUpdateConfig: boolean;
onFilterChange: (next: string) => void;
onRefresh: () => void;
onToggle: (agentId: string, skillName: string, enabled: boolean) => void;
@@ -717,7 +720,11 @@ export function renderAgentSkills(params: {
onConfigReload: () => void;
onConfigSave: () => void;
}) {
const editable = Boolean(params.configForm) && !params.configLoading && !params.configSaving;
const editable =
params.canUpdateConfig &&
Boolean(params.configForm) &&
!params.configLoading &&
!params.configSaving;
const config = resolveAgentConfig(params.configForm, params.agentId);
const allowlist = Array.isArray(config.entry?.skills) ? config.entry?.skills : undefined;
const allowSet = new Set(normalizeStringEntries(allowlist ?? []));
@@ -788,7 +795,7 @@ export function renderAgentSkills(params: {
</button>
<button
class="btn btn--sm primary"
?disabled=${params.configSaving || !params.configDirty}
?disabled=${!params.canUpdateConfig || params.configSaving || !params.configDirty}
@click=${params.onConfigSave}
>
${params.configSaving ? t("common.saving") : t("common.save")}
+37
View File
@@ -76,6 +76,37 @@ function expectAgentTab(container: Element, text: string): HTMLElement & { disab
}
describe("renderAgents", () => {
it("keeps agent navigation readable while disabling unavailable mutations", async () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderAgents(
createProps({
access: {
canCreateAgent: false,
canUpdateConfig: false,
canUpdateIdentity: false,
canWriteFiles: false,
canRunCron: false,
},
}),
),
container,
);
const select = container.querySelector("openclaw-agent-select") as
| (HTMLElement & { onCreateAgent: (() => void) | null; updateComplete: Promise<boolean> })
| null;
await select?.updateComplete;
expect(select?.querySelector(".agent-select__label")?.textContent?.trim()).toBe("Beta");
expect(select?.onCreateAgent).toBeNull();
const setDefault = container.querySelectorAll<HTMLButtonElement>(
".agents-toolbar-actions button",
)[1];
expect(setDefault?.disabled).toBe(true);
container.remove();
});
it("opens global Agent defaults before the per-agent tabs", () => {
const container = document.createElement("div");
const onOpenAgentDefaults = vi.fn();
@@ -745,6 +776,7 @@ describe("renderAgentFiles", () => {
render(
renderAgentFiles({
agentId: "alpha",
canWrite: true,
agentFilesList: {
agentId: "alpha",
workspace: "/tmp/workspace",
@@ -791,6 +823,7 @@ describe("renderAgentFiles", () => {
render(
renderAgentFiles({
agentId: "alpha",
canWrite: true,
agentFilesList: {
agentId: "alpha",
workspace: "/tmp/workspace",
@@ -859,6 +892,7 @@ describe("renderAgentFiles", () => {
render(
renderAgentFiles({
agentId: "alpha",
canWrite: true,
agentFilesList: {
agentId: "alpha",
workspace: "/tmp/workspace",
@@ -911,6 +945,7 @@ describe("renderAgentFiles", () => {
render(
renderAgentFiles({
agentId: "alpha",
canWrite: true,
agentFilesList: {
agentId: "alpha",
workspace: "/tmp/workspace",
@@ -963,6 +998,7 @@ describe("renderAgentFiles", () => {
render(
renderAgentFiles({
agentId: "alpha",
canWrite: true,
agentFilesList: {
agentId: "alpha",
workspace: "/tmp/workspace",
@@ -1014,6 +1050,7 @@ describe("renderAgentFiles", () => {
render(
renderAgentFiles({
agentId: "alpha",
canWrite: true,
agentFilesList: {
agentId: "alpha",
workspace: "/tmp/workspace",
+16 -2
View File
@@ -94,6 +94,13 @@ type ToolsEffectiveState = {
};
type AgentsProps = {
access: {
canCreateAgent: boolean;
canUpdateConfig: boolean;
canUpdateIdentity: boolean;
canWriteFiles: boolean;
canRunCron: boolean;
};
basePath: string;
authToken: string | null;
loading: boolean;
@@ -196,7 +203,7 @@ export function renderAgents(props: AgentsProps) {
.authToken=${props.authToken}
.disabled=${props.loading}
.onSelect=${props.onSelectAgent}
.onCreateAgent=${props.onCreateAgent}
.onCreateAgent=${props.access.canCreateAgent ? props.onCreateAgent : null}
></openclaw-agent-select>
</div>
<div class="agents-toolbar-actions">
@@ -212,7 +219,8 @@ export function renderAgents(props: AgentsProps) {
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
?disabled=${!props.access.canUpdateConfig ||
Boolean(defaultId && selectedAgent.id === defaultId)}
@click=${() => props.onSetDefault(selectedAgent.id)}
>
${defaultId && selectedAgent.id === defaultId
@@ -285,6 +293,8 @@ export function renderAgents(props: AgentsProps) {
identityDraft: props.identityDraft,
identitySaving: props.identitySaving,
identityError: props.identityError,
canUpdateConfig: props.access.canUpdateConfig,
canUpdateIdentity: props.access.canUpdateIdentity,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
@@ -312,6 +322,7 @@ export function renderAgents(props: AgentsProps) {
agentFileContents: props.agentFiles.contents,
agentFileDrafts: props.agentFiles.drafts,
agentFileSaving: props.agentFiles.saving,
canWrite: props.access.canWriteFiles,
onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange,
@@ -334,6 +345,7 @@ export function renderAgents(props: AgentsProps) {
toolsEffectiveResult: props.toolsEffective.result,
runtimeSessionKey: props.runtimeSessionKey,
runtimeSessionMatchesSelectedAgent: props.runtimeSessionMatchesSelectedAgent,
canUpdateConfig: props.access.canUpdateConfig,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,
@@ -352,6 +364,7 @@ export function renderAgents(props: AgentsProps) {
configSaving: props.config.saving,
configDirty: props.config.dirty,
filter: props.agentSkills.filter,
canUpdateConfig: props.access.canUpdateConfig,
onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle,
@@ -398,6 +411,7 @@ export function renderAgents(props: AgentsProps) {
scopedNextWakeAtMs: props.cron.scopedNextWakeAtMs,
loading: props.cron.loading,
error: props.cron.error,
canRunNow: props.access.canRunCron,
onRefresh: props.onCronRefresh,
onLoadMore: props.onCronLoadMore,
onRunNow: props.onCronRunNow,