Skip to content

Fix Control UI context freshness and compaction CTA#71297

Merged
steipete merged 2 commits intoopenclaw:mainfrom
BunsDev:meow/control-ui-context-compaction
Apr 25, 2026
Merged

Fix Control UI context freshness and compaction CTA#71297
steipete merged 2 commits intoopenclaw:mainfrom
BunsDev:meow/control-ui-context-compaction

Conversation

@BunsDev
Copy link
Copy Markdown
Member

@BunsDev BunsDev commented Apr 25, 2026

Summary

  • Merge live session usage metadata from websocket events into the Control UI session list before the full refresh completes.
  • Coalesce overlapping session refreshes so context measurements do not stay stale under chat/event churn.
  • Add a compact recommendation button to the chat context notice when fresh usage is high.

Duplicate / Resolved Items

Test Plan

  • pnpm test ui/src/ui/controllers/sessions.test.ts ui/src/ui/chat/context-notice.test.ts ui/src/ui/app-gateway.sessions.node.test.ts
  • pnpm check:changed

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 25, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Unbounded client-side sessions list growth from gateway events (UI memory/CPU DoS)
2 🟡 Medium Client-side DoS via unvalidated sessions.changed websocket payload merged into session rows
1. 🟡 Unbounded client-side sessions list growth from gateway events (UI memory/CPU DoS)
Property Value
Severity Medium
CWE CWE-400
Location ui/src/ui/controllers/sessions.ts:236-272

Description

The UI updates state.sessionsResult.sessions directly from realtime gateway events via applySessionsChangedEvent(...).

  • The event payload is treated as an untrusted unknown record and a key is extracted from payload.session.key / payload.sessionKey / payload.key.
  • If that key is not found in the current list, a new GatewaySessionRow is prepended and count is incremented.
  • There is no cap on list size and no check that the session key is already known/returned by sessions.list.

If an attacker can cause a client to receive repeated sessions.changed or session.message events containing random/unique keys (e.g., via a compromised/misconfigured gateway broadcast channel, or cross-tenant event leakage), the browser can be forced to grow memory and repeatedly re-render/sort/filter/paginate sessions, leading to client-side resource exhaustion.

Vulnerable code:

const existingIndex = previousRows.findIndex((row) => row.key === key);
...
const sessions =
  existingIndex >= 0
    ? previousRows.map((row, index) => (index === existingIndex ? nextRow : row))
    : [nextRow, ...previousRows];
...
count: existingIndex >= 0 ? state.sessionsResult.count : state.sessionsResult.count + 1,

Recommendation

Treat gateway event payloads as potentially attacker-influenced and prevent unbounded growth.

Options (can be combined):

  1. Do not create new rows from events unless the key is already present in state.sessionsResult.sessions.
if (existingIndex < 0) return false; // ignore unknown session keys
  1. Cap the maximum number of sessions kept in memory (e.g., respect the current limit filter or a hard cap):
const MAX_EVENT_SESSIONS = 500;
const nextSessions = existingIndex >= 0
  ? previousRows.map((row, i) => (i === existingIndex ? nextRow : row))
  : [nextRow, ...previousRows].slice(0, MAX_EVENT_SESSIONS);
  1. Validate key format (length/charset) to avoid attacker-supplied huge strings.

  2. Consider rate limiting / debouncing event-driven updates and relying on sessions.list refresh for authoritative state.

2. 🟡 Client-side DoS via unvalidated `sessions.changed` websocket payload merged into session rows
Property Value
Severity Medium
CWE CWE-20
Location ui/src/ui/controllers/sessions.ts:221-257

Description

The UI merges the gateway/websocket event payload directly into the in-memory GatewaySessionRow without type validation.

  • applySessionsChangedEvent(state, payload) accepts payload: unknown, selects source = payload.session ?? payload, then copies an allow-list of fields onto a GatewaySessionRow.
  • Because values are assigned verbatim (no runtime schema checks), an attacker who can influence websocket/gateway events can inject type-confused values (e.g., thinkingOptions: "x" or thinkingLevels: {length:1}) that later break rendering logic.
  • Example crash: views/sessions.ts assumes row.thinkingOptions is an array and calls .map(...) on it. If an event sets thinkingOptions to a string/object with a truthy length, the page will throw at render time, potentially breaking the sessions UI (client-side DoS).

Vulnerable code:

