From be00fcfccba108f88dc3d4380146c6e058770b03 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Fri, 27 Mar 2026 13:36:31 -0700 Subject: [PATCH] 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 --- src/auto-reply/reply/session.test.ts | 37 +++++++++++++++++ src/auto-reply/reply/session.ts | 28 ++++++++++++- .../server.chat.gateway-server-chat.test.ts | 41 +++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 941aae5e4ab..72899bd6072 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 1b60256529b..1494ec3f0ed 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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. diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index ffc6b338fa4..692c767e523 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -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((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";