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.
- package/dist/agent/agent.js +201 -12
- package/dist/agent/tools.js +127 -16
- package/dist/master.js +55 -3
- package/dist/server/api.js +130 -6
- package/package.json +1 -1
- package/public/assets/index-Cm_AmGsg.js +30 -0
- package/public/assets/index-Cm_AmGsg.js.map +1 -0
- package/public/assets/{shiki-N1Dpty1W.js → shiki-OoTC7Aoo.js} +2 -2
- package/public/assets/{shiki-N1Dpty1W.js.map → shiki-OoTC7Aoo.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-DDx2AmuD.js +0 -30
- package/public/assets/index-DDx2AmuD.js.map +0 -1
package/dist/server/api.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|