From 46008178d1bab4a9cbb8ea270468b0f370cec370 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 02:31:03 +0000 Subject: [PATCH] fix: isolate TUI /new sessions per client Landed from contributor PR #39238 by @widingmarcus-cyber. Co-authored-by: Marcus Widing --- CHANGELOG.md | 1 + src/tui/tui-command-handlers.test.ts | 40 +++++++++++++++++++++++----- src/tui/tui-command-handlers.ts | 20 +++++++++++++- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da25d0bb436..19ec82fb2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream `terminated` failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard. - Agents/fallback cooldown probe execution: thread explicit rate-limit cooldown probe intent from model fallback into embedded runner auth-profile selection so same-provider fallback attempts can actually run when all profiles are cooldowned for `rate_limit` (instead of failing pre-run as `No available auth profile`), while preserving default cooldown skip behavior and adding regression tests at both fallback and runner layers. (#13623) Thanks @asfura. - Cron/OpenAI Codex OAuth refresh hardening: when `openai-codex` token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal. +- TUI/session isolation for `/new`: make `/new` allocate a unique `tui-` session key instead of resetting the shared agent session, so multiple TUI clients on the same agent stop receiving each other’s replies; also sanitize `/new` and `/reset` failure text before rendering in-terminal. Landed from contributor PR #39238 by @widingmarcus-cyber. Thanks @widingmarcus-cyber. - Cron/file permission hardening: enforce owner-only (`0600`) cron store/backup/run-log files and harden cron store + run-log directories to `0700`, including pre-existing directories from older installs. (#36078) Thanks @aerelune. - Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index bb17cbed9a4..82da86131a5 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -7,12 +7,14 @@ type SetActivityStatusMock = ReturnType & ((text: string) => void) function createHarness(params?: { sendChat?: ReturnType; resetSession?: ReturnType; + setSession?: ReturnType; loadHistory?: LoadHistoryMock; setActivityStatus?: SetActivityStatusMock; isConnected?: boolean; }) { const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" }); const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true }); + const setSession = params?.setSession ?? vi.fn().mockResolvedValue(undefined); const addUser = vi.fn(); const addSystem = vi.fn(); const requestRender = vi.fn(); @@ -36,7 +38,7 @@ function createHarness(params?: { closeOverlay: vi.fn(), refreshSessionInfo: vi.fn(), loadHistory, - setSession: vi.fn(), + setSession, refreshAgents: vi.fn(), abortActive: vi.fn(), setActivityStatus, @@ -51,6 +53,7 @@ function createHarness(params?: { handleCommand, sendChat, resetSession, + setSession, addUser, addSystem, requestRender, @@ -104,16 +107,26 @@ describe("tui command handlers", () => { expect(requestRender).toHaveBeenCalled(); }); - it("passes reset reason when handling /new and /reset", async () => { + it("creates unique session for /new and resets shared session for /reset", async () => { const loadHistory = vi.fn().mockResolvedValue(undefined); - const { handleCommand, resetSession } = createHarness({ loadHistory }); + const setSessionMock = vi.fn().mockResolvedValue(undefined); + const { handleCommand, resetSession } = createHarness({ + loadHistory, + setSession: setSessionMock, + }); await handleCommand("/new"); await handleCommand("/reset"); - expect(resetSession).toHaveBeenNthCalledWith(1, "agent:main:main", "new"); - expect(resetSession).toHaveBeenNthCalledWith(2, "agent:main:main", "reset"); - expect(loadHistory).toHaveBeenCalledTimes(2); + // /new creates a unique session key (isolates TUI client) (#39217) + expect(setSessionMock).toHaveBeenCalledTimes(1); + expect(setSessionMock).toHaveBeenCalledWith( + expect.stringMatching(/^tui-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/), + ); + // /reset still resets the shared session + expect(resetSession).toHaveBeenCalledTimes(1); + expect(resetSession).toHaveBeenCalledWith("agent:main:main", "reset"); + expect(loadHistory).toHaveBeenCalledTimes(1); // /reset calls loadHistory directly; /new does so indirectly via setSession }); it("reports send failures and marks activity status as error", async () => { @@ -129,6 +142,21 @@ describe("tui command handlers", () => { expect(setActivityStatus).toHaveBeenLastCalledWith("error"); }); + it("sanitizes control sequences in /new and /reset failures", async () => { + const setSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m")); + const resetSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m")); + const { handleCommand, addSystem } = createHarness({ + setSession, + resetSession, + }); + + await handleCommand("/new"); + await handleCommand("/reset"); + + expect(addSystem).toHaveBeenNthCalledWith(1, "new session failed: Error: boom"); + expect(addSystem).toHaveBeenNthCalledWith(2, "reset failed: Error: boom"); + }); + it("reports disconnected status and skips gateway send when offline", async () => { const { handleCommand, sendChat, addUser, addSystem, setActivityStatus } = createHarness({ isConnected: false, diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 989c942beb6..ced4f99b7e7 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -16,6 +16,7 @@ import { createSettingsList, } from "./components/selectors.js"; import type { GatewayChatClient } from "./gateway-chat.js"; +import { sanitizeRenderableText } from "./tui-formatters.js"; import { formatStatusSummary } from "./tui-status-summary.js"; import type { AgentSummary, @@ -423,6 +424,23 @@ export function createCommandHandlers(context: CommandHandlerContext) { } break; case "new": + try { + // Clear token counts immediately to avoid stale display (#1523) + state.sessionInfo.inputTokens = null; + state.sessionInfo.outputTokens = null; + state.sessionInfo.totalTokens = null; + tui.requestRender(); + + // Generate unique session key to isolate this TUI client (#39217) + // This ensures /new creates a fresh session that doesn't broadcast + // to other connected TUI clients sharing the original session key. + const uniqueKey = `tui-${randomUUID()}`; + await setSession(uniqueKey); + chatLog.addSystem(`new session: ${uniqueKey}`); + } catch (err) { + chatLog.addSystem(`new session failed: ${sanitizeRenderableText(String(err))}`); + } + break; case "reset": try { // Clear token counts immediately to avoid stale display (#1523) @@ -435,7 +453,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem(`session ${state.currentSessionKey} reset`); await loadHistory(); } catch (err) { - chatLog.addSystem(`reset failed: ${String(err)}`); + chatLog.addSystem(`reset failed: ${sanitizeRenderableText(String(err))}`); } break; case "abort":