fix: isolate TUI /new sessions per client

Landed from contributor PR #39238 by @widingmarcus-cyber.

Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-08 02:31:03 +00:00
parent 76a028a50a
commit 46008178d1
3 changed files with 54 additions and 7 deletions

View File

@@ -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-<uuid>` session key instead of resetting the shared agent session, so multiple TUI clients on the same agent stop receiving each others 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.

View File

@@ -7,12 +7,14 @@ type SetActivityStatusMock = ReturnType<typeof vi.fn> & ((text: string) => void)
function createHarness(params?: {
sendChat?: ReturnType<typeof vi.fn>;
resetSession?: ReturnType<typeof vi.fn>;
setSession?: ReturnType<typeof vi.fn>;
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,

View File

@@ -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":