Fix Control UI context freshness and compaction CTA#71297
Fix Control UI context freshness and compaction CTA#71297steipete merged 2 commits intoopenclaw:mainfrom
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Unbounded client-side sessions list growth from gateway events (UI memory/CPU DoS)
DescriptionThe UI updates
If an attacker can cause a client to receive repeated 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,RecommendationTreat gateway event payloads as potentially attacker-influenced and prevent unbounded growth. Options (can be combined):
if (existingIndex < 0) return false; // ignore unknown session keys
const MAX_EVENT_SESSIONS = 500;
const nextSessions = existingIndex >= 0
? previousRows.map((row, i) => (i === existingIndex ? nextRow : row))
: [nextRow, ...previousRows].slice(0, MAX_EVENT_SESSIONS);
2. 🟡 Client-side DoS via unvalidated `sessions.changed` websocket payload merged into session rows
DescriptionThe UI merges the gateway/websocket event payload directly into the in-memory
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(...)RecommendationAdd runtime validation/coercion for Options:
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;
Additionally, treat unexpected types defensively in render paths (e.g., ensure Analyzed PR: #71297 at commit Last updated on: 2026-04-25T00:27:57Z |
Greptile SummaryThis PR delivers three improvements: live-merging websocket session usage metadata into the Control UI session list via Confidence Score: 5/5Safe 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 AIThis 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 |
| 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; |
There was a problem hiding this 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(...)).
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.| 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; | ||
| } |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
💡 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); |
There was a problem hiding this comment.
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 👍 / 👎.
4424aa1 to
6fd9cb1
Compare
8116e49 to
7a3f5d5
Compare
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>
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>
Summary
Duplicate / Resolved Items
/usage, footer, compaction-history, or threshold behavior outside this PR's chat notice/session-freshness scope.Test Plan