teapot-coding-agent 0.25.2 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -91,21 +91,68 @@ function mimeFor(rel, kind) {
91
91
  }
92
92
  export function buildApp(master) {
93
93
  const app = new Hono();
94
+ /**
95
+ * Cap on concurrent `/api/ws` event-stream clients (#79).
96
+ *
97
+ * Each one holds a listener on the global bus and receives every agent's
98
+ * events, so this bounds real fan-out. 64 is far above any plausible number of
99
+ * open tabs on a LAN and low enough that a runaway client cannot exhaust memory.
100
+ */
101
+ const MAX_WS_CLIENTS = 64;
94
102
  // Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
95
103
  // the config's `password` field. Static files stay public; only /api/* is
96
- // gated. WebSocket handshakes can't send headers → they accept ?token=.
104
+ // gated.
97
105
  const apiToken = process.env.TEAPOT_API_TOKEN || master.config.password || "";
106
+ /**
107
+ * Routes where `?token=` is accepted (#79).
108
+ *
109
+ * A browser WebSocket handshake cannot send an Authorization header, so the
110
+ * query parameter is the ONLY way for the UI to authenticate those two
111
+ * sockets. It used to be accepted on EVERY `/api/*` route, which put the
112
+ * secret into proxy access logs, `Referer` headers and browser history for
113
+ * requests that never needed it — `DELETE /api/agents/:id`, `PUT
114
+ * /api/config`, `POST /api/update/restart`.
115
+ *
116
+ * So this narrows where the secret can LEAK rather than changing who is
117
+ * authenticated: every route still accepts the header, and only these two
118
+ * also accept the query form.
119
+ */
120
+ const tokenInQueryOk = (path) => path === "/api/ws" || /^\/api\/agents\/[^/]+\/term$/.test(path);
98
121
  if (apiToken)
99
122
  app.use("/api/*", async (c, next) => {
100
123
  const h = c.req.header("authorization");
101
124
  const provided = h?.startsWith("Bearer ") ? h.slice(7) : undefined;
102
- const q = c.req.query("token");
103
- if ((provided && provided === apiToken) || (q && q === apiToken))
125
+ if (provided && provided === apiToken)
104
126
  return next();
127
+ if (tokenInQueryOk(c.req.path)) {
128
+ const q = c.req.query("token");
129
+ if (q && q === apiToken)
130
+ return next();
131
+ }
105
132
  return c.json({ error: "unauthorized" }, 401);
106
133
  });
107
134
  // per-agent terminal spawn guard
108
135
  const termCounts = new Map();
136
+ /**
137
+ * Live `/api/ws` event-stream connections (#79).
138
+ *
139
+ * Every one of these receives EVERY agent's events, so the count is a direct
140
+ * multiplier on fan-out, and `bus.setMaxListeners(1000)` only silences a
141
+ * warning — it does not bound anything. Capped so a stuck or hostile client
142
+ * cannot pin unbounded memory, and paired with a liveness check below so a
143
+ * client that stops reading is reaped rather than buffered for ever.
144
+ */
145
+ let wsClients = 0;
146
+ /**
147
+ * Agent ids reserved by an in-flight create (#71).
148
+ *
149
+ * The uniqueness loop is a check, not a reservation, and `addAgent` only
150
+ * publishes the id after awaiting — so concurrent creates all computed the
151
+ * same "unique" id and all persisted an entry. Reserving before the first
152
+ * await closes the window. Entries are released as soon as the agent is
153
+ * published or the create fails, so this never grows.
154
+ */
155
+ const idReservations = new Set();
109
156
  // keyed by CHILD, not agent: one agent may have up to 10 concurrent
110
157
  // terminals, each with its own size and its own foreground program
111
158
  const termSizes = new TermSizeTracker();
@@ -118,8 +165,30 @@ export function buildApp(master) {
118
165
  app.get("/api/ws", upgradeWebSocket(() => {
119
166
  let onUpdate = null;
120
167
  let ka = null;
168
+ // set by onOpen, cleared by a pong. Declared at the connection's scope
169
+ // because onMessage is a SIBLING handler and cannot see a local in onOpen.
170
+ let awaitingPong = false;
171
+ let counted = false;
121
172
  return {
122
173
  onOpen(_evt, ws) {
174
+ // #79: refuse beyond the cap rather than accepting and buffering.
175
+ // Each connection costs a listener on the global bus and receives
176
+ // every agent's events, so this bounds real memory, not just a count.
177
+ if (wsClients >= MAX_WS_CLIENTS) {
178
+ try {
179
+ ws.send(JSON.stringify({ kind: "error", error: `too many event-stream clients (max ${MAX_WS_CLIENTS})` }));
180
+ ws.close(1013, "too many clients");
181
+ }
182
+ catch {
183
+ /* already gone */
184
+ }
185
+ return;
186
+ }
187
+ wsClients++;
188
+ counted = true;
189
+ // a client that stops reading makes ws.send buffer without bound; the
190
+ // app-level ping doubles as a liveness probe — if a ping goes
191
+ // unanswered the socket is reaped instead of accumulating
123
192
  const send = (data) => {
124
193
  try {
125
194
  ws.send(JSON.stringify(data));
@@ -141,8 +210,23 @@ export function buildApp(master) {
141
210
  send(ev);
142
211
  };
143
212
  bus.on("update", onUpdate);
144
- // app-level liveness ping every 30s
145
- ka = setInterval(() => send({ kind: "ping" }), 30_000);
213
+ // app-level liveness ping every 30s, and a reaper for a client that
214
+ // has stopped answering (#79)
215
+ ka = setInterval(() => {
216
+ if (awaitingPong) {
217
+ // the previous ping went unanswered: the client is not reading,
218
+ // so its send buffer is growing. Reap it.
219
+ try {
220
+ ws.close(1011, "unresponsive");
221
+ }
222
+ catch {
223
+ /* already gone */
224
+ }
225
+ return;
226
+ }
227
+ awaitingPong = true;
228
+ send({ kind: "ping" });
229
+ }, 30_000);
146
230
  },
147
231
  onMessage(evt, ws) {
148
232
  // clients may send {"kind":"ping"} — nothing else to do today
@@ -150,6 +234,8 @@ export function buildApp(master) {
150
234
  const m = JSON.parse(String(evt.data));
151
235
  if (m?.kind === "ping")
152
236
  ws.send(JSON.stringify({ kind: "pong" }));
237
+ if (m?.kind === "pong")
238
+ awaitingPong = false;
153
239
  }
154
240
  catch {
155
241
  /* ignore junk */
@@ -160,6 +246,10 @@ export function buildApp(master) {
160
246
  clearInterval(ka);
161
247
  if (onUpdate)
162
248
  bus.off("update", onUpdate);
249
+ // only a connection that was actually admitted owns a slot — a
250
+ // refused one must not decrement one it never took (#70's lesson)
251
+ if (counted)
252
+ wsClients = Math.max(0, wsClients - 1);
163
253
  },
164
254
  };
165
255
  }));
@@ -169,6 +259,12 @@ export function buildApp(master) {
169
259
  app.get("/api/agents/:id/term", upgradeWebSocket((c) => {
170
260
  const agentId = c.req.param("id") ?? "";
171
261
  let child = null;
262
+ // #70: whether THIS connection was admitted to the per-agent terminal
263
+ // budget. Declared per connection (not inside onOpen) because onClose is a
264
+ // sibling handler: a connection refused at the cap never incremented the
265
+ // counter, so onClose must not decrement for it — that asymmetry walked
266
+ // the counter below the live-shell count and disabled the cap entirely.
267
+ let admitted = false;
172
268
  const cleanup = () => {
173
269
  if (!child)
174
270
  return;
@@ -198,7 +294,12 @@ export function buildApp(master) {
198
294
  return;
199
295
  }
200
296
  termCounts.set(agentId, cur + 1);
297
+ admitted = true;
201
298
  if (!agent) {
299
+ // the slot was just taken and this connection owns it, so release
300
+ // it here rather than leaving onClose to work it out
301
+ termCounts.set(agentId, cur);
302
+ admitted = false;
202
303
  send({ kind: "exit", error: `no such agent: ${agentId}` });
203
304
  return;
204
305
  }
@@ -250,6 +351,11 @@ export function buildApp(master) {
250
351
  if (dead)
251
352
  termSizes.forget(dead);
252
353
  cleanup();
354
+ // #70: only an ADMITTED connection owns a slot. A connection refused
355
+ // at the cap never incremented, so decrementing for it is exactly
356
+ // what drove the counter negative and disabled the limit.
357
+ if (!admitted)
358
+ return;
253
359
  const n = (termCounts.get(agentId) ?? 1) - 1;
254
360
  if (n <= 0)
255
361
  termCounts.delete(agentId);
@@ -281,10 +387,27 @@ export function buildApp(master) {
281
387
  let n = 2;
282
388
  // ids must be unique among RUNNING agents AND persisted config entries —
283
389
  // a config-only agent (not yet loaded) would otherwise collide on addAgent
284
- while (master.agents.has(id) || master.config.agents.some((a) => a.id === id))
390
+ //
391
+ // #71: the loop below is a CHECK, not a RESERVATION, and addAgent only
392
+ // publishes the id after several awaits (mkdirSync, agent.init()). Every
393
+ // concurrent request therefore passed the check, computed the same "unique"
394
+ // id, and all persisted an entry — measured: 3 concurrent creates → 3 config
395
+ // entries, and with 5 concurrent spawns each minting its own session dir,
396
+ // `resolveSessionDir` binds to the first entry on restart and the rest are
397
+ // orphaned.
398
+ //
399
+ // So the id is RESERVED synchronously, before the first await. The set is
400
+ // per-process and only guards the window; a real agent arriving through
401
+ // another path still collides correctly via addAgent's own check.
402
+ while ((master.agents.has(id) || master.config.agents.some((a) => a.id === id) || idReservations.has(id)))
285
403
  id = `${base.slice(0, 38)}-${n++}`;
404
+ idReservations.add(id);
405
+ const releaseId = () => idReservations.delete(id);
286
406
  try {
407
+ // the reservation covers the awaits inside addAgent; once the agent is
408
+ // published the reservation is redundant and must be released
287
409
  const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, { persist: true, fresh: true });
410
+ releaseId();
288
411
  // NOTE: creation no longer auto-starts an LLM loop — the agent sits in
289
412
  // "stopped" (a lazy, zero-cost session) until the operator sends the
290
413
  // first prompt or presses ▶ start. The web UI relies on this.
@@ -293,6 +416,7 @@ export function buildApp(master) {
293
416
  return c.json({ ok: true, agent: agent.snapshot() });
294
417
  }
295
418
  catch (err) {
419
+ releaseId(); // never leak a reservation on failure
296
420
  return c.json({ error: err.message }, 400);
297
421
  }
298
422
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.25.2",
3
+ "version": "0.26.1",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",