fix(web,gateway): close P3 re-review#3 findings — sanitize command errors, harden turn-lock & caps
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user