fix(web,gateway): close P3 re-review#3 findings — sanitize command errors, harden turn-lock & caps

This commit is contained in:
shaggy (mosaic-dev box)
2026-08-10 14:21:52 -05:00
parent 48bb19310d
commit d46a2d675a
7 changed files with 350 additions and 166 deletions
+119 -84
View File
@@ -1,6 +1,7 @@
import { useEffect, useReducer, useRef } from 'react';
import { destroySocket, getSocket } from '@/lib/socket';
import {
MAX_COMMAND_MESSAGE_CHARS,
MAX_COMMAND_RESULTS,
MAX_EXECUTED_APPROVAL_IDS,
MAX_MANIFEST_ITEMS,
@@ -121,6 +122,14 @@ function isValidToolCallId(value: unknown): value is string {
* startup failure instead of leaving `sending`/`pendingSend` stuck forever. */
const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please try again.';
/** Shown when the non-evicting executed-approval dedup cache is already at
* MAX_EXECUTED_APPROVAL_IDS and a distinct, never-before-seen approval is
* denied as a result (see `runApprovedCommand`'s saturation branch below).
* The approval is still consumed (the UI lock is released) but the user
* must see why the command did not run rather than have it silently
* dropped. */
const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.';
/** True only for the narrow case a malformed-conversationId `error`/`agent:end`
* must be treated as a terminal startup failure: no conversation has ever been
* established yet, and a send is still pending one. Once a conversation is
@@ -131,17 +140,6 @@ function isUnrecoverableStartupFailure(state: ChatConnectionState): boolean {
return state.conversationId === null && state.pendingSend;
}
/** True when a scoped `agent:end`/`error` is a recognized-stale terminal: a
* send is genuinely in flight (`sending`) but the current turn's own
* ack/start has not armed `armedTurnToken` yet — so this event must be a
* leftover from an earlier turn on the same conversation, not this one's own
* terminal. Recognizing this must make the event a true no-op: it may not
* finalize/clear transient state (text, thinking, streaming, error,
* approvalRequestPending) that belongs to the still in-flight turn. */
function isRecognizedStaleTerminal(state: ChatConnectionState): boolean {
return state.sending && state.armedTurnToken !== state.turnToken;
}
/** Deterministic, unique-per-event fallback id for a malformed `toolCallId`.
* Sourced from a monotonically increasing counter carried in state (`toolSeq`)
* rather than `tools.length`, so it stays collision-free even once `tools` is
@@ -159,6 +157,15 @@ function capList<T>(arr: unknown, max: number): T[] {
return (arr.length > max ? arr.slice(0, max) : arr) as T[];
}
/** Like `capList`, but for a server-provided string collection (e.g.
* `system:reload.providers`): a non-string entry is dropped rather than
* invalidating the whole list, and the result is capped at `max`. */
function capStringList(arr: unknown, max: number): string[] {
if (!Array.isArray(arr)) return [];
const strings = arr.filter((item): item is string => typeof item === 'string');
return strings.length > max ? strings.slice(0, max) : strings;
}
/** Normalizes a raw message:ack payload before it is stored — `messageId` is
* given a stable, visible "unknown" fallback rather than storing a raw
* non-string value (which would either render as "[object Object]" or,
@@ -198,7 +205,10 @@ function sanitizeApproval(
/** Normalizes a raw command:result payload before it is stored — `success`
* is coerced to a literal boolean so a truthy non-boolean value (e.g. an
* object) can never be displayed as a successful result. */
* object) can never be displayed as a successful result, and a curated
* `message` is bounded to MAX_COMMAND_MESSAGE_CHARS so a hostile/malformed
* gateway cannot push an unbounded string into state (the render site in
* commands-panel.tsx applies the same bound again as defense-in-depth). */
function sanitizeCommandResult(
payload: SlashCommandResultPayload,
conversationId: string,
@@ -207,7 +217,10 @@ function sanitizeCommandResult(
conversationId,
command: asString(payload.command, 'unknown'),
success: payload.success === true,
message: typeof payload.message === 'string' ? payload.message : undefined,
message:
typeof payload.message === 'string'
? payload.message.slice(0, MAX_COMMAND_MESSAGE_CHARS)
: undefined,
};
}
@@ -266,20 +279,32 @@ export interface ChatConnectionState {
* tools remain, so fallback ids stay unique across the MAX_TOOLS cap
* boundary. */
toolSeq: number;
/** Monotonically increasing id for the current in-flight turn, minted at
* local send time. Wire payloads (agent:end/error) carry only a
* conversationId, not a turn-scoped correlation id — two different turns
* on the SAME conversation are indistinguishable on the wire, so this
* local counter is what actually tells them apart. */
turnToken: number;
/** The `turnToken` value (if any) that has been armed to accept a normal
* terminal release, set only by an accepted (conversation-scoped)
* message:ack or agent:start for the CURRENT token. A same-conversation
* agent:end/error may only set `sending: false` when this equals
* `turnToken` — otherwise it is a stale terminal event from an earlier
* turn on the same conversation and must not unlock a still-in-flight
* later turn. Fails closed: an unarmed token can never be unlocked. */
armedTurnToken: number | null;
/** Explicit per-turn phase for the CURRENT (most recently sent) turn. Wire
* payloads (agent:end/error) carry only a conversationId, not a
* turn-scoped correlation id — two different turns on the SAME
* conversation are indistinguishable on the wire by conversationId alone,
* so this local phase is what actually tells them apart:
*
* - 'pending': minted by `local/send`; the turn has been sent but has not
* yet received its own `agent:start`. A same-conversation `agent:end`
* arriving in this phase cannot be trusted as this turn's own — Socket.IO
* delivers in order and the Gateway always emits `agent:start` before a
* legitimate `agent:end`, so this can only be a stale/duplicate end left
* over from an earlier turn (a `message:ack` alone does NOT advance this
* phase, precisely to close that hole). A same-conversation `error`,
* however, IS trusted in this phase: the Gateway can legitimately emit a
* session-creation error before ack/start, and once an earlier turn on
* this conversation has already fully settled (via its own terminal
* event) ordered delivery guarantees nothing further remains in flight
* for it — so any later scoped error can only belong to this turn.
* - 'active': set only by this turn's own accepted `agent:start`. Only
* from this phase may a scoped `agent:end` settle the turn.
* - 'settled': set once this turn's own terminal (`agent:end` or `error`)
* has been processed. A further scoped `agent:end`/`error` in this phase
* is a true no-op — it must not re-finalize or re-release anything.
*
* Fails closed: `agent:end` can only ever settle from 'active'. */
turnPhase: 'pending' | 'active' | 'settled';
}
export interface ChatConnectionActions {
@@ -319,8 +344,7 @@ const initialState: ChatConnectionState = {
messages: [],
messageSeq: 0,
toolSeq: 0,
turnToken: 0,
armedTurnToken: null,
turnPhase: 'settled',
};
type Action =
@@ -340,6 +364,7 @@ type Action =
| { type: 'local/send'; content: string }
| { type: 'local/approve-request'; command: string; args?: string }
| { type: 'local/consume-approval' }
| { type: 'local/approval-saturated' }
| { type: 'local/disconnect' };
/**
@@ -371,11 +396,13 @@ function resolveScopedConversation(
if (state.conversationId === null && state.pendingSend) {
// The very first scoped event for a brand-new conversation can only belong
// to the currently in-flight turn — no prior same-conversation turn exists to
// be confused with. Arm the current turnToken so a terminal event (error,
// agent:end) arriving before ack/start can still properly release the send lock.
// be confused with. This only establishes conversation identity; it does not
// advance `turnPhase` — only this turn's own `agent:start` may do that (see
// the 'server/agent:start' case), and only this turn's own `error` may settle
// it directly from 'pending' (see the 'server/error' case).
return {
active: true,
state: { ...state, conversationId, pendingSend: false, armedTurnToken: state.turnToken },
state: { ...state, conversationId, pendingSend: false },
};
}
return { active: false, state: null };
@@ -413,17 +440,15 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
conversationId,
pendingSend: false,
ack: sanitizeAck(payload, conversationId),
// The very first scoped event ever received for a brand-new
// conversation can only belong to the currently in-flight turn
// no prior same-conversation turn can exist to be confused with.
armedTurnToken: state.turnToken,
// Deliberately does NOT advance `turnPhase` — an ack alone must
// never be enough to make a later `agent:end` settle this turn.
// Only this turn's own `agent:start` may do that.
};
}
if (payload.conversationId !== state.conversationId) return state;
return {
...state,
ack: sanitizeAck(payload, state.conversationId),
armedTurnToken: state.turnToken,
};
}
@@ -439,7 +464,9 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
thinkingDroppedChars: 0,
tools: [],
error: null,
armedTurnToken: resolved.state.turnToken,
// Only this turn's own accepted agent:start may move it into the
// 'active' phase that a real agent:end may later settle.
turnPhase: 'active',
};
}
@@ -539,18 +566,24 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
sending: false,
streaming: false,
approvalRequestPending: false,
armedTurnToken: null,
turnPhase: 'settled',
error: CONVERSATION_START_FAILURE,
};
}
return state;
}
const next = resolved.state;
if (isRecognizedStaleTerminal(next)) {
// A same-conversation agent:end recognized as stale must be a true
// no-op — it must not finalize the stale turn's leftover
// text/thinking into a message or reset streaming, both of which
// legitimately belong to the still in-flight later turn.
if (next.turnPhase !== 'active') {
// Only an 'active' turn (one that has received its own agent:start)
// may be settled by an agent:end. A 'pending' phase means this
// turn has not started yet — Socket.IO delivers in order and the
// Gateway always emits agent:start before a legitimate agent:end, so
// this can only be a stale/duplicate end left over from an earlier
// turn. A 'settled' phase means this turn's own end already fired —
// this is a redelivered duplicate. Either way this must be a true
// no-op: it must not finalize leftover text/thinking into a message
// or reset streaming, both of which legitimately belong to the
// still in-flight (or already-finalized) current turn.
return next;
}
const hasContent = next.text.length > 0 || next.thinking.length > 0;
@@ -569,19 +602,11 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
MAX_MESSAGES,
)
: next.messages;
// The wire carries only conversationId, not a turn id — a stale
// agent:end left over from an EARLIER turn on this SAME conversation
// is indistinguishable from this turn's own by conversationId alone.
// Only release `sending` when this turn's own accepted ack/start armed
// the current token; otherwise this is treated as a stale echo and the
// lock stays held (fail closed) so it cannot unlock a later, still
// in-flight turn.
const canRelease = next.armedTurnToken === next.turnToken;
return {
...next,
streaming: false,
sending: canRelease ? false : next.sending,
armedTurnToken: canRelease ? null : next.armedTurnToken,
sending: false,
turnPhase: 'settled',
text: '',
thinking: '',
textDroppedChars: 0,
@@ -671,19 +696,19 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
case 'server/system:reload': {
const { payload } = action;
// Computed once and reused for both `systemReload` and `manifest` —
// spreading the raw `payload` into `systemReload` first and only
// capping the copy handed to `manifest` left the raw, uncapped
// commands/skills sitting in `state.systemReload`. The sanitized
// fields are placed after the spread below so they always win.
// `systemReload` is built from exactly the SystemReloadPayload
// contract fields (commands, skills, providers, message) after
// runtime normalization — the raw payload is never spread into state,
// so a hostile/malfunctioning gateway cannot smuggle extra fields in.
const commands = capList<CommandDef>(payload.commands, MAX_MANIFEST_ITEMS);
const skills = capList<SkillCommandDef>(payload.skills, MAX_MANIFEST_ITEMS);
const providers = capStringList(payload.providers, MAX_MANIFEST_ITEMS);
return {
...state,
systemReload: {
...payload,
commands,
skills,
providers,
message: asString(payload.message, 'Commands reloaded.'),
},
manifest: {
@@ -705,31 +730,33 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
sending: false,
streaming: false,
approvalRequestPending: false,
armedTurnToken: null,
turnPhase: 'settled',
error: CONVERSATION_START_FAILURE,
};
}
return state;
}
const next = resolved.state;
if (isRecognizedStaleTerminal(next)) {
// Same true-no-op guard as `server/agent:end` above: a
// same-conversation error recognized as stale must not display/store
// its message, touch streaming, or clear approvalRequestPending —
// doing so would re-arm the approve UI for a request that belongs to
// the still in-flight later turn while the first remains outstanding.
return next;
if (next.turnPhase === 'settled') {
// No in-flight current turn: the error is still visibly surfaced,
// but there is no turn/approval lock left to mutate.
return { ...next, error: asString(payload.error, 'An error occurred.') };
}
// Same fail-closed turn-token guard as `server/agent:end` above: a
// same-conversation error left over from an earlier, already-finished
// turn must not release a later turn's still-in-flight send lock.
const canRelease = next.armedTurnToken === next.turnToken;
// A current turn-scoped error is terminal from either 'pending'
// (pre-ack — the Gateway can emit a session-creation error before
// ack) or 'active' (a prompt error after ack/start) state. Once an
// earlier turn on this conversation has already fully settled (its
// own terminal event processed, moving turnPhase to 'settled' before
// this turn was even sent), ordered Socket.IO delivery guarantees
// nothing further remains in flight for it — so a scoped error seen
// while turnPhase is 'pending' or 'active' can only belong to THIS
// turn, and must settle and release the lock.
return {
...next,
error: asString(payload.error, 'An error occurred.'),
streaming: false,
sending: canRelease ? false : next.sending,
armedTurnToken: canRelease ? null : next.armedTurnToken,
sending: false,
turnPhase: 'settled',
// A turn-scoped error also invalidates any approval request awaiting
// a response — the gateway that just errored is unlikely to still
// answer it, and the synchronous approveLockRef mirrors this field.
@@ -752,12 +779,11 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
messageSeq: state.messageSeq + 1,
error: null,
sending: true,
// Sending a new turn mints a fresh token and immediately clears
// terminal eligibility — only THIS turn's own accepted ack/start may
// arm it, so a same-conversation terminal left over from the turn
// that just finished can never be mistaken for this one's.
turnToken: state.turnToken + 1,
armedTurnToken: null,
// Sending a new turn begins a fresh 'pending' phase — only THIS
// turn's own accepted agent:start may move it to 'active', so a
// same-conversation agent:end left over from the turn that just
// finished can never be mistaken for this one's.
turnPhase: 'pending',
pendingSend: state.conversationId === null ? true : state.pendingSend,
};
}
@@ -771,7 +797,7 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
sending: false,
pendingSend: false,
approvalRequestPending: false,
armedTurnToken: null,
turnPhase: 'settled',
};
}
@@ -792,6 +818,14 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
return { ...state, approval: null, pendingApproval: null };
}
case 'local/approval-saturated': {
// The non-evicting executed-approval cache is full and this is a
// distinct, never-before-seen approval — it is denied (fail closed),
// but unlike a plain replay of an already-executed ID, this must be
// visibly surfaced rather than silently dropped.
return { ...state, approval: null, pendingApproval: null, error: APPROVAL_LIMIT_MESSAGE };
}
default:
return state;
}
@@ -973,9 +1007,10 @@ export function useChatConnection(): ChatConnectionValue {
// unmounted (lifecycle reset), not recoverable by re-approving — a
// false positive/availability cost but a genuine replay of any ID
// ever seen by this hook can never execute a second time. Consuming
// (rather than silently no-op'ing) releases the UI lock so the
// denial is visible/recoverable by remounting.
dispatch({ type: 'local/consume-approval' });
// (rather than silently no-op'ing) releases the UI lock, and unlike
// the plain-replay branch above, sets a visible error notice so the
// denial is surfaced rather than silently dropped.
dispatch({ type: 'local/approval-saturated' });
return;
}
executedApprovalIds.current.add(approval.approvalId);