mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix: gate agent management actions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user