for (const field of SESSION_EVENT_ROW_FIELDS) {
  if (!hasOwn(source, field)) continue;
  const value = source[field];
  if (value === undefined) {
    delete mutableNext[field];
  } else {
    mutableNext[field] = value; // no validation/coercion
  }
}

And later:

(row.thinkingOptions?.length ? row.thinkingOptions : DEFAULT_THINK_LEVELS).map(...)

Recommendation

Add runtime validation/coercion for sessions.changed / session.message payloads before merging into application state.

Options:

  1. Validate with a schema (preferred):
import { z } from "zod";

const SessionRowPatch = z.object({
  key: z.string().min(1),
  thinkingOptions: z.array(z.string()).optional(),
  thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
  label: z.string().nullable().optional(),
  displayName: z.string().nullable().optional(),// ...add the rest with correct types...
}).passthrough(false);

const parsed = SessionRowPatch.safeParse(source);
if (!parsed.success) return false;
const patch = parsed.data;
  1. Field-by-field guards: only assign mutableNext[field] = value when typeof/Array.isArray checks pass for that field; otherwise ignore the update.

Additionally, treat unexpected types defensively in render paths (e.g., ensure Array.isArray(row.thinkingOptions) before using .map).


Analyzed PR: #71297 at commit 7a3f5d5

Last updated on: 2026-04-25T00:27:57Z

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation app: web-ui App: web-ui size: M maintainer Maintainer-authored PR labels Apr 25, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 25, 2026

Greptile Summary

This PR delivers three improvements: live-merging websocket session usage metadata into the Control UI session list via applySessionsChangedEvent, coalescing concurrent loadSessions calls so only the latest pending request runs after the in-flight one finishes, and a Compact CTA button in the context notice when token usage is ≥ 90% of the limit. Tests cover all three paths. Two P2-level edge cases worth a look: sessionsError is not cleared between coalesced loop iterations (stale error banner alongside valid data if first load fails then pending succeeds), and applySessionsChangedEvent will prepend an incomplete row when a session.message event arrives for a session absent from the current filtered list.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 edge cases that don't affect the primary user path.

Both findings are narrow edge cases (concurrent load coalescing with transient errors, or session.message for a filtered-out session) that are unlikely to surface in normal usage and self-correct on the next successful load or refresh. No data loss, no security concerns, no primary-path breakage.

ui/src/ui/controllers/sessions.ts — the coalescing loop and applySessionsChangedEvent insertion logic

Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/ui/controllers/sessions.ts
Line: 289-305

Comment:
`sessionsError` not cleared between coalesced iterations

`state.sessionsError = null` is set once before the loop. If the first `loadSessionsOnce` call fails (setting `sessionsError` via its `.catch` handler) and a pending load was queued that then succeeds, the stale error string will persist on `state` even though `sessionsResult` now holds valid data. The previous `withSessionsLoading` wrapper cleared it at the start of every individual call. To preserve that contract, add `state.sessionsError = null;` at the top of the loop body (before `await loadSessionsOnce(...)`).

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ui/src/ui/controllers/sessions.ts
Line: 209-222

Comment:
New skeletal rows added when session not in current list for `session.message` events

`applySessionsChangedEvent` is called in `handleSessionMessageGatewayEvent` for every incoming message. When the emitting session is absent from `state.sessionsResult.sessions` (e.g., filtered out by the active-minutes/limit query), the function prepends a new row with only `key`, `kind`, and whatever fields the event carries, and increments `count`. For a `session.message` payload that only carries `sessionKey`, this creates an incomplete row at the front of the list until the next `loadSessions` overwrites it. Consider bailing out early (returning `false`) when `existingIndex < 0` for callers that are not `sessions.changed` events, or pass a flag to opt into the "insert new" path.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Merge branch 'main' into meow/control-ui..." | Re-trigger Greptile

