mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 18:50:21 +00:00
fix(gateway): require admin for persisted verbose defaults (#55916)
* fix(gateway): require admin for verbose persistence * gateway: tighten verbose persistence follow-ups
This commit is contained in:
@@ -20,10 +20,13 @@ import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js";
|
||||
import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js";
|
||||
import {
|
||||
canPersistInternalExecDirective,
|
||||
canPersistInternalVerboseDirective,
|
||||
formatDirectiveAck,
|
||||
formatElevatedRuntimeHint,
|
||||
formatElevatedUnavailableText,
|
||||
formatInternalExecPersistenceDeniedText,
|
||||
formatInternalVerboseCurrentReplyOnlyText,
|
||||
formatInternalVerbosePersistenceDeniedText,
|
||||
enqueueModeSwitchEvents,
|
||||
withOptions,
|
||||
} from "./directive-handling.shared.js";
|
||||
@@ -99,6 +102,10 @@ export async function handleDirectiveOnly(
|
||||
surface: params.surface,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
});
|
||||
const allowInternalVerbosePersistence = canPersistInternalVerboseDirective({
|
||||
surface: params.surface,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
});
|
||||
|
||||
const modelInfo = await maybeHandleModelDirectiveInfo({
|
||||
directives,
|
||||
@@ -319,7 +326,9 @@ export async function handleDirectiveOnly(
|
||||
const shouldPersistSessionEntry =
|
||||
(directives.hasThinkDirective && Boolean(directives.thinkLevel)) ||
|
||||
(directives.hasFastDirective && directives.fastMode !== undefined) ||
|
||||
(directives.hasVerboseDirective && Boolean(directives.verboseLevel)) ||
|
||||
(directives.hasVerboseDirective &&
|
||||
Boolean(directives.verboseLevel) &&
|
||||
allowInternalVerbosePersistence) ||
|
||||
(directives.hasReasoningDirective && Boolean(directives.reasoningLevel)) ||
|
||||
(directives.hasElevatedDirective && Boolean(directives.elevatedLevel)) ||
|
||||
(directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) ||
|
||||
@@ -342,7 +351,11 @@ export async function handleDirectiveOnly(
|
||||
if (shouldDowngradeXHigh) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
if (
|
||||
directives.hasVerboseDirective &&
|
||||
directives.verboseLevel &&
|
||||
allowInternalVerbosePersistence
|
||||
) {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
}
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
@@ -457,13 +470,22 @@ export async function handleDirectiveOnly(
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
parts.push(
|
||||
directives.verboseLevel === "off"
|
||||
? formatDirectiveAck("Verbose logging disabled.")
|
||||
: directives.verboseLevel === "full"
|
||||
? formatDirectiveAck("Verbose logging set to full.")
|
||||
: formatDirectiveAck("Verbose logging enabled."),
|
||||
!allowInternalVerbosePersistence
|
||||
? formatDirectiveAck(formatInternalVerboseCurrentReplyOnlyText())
|
||||
: directives.verboseLevel === "off"
|
||||
? formatDirectiveAck("Verbose logging disabled.")
|
||||
: directives.verboseLevel === "full"
|
||||
? formatDirectiveAck("Verbose logging set to full.")
|
||||
: formatDirectiveAck("Verbose logging enabled."),
|
||||
);
|
||||
}
|
||||
if (
|
||||
directives.hasVerboseDirective &&
|
||||
directives.verboseLevel &&
|
||||
!allowInternalVerbosePersistence
|
||||
) {
|
||||
parts.push(formatDirectiveAck(formatInternalVerbosePersistenceDeniedText()));
|
||||
}
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
parts.push(
|
||||
directives.reasoningLevel === "off"
|
||||
|
||||
@@ -583,6 +583,43 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
expect(sessionEntry.execNode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks internal operator.write verbose persistence in directive-only handling", async () => {
|
||||
const directives = parseInlineDirectives("/verbose full");
|
||||
const sessionEntry = createSessionEntry();
|
||||
const sessionStore = { [sessionKey]: sessionEntry };
|
||||
const result = await handleDirectiveOnly(
|
||||
createHandleParams({
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
surface: "webchat",
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result?.text).toContain("Verbose logging set for the current reply only.");
|
||||
expect(result?.text).toContain("operator.admin");
|
||||
expect(sessionEntry.verboseLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows internal operator.admin verbose persistence in directive-only handling", async () => {
|
||||
const directives = parseInlineDirectives("/verbose full");
|
||||
const sessionEntry = createSessionEntry();
|
||||
const sessionStore = { [sessionKey]: sessionEntry };
|
||||
const result = await handleDirectiveOnly(
|
||||
createHandleParams({
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
surface: "webchat",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result?.text).toContain("Verbose logging set to full.");
|
||||
expect(sessionEntry.verboseLevel).toBe("full");
|
||||
});
|
||||
|
||||
it("allows internal operator.admin exec persistence in directive-only handling", async () => {
|
||||
const directives = parseInlineDirectives(
|
||||
"/exec host=node security=allowlist ask=always node=worker-1",
|
||||
@@ -646,4 +683,38 @@ describe("persistInlineDirectives internal exec scope gate", () => {
|
||||
expect(sessionEntry.execAsk).toBeUndefined();
|
||||
expect(sessionEntry.execNode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips verbose persistence for internal operator.write callers", async () => {
|
||||
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
|
||||
const directives = parseInlineDirectives("/verbose full");
|
||||
const sessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
} as SessionEntry;
|
||||
const sessionStore = { "agent:main:main": sessionEntry };
|
||||
|
||||
await persistInlineDirectives({
|
||||
directives,
|
||||
cfg: baseConfig(),
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/sessions.json",
|
||||
elevatedEnabled: true,
|
||||
elevatedAllowed: true,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
initialModelLabel: "anthropic/claude-opus-4-5",
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
agentCfg: undefined,
|
||||
surface: "webchat",
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
|
||||
expect(sessionEntry.verboseLevel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { resolveModelSelectionFromDirective } from "./directive-handling.model-s
|
||||
import type { InlineDirectives } from "./directive-handling.parse.js";
|
||||
import {
|
||||
canPersistInternalExecDirective,
|
||||
canPersistInternalVerboseDirective,
|
||||
enqueueModeSwitchEvents,
|
||||
} from "./directive-handling.shared.js";
|
||||
import type { ElevatedLevel, ReasoningLevel } from "./directives.js";
|
||||
@@ -65,6 +66,10 @@ export async function persistInlineDirectives(params: {
|
||||
surface: params.surface,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
});
|
||||
const allowInternalVerbosePersistence = canPersistInternalVerboseDirective({
|
||||
surface: params.surface,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
});
|
||||
const activeAgentId = sessionKey
|
||||
? resolveSessionAgentId({ sessionKey, config: cfg })
|
||||
: resolveDefaultAgentId(cfg);
|
||||
@@ -89,7 +94,11 @@ export async function persistInlineDirectives(params: {
|
||||
sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
updated = true;
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
if (
|
||||
directives.hasVerboseDirective &&
|
||||
directives.verboseLevel &&
|
||||
allowInternalVerbosePersistence
|
||||
) {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,13 @@ export const formatElevatedRuntimeHint = () =>
|
||||
export const formatInternalExecPersistenceDeniedText = () =>
|
||||
"Exec defaults require operator.admin for internal gateway callers; skipped persistence.";
|
||||
|
||||
export function canPersistInternalExecDirective(params: {
|
||||
export const formatInternalVerbosePersistenceDeniedText = () =>
|
||||
"Verbose defaults require operator.admin for internal gateway callers; skipped persistence.";
|
||||
|
||||
export const formatInternalVerboseCurrentReplyOnlyText = () =>
|
||||
"Verbose logging set for the current reply only.";
|
||||
|
||||
function canPersistInternalDirective(params: {
|
||||
surface?: string;
|
||||
gatewayClientScopes?: string[];
|
||||
}): boolean {
|
||||
@@ -28,6 +34,9 @@ export function canPersistInternalExecDirective(params: {
|
||||
return scopes.includes("operator.admin");
|
||||
}
|
||||
|
||||
export const canPersistInternalExecDirective = canPersistInternalDirective;
|
||||
export const canPersistInternalVerboseDirective = canPersistInternalDirective;
|
||||
|
||||
export const formatElevatedEvent = (level: ElevatedLevel) => {
|
||||
if (level === "full") {
|
||||
return "Elevated FULL — exec runs on host with auto-approval.";
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
rpcReq,
|
||||
testState,
|
||||
trackConnectChallengeNonce,
|
||||
withGatewayServer,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
import { agentCommand } from "./test-helpers.mocks.js";
|
||||
@@ -684,6 +685,47 @@ describe("gateway server chat", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("chat.send does not persist verboseLevel for operator.write callers", async () => {
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
await withMainSessionStore(async () => {
|
||||
let scopedWs: WebSocket | undefined;
|
||||
|
||||
try {
|
||||
scopedWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
trackConnectChallengeNonce(scopedWs);
|
||||
await new Promise<void>((resolve) => scopedWs?.once("open", resolve));
|
||||
await connectOk(scopedWs, {
|
||||
scopes: ["operator.write"],
|
||||
});
|
||||
|
||||
const sendRes = await rpcReq(scopedWs, "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "/verbose full",
|
||||
idempotencyKey: "idem-write-scope-verbose-no-persist",
|
||||
});
|
||||
expect(sendRes.ok).toBe(true);
|
||||
|
||||
const waitRes = await rpcReq(scopedWs, "agent.wait", {
|
||||
runId: "idem-write-scope-verbose-no-persist",
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
expect(waitRes.ok).toBe(true);
|
||||
expect(waitRes.payload?.status).toBe("ok");
|
||||
|
||||
const raw = await fs.readFile(testState.sessionStorePath!, "utf-8");
|
||||
const stored = JSON.parse(raw) as {
|
||||
"agent:main:main"?: {
|
||||
verboseLevel?: string;
|
||||
};
|
||||
};
|
||||
expect(stored["agent:main:main"]?.verboseLevel).toBeUndefined();
|
||||
} finally {
|
||||
scopedWs?.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("agent.wait resolves chat.send runs that finish without lifecycle events", async () => {
|
||||
await withMainSessionStore(async () => {
|
||||
const runId = "idem-wait-chat-1";
|
||||
|
||||
Reference in New Issue
Block a user