Gateway: align chat.send reset scope checks (#56009)

* Gateway: align chat.send reset scope checks

* Gateway: tighten chat.send reset regression test

* Gateway: honor internal provider reset scope
This commit is contained in:
Jacob Tomlinson 2026-03-27 13:36:31 -07:00 committed by GitHub
parent aa66ae1fc7
commit be00fcfccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 104 additions and 2 deletions

View File

@ -1405,6 +1405,43 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
}
});
it("requires operator.admin when Provider is internal even if Surface carries external metadata", async () => {
const storePath = await createStorePath("openclaw-internal-reset-provider-authoritative-");
const sessionKey = "agent:main:telegram:dm:provider-authoritative";
const existingSessionId = "existing-session-provider-authoritative";
await seedSessionStoreWithOverrides({
storePath,
sessionKey,
sessionId: existingSessionId,
overrides: {},
});
const cfg = {
session: { store: storePath, idleMinutes: 999 },
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "/reset",
RawBody: "/reset",
CommandBody: "/reset",
Provider: "webchat",
Surface: "telegram",
OriginatingChannel: "telegram",
GatewayClientScopes: ["operator.write"],
ChatType: "direct",
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(false);
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
});
it("archives the old session store entry on /new", async () => {
const storePath = await createStorePath("openclaw-archive-old-");
const sessionKey = "agent:main:telegram:dm:user-archive";

View File

@ -35,6 +35,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
@ -78,6 +79,29 @@ export type SessionInitResult = {
triggerBodyNormalized: string;
};
function isResetAuthorizedForContext(params: {
ctx: MsgContext;
cfg: OpenClawConfig;
commandAuthorized: boolean;
}): boolean {
const auth = resolveCommandAuthorization(params);
if (!auth.isAuthorizedSender) {
return false;
}
const provider = params.ctx.Provider;
const internalGatewayCaller = provider
? isInternalMessageChannel(provider)
: isInternalMessageChannel(params.ctx.Surface);
if (!internalGatewayCaller) {
return true;
}
const scopes = params.ctx.GatewayClientScopes;
if (!Array.isArray(scopes) || scopes.length === 0) {
return true;
}
return scopes.includes("operator.admin");
}
function resolveAcpResetBindingContext(ctx: MsgContext): {
channel: string;
accountId: string;
@ -251,11 +275,11 @@ export async function initSessionState(params: {
// Use CommandBody/RawBody for reset trigger matching (clean message without structural context).
const rawBody = commandSource;
const trimmedBody = rawBody.trim();
const resetAuthorized = resolveCommandAuthorization({
const resetAuthorized = isResetAuthorizedForContext({
ctx,
cfg,
commandAuthorized,
}).isAuthorizedSender;
});
// Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the
// web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets.

View File

@ -726,6 +726,47 @@ describe("gateway server chat", () => {
});
});
test("chat.send does not rotate sessions for operator.write reset triggers", 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: "/reset",
idempotencyKey: "idem-write-scope-reset-no-rotate",
});
expect(sendRes.ok).toBe(true);
const waitRes = await rpcReq(scopedWs, "agent.wait", {
runId: "idem-write-scope-reset-no-rotate",
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"?: {
sessionId?: string;
};
};
expect(stored["agent:main:main"]?.sessionId).toBe("sess-main");
} 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";