Comment on lines +289 to +305
control.loading = true;
state.sessionsLoading = true;
state.sessionsError = null;
let currentOverrides: LoadSessionsOverrides | undefined = overrides;
try {
for (;;) {
control.pending = null;
await loadSessionsOnce(state, client, currentOverrides);
const pending = takePendingSessionsLoad(control);
if (!pending || !state.client || !state.connected) {
break;
}
currentOverrides = pending.overrides;
}
} finally {
control.loading = false;
control.pending = null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 sessionsError not cleared between coalesced iterations

state.sessionsError = null is set once before the loop. If the first loadSessionsOnce call fails (setting sessionsError via its .catch handler) and a pending load was queued that then succeeds, the stale error string will persist on state even though sessionsResult now holds valid data. The previous withSessionsLoading wrapper cleared it at the start of every individual call. To preserve that contract, add state.sessionsError = null; at the top of the loop body (before await loadSessionsOnce(...)).

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/controllers/sessions.ts
Line: 289-305

Comment:
`sessionsError` not cleared between coalesced iterations

`state.sessionsError = null` is set once before the loop. If the first `loadSessionsOnce` call fails (setting `sessionsError` via its `.catch` handler) and a pending load was queued that then succeeds, the stale error string will persist on `state` even though `sessionsResult` now holds valid data. The previous `withSessionsLoading` wrapper cleared it at the start of every individual call. To preserve that contract, add `state.sessionsError = null;` at the top of the loop body (before `await loadSessionsOnce(...)`).

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +209 to +222
export function applySessionsChangedEvent(state: SessionsState, payload: unknown): boolean {
if (!isRecord(payload) || !state.sessionsResult) {
return false;
}
const eventSession = isRecord(payload.session) ? payload.session : null;
const source = eventSession ?? payload;
const key =
(typeof source.key === "string" && source.key.trim()) ||
(typeof payload.sessionKey === "string" && payload.sessionKey.trim()) ||
(typeof payload.key === "string" && payload.key.trim()) ||
"";
if (!key) {
return false;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 New skeletal rows added when session not in current list for session.message events

applySessionsChangedEvent is called in handleSessionMessageGatewayEvent for every incoming message. When the emitting session is absent from state.sessionsResult.sessions (e.g., filtered out by the active-minutes/limit query), the function prepends a new row with only key, kind, and whatever fields the event carries, and increments count. For a session.message payload that only carries sessionKey, this creates an incomplete row at the front of the list until the next loadSessions overwrites it. Consider bailing out early (returning false) when existingIndex < 0 for callers that are not sessions.changed events, or pass a flag to opt into the "insert new" path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/controllers/sessions.ts
Line: 209-222

Comment:
New skeletal rows added when session not in current list for `session.message` events

`applySessionsChangedEvent` is called in `handleSessionMessageGatewayEvent` for every incoming message. When the emitting session is absent from `state.sessionsResult.sessions` (e.g., filtered out by the active-minutes/limit query), the function prepends a new row with only `key`, `kind`, and whatever fields the event carries, and increments `count`. For a `session.message` payload that only carries `sessionKey`, this creates an incomplete row at the front of the list until the next `loadSessions` overwrites it. Consider bailing out early (returning `false`) when `existingIndex < 0` for callers that are not `sessions.changed` events, or pass a flag to opt into the "insert new" path.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 875b88aa27

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

try {
for (;;) {
control.pending = null;
await loadSessionsOnce(state, client, currentOverrides);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use active client when replaying queued session loads

When a second loadSessions call is queued during an in-flight request, the replay loop keeps calling loadSessionsOnce with the client captured before the loop started. If the websocket reconnects in between (so state.client points to a new connection), the queued refresh still uses the stale/disconnected client and can fail, leaving session usage stale until another event triggers a fresh reload. This is introduced by the coalescing loop and is observable in reconnect + event churn scenarios.

Useful? React with 👍 / 👎.

@steipete steipete force-pushed the meow/control-ui-context-compaction branch from 8116e49 to 7a3f5d5 Compare April 25, 2026 00:19
@steipete steipete merged commit da77317 into openclaw:main Apr 25, 2026
9 checks passed
@steipete
Copy link
Copy Markdown
Contributor

Landed via squash merge onto main.

  • Source head: 7a3f5d5
  • Merge commit: da77317
  • Gate: PR CI green; local pnpm check:changed green after the session-loading refactor; focused UI tests passed.

Thanks @BunsDev!

Angfr95 pushed a commit to Angfr95/openclaw that referenced this pull request Apr 25, 2026
Patch live session usage metadata into the Control UI session list, coalesce overlapping refreshes, and add a compact action when fresh context usage is high.

Keep session refresh loading separate from session mutation ownership so background refreshes cannot re-enable mutation UI or overwrite delete/restore state mid-flight.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
Patch live session usage metadata into the Control UI session list, coalesce overlapping refreshes, and add a compact action when fresh context usage is high.

Keep session refresh loading separate from session mutation ownership so background refreshes cannot re-enable mutation UI or overwrite delete/restore state mid-flight.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui docs Improvements or additions to documentation maintainer Maintainer-authored PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Control UI: Context warning blocks screen on refresh with incorrect values

2 participants