From 96aad965ab4eba262655816b15f08f71373be330 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 13:40:30 +0000 Subject: [PATCH] fix: land NO_REPLY announce suppression and auth scope assertions Landed follow-up for #27535 and aligned shared-auth gateway expectations after #27498. Co-authored-by: kevinWangSheng <118158941+kevinWangSheng@users.noreply.github.com> --- CHANGELOG.md | 7 +++++ src/agents/pi-embedded-runner/extra-params.ts | 10 +++++-- src/agents/subagent-announce.format.test.ts | 17 +++++++++++ src/agents/subagent-announce.ts | 5 +++- src/gateway/server.auth.test.ts | 29 +++++++++---------- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc44cf885e4..fae0d2dab5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ Docs: https://docs.openclaw.ai - Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282) - iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman. - Queue/Drain/Cron reliability: harden lane draining with guaranteed `draining` flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add `/stop` queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron `agentTurn` outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427) +- Typing/Dispatch idle: force typing cleanup when `markDispatchIdle` never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493) +- Telegram native commands: degrade command registration on `BOT_COMMANDS_TOO_MUCH` by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456) +- Config/Plugins entries: treat unknown `plugins.entries.*` ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455) +- Sessions cleanup/Doctor: add `openclaw sessions cleanup --fix-missing` to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422) +- Azure OpenAI Responses: force `store=true` for `azure-openai-responses` direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497) +- Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494) +- NO_REPLY suppression: suppress `NO_REPLY` before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531) - Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. - Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts. - Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index ff493299d0c..dc1db5f7642 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -184,10 +184,16 @@ function isDirectOpenAIBaseUrl(baseUrl: unknown): boolean { try { const host = new URL(baseUrl).hostname.toLowerCase(); - return host === "api.openai.com" || host === "chatgpt.com" || host.endsWith(".openai.azure.com"); + return ( + host === "api.openai.com" || host === "chatgpt.com" || host.endsWith(".openai.azure.com") + ); } catch { const normalized = baseUrl.toLowerCase(); - return normalized.includes("api.openai.com") || normalized.includes("chatgpt.com") || normalized.includes(".openai.azure.com"); + return ( + normalized.includes("api.openai.com") || + normalized.includes("chatgpt.com") || + normalized.includes(".openai.azure.com") + ); } } diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index 8952e82cc68..712d1d204b9 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -435,6 +435,23 @@ describe("subagent announce formatting", () => { expect(sessionsDeleteSpy).toHaveBeenCalledTimes(1); }); + it("suppresses completion delivery when subagent reply is NO_REPLY", async () => { + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-no-reply", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "slack", to: "channel:C123", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + roundOneReply: " NO_REPLY ", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).not.toHaveBeenCalled(); + }); + it("retries completion direct send on transient channel-unavailable errors", async () => { sendSpy .mockRejectedValueOnce(new Error("Error: No active WhatsApp Web listener (account: default)")) diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 0d2f961c01e..32cf49cc2db 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -1,5 +1,5 @@ import { resolveQueueSettings } from "../auto-reply/reply/queue.js"; -import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import { loadConfig } from "../config/config.js"; import { @@ -1161,6 +1161,9 @@ export async function runSubagentAnnounceFlow(params: { if (isAnnounceSkip(reply)) { return true; } + if (isSilentReplyText(reply, SILENT_REPLY_TOKEN)) { + return true; + } if (!outcome) { outcome = { status: "unknown" }; diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 71b435d137b..c5a82390cea 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -416,13 +416,14 @@ describe("gateway server auth/connect", () => { opts: Parameters[1]; expectConnectOk: boolean; expectConnectError?: string; + expectStatusOk?: boolean; expectStatusError?: string; }> = [ { - name: "operator + valid shared token => connected with zero scopes", + name: "operator + valid shared token => connected with preserved scopes", opts: { role: "operator", token, device: null }, expectConnectOk: true, - expectStatusError: "missing scope", + expectStatusOk: true, }, { name: "node + valid shared token => rejected without device", @@ -449,12 +450,14 @@ describe("gateway server auth/connect", () => { ); continue; } - if (scenario.expectStatusError) { + if (scenario.expectStatusOk !== undefined) { const status = await rpcReq(ws, "status"); - expect(status.ok, scenario.name).toBe(false); - expect(status.error?.message ?? "", scenario.name).toContain( - scenario.expectStatusError, - ); + expect(status.ok, scenario.name).toBe(scenario.expectStatusOk); + if (!scenario.expectStatusOk && scenario.expectStatusError) { + expect(status.error?.message ?? "", scenario.name).toContain( + scenario.expectStatusError, + ); + } } } finally { ws.close(); @@ -811,8 +814,7 @@ describe("gateway server auth/connect", () => { const res = await connectReq(ws, { token: "secret", device: null }); expect(res.ok).toBe(true); const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(false); - expect(status.error?.message).toContain("missing scope"); + expect(status.ok).toBe(true); const health = await rpcReq(ws, "health"); expect(health.ok).toBe(true); ws.close(); @@ -896,8 +898,7 @@ describe("gateway server auth/connect", () => { } if (tc.expectStatusChecks) { const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(false); - expect(status.error?.message ?? "").toContain("missing scope"); + expect(status.ok).toBe(true); const health = await rpcReq(ws, "health"); expect(health.ok).toBe(true); } @@ -923,8 +924,7 @@ describe("gateway server auth/connect", () => { }); expect(res.ok).toBe(true); const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(false); - expect(status.error?.message ?? "").toContain("missing scope"); + expect(status.ok).toBe(true); const health = await rpcReq(ws, "health"); expect(health.ok).toBe(true); ws.close(); @@ -946,8 +946,7 @@ describe("gateway server auth/connect", () => { }); expect(res.ok).toBe(true); const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(false); - expect(status.error?.message ?? "").toContain("missing scope"); + expect(status.ok).toBe(true); const health = await rpcReq(ws, "health"); expect(health.ok).toBe(true); ws.close();