teapot-coding-agent 0.37.59 → 0.37.61

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.
Files changed (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
@@ -1,44 +1,8 @@
1
- /**
2
- * OAuth for MCP servers (#67), on the official client's `OAuthClientProvider`.
3
- *
4
- * The hand-written discovery/PKCE/token-exchange layer is GONE. What remains is the part
5
- * the SDK cannot own: **where the grant is stored**, and **how the operator completes the
6
- * browser step**. The SDK calls this interface at each stage:
7
- *
8
- * clientMetadata() → who we are, for dynamic registration
9
- * clientInformation() → the client id/secret obtained earlier, or undefined
10
- * saveClientInformation() → persist what registration returned
11
- * tokens() → the current grant, or undefined
12
- * saveTokens() → persist a token PAIR
13
- * redirectToAuthorization() → the URL to open in a browser
14
- * saveCodeVerifier() → keep the PKCE verifier for the exchange
15
- * codeVerifier() → hand it back afterwards
16
- *
17
- * Storage choices, unchanged from the previous implementation because they are the
18
- * security-relevant part and were already right:
19
- * - **access token: memory only.** It is short-lived and bound to a fingerprint of the
20
- * client credentials, so a changed client id can never reuse a stale token.
21
- * - **refresh token + client info: on disk at 0600**, beside the data dir, never in
22
- * `config.json` (which operators copy around).
23
- *
24
- * The redirect is a real LOOPBACK listener: this is a local harness with a browser UI, so
25
- * the tab can close itself. The reference implementations (easywebui, deepseek-pp) run
26
- * serverless and need a host-provided browser identity instead.
27
- */
28
1
  import { createServer } from "node:http";
29
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
30
3
  import path from "node:path";
31
- /** how long a started flow waits for the browser */
32
4
  export const FLOW_TIMEOUT_MS = 5 * 60_000;
33
- /**
34
- * One server's OAuth state.
35
- *
36
- * The pieces the SDK needs and the pieces the UI needs live together because they are
37
- * two views of the same grant: a token saved here is what `status()` reports as
38
- * authorized.
39
- */
40
5
  export class ServerAuth {
41
- /** the URL the browser must be sent to, set by `redirectToAuthorization` */
42
6
  authUrl;
43
7
  verifier;
44
8
  flow = { phase: "idle" };
@@ -47,60 +11,29 @@ export class ServerAuth {
47
11
  server;
48
12
  redirectUrl;
49
13
  persist;
50
- // Explicit fields, NOT parameter properties (`constructor(readonly x)`): Node's
51
- // type-stripping loader refuses that syntax and `pnpm test` runs the sources directly
52
- // (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX).
53
14
  constructor(server, redirectUrl, persist) {
54
15
  this.server = server;
55
16
  this.redirectUrl = redirectUrl;
56
17
  this.persist = persist;
57
18
  }
58
- /** load persisted state (called once, before the server is connected) */
59
19
  async hydrate(data) {
60
20
  if (!data)
61
21
  return;
62
22
  this.store = { ...data };
63
- /**
64
- * Restore the PKCE verifier alongside the rest.
65
- *
66
- * `codeVerifier()` reads the in-memory field and throws when it is empty, so a rebuilt instance
67
- * would refuse the exchange even though the verifier is on disk. MEASURED cause of Notion's
68
- * `invalid_grant`: `saveCodeVerifier` never persisted, and even once it does, nothing read the
69
- * value back into the field that the exchange actually uses.
70
- */
71
23
  if (data.codeVerifier)
72
24
  this.verifier = data.codeVerifier;
73
25
  }
74
- /** the persisted payload, for the store */
75
26
  snapshot() {
76
27
  return { ...this.store };
77
28
  }
78
- // ---- OAuthClientProvider ----
79
29
  get clientMetadata() {
80
30
  return {
81
31
  client_name: "teapot",
82
- // a local harness is a PUBLIC client: it cannot keep a secret meaningful, and the
83
- // SDK sends `token_endpoint_auth_method: none` for exactly this shape
84
32
  redirect_uris: [this.redirectUrl],
85
33
  grant_types: ["authorization_code", "refresh_token"],
86
34
  response_types: ["code"],
87
35
  };
88
36
  }
89
- /**
90
- * Does a recorded redirect URI describe THIS loopback listener?
91
- *
92
- * The callback binds an EPHEMERAL port (`server.listen(0)`), so `redirectUrl` carries a different
93
- * port on every attempt while the registration is bound to the one it was created with. Comparing
94
- * them literally means "never matches", which re-registers on every flow — and re-registering
95
- * issues a NEW client_id, which `saveClientInformation` correctly treats as invalidating the
96
- * grant. MEASURED: Canva's store had `clientId`/`clientInformation` and NO `accessToken`/
97
- * `refreshToken`, i.e. the authorization kept working and the token was thrown away by this loop.
98
- *
99
- * A loopback port is an implementation detail of the listener, not an identity of the client: the
100
- * authorization server redirects to whatever port the request named, and RFC 8252 §7.3 explicitly
101
- * allows a loopback redirect to use any port. So the comparison ignores it, and only the
102
- * scheme/host/path have to agree.
103
- */
104
37
  redirectMatches(recorded) {
105
38
  if (recorded === this.redirectUrl)
106
39
  return true;
@@ -110,52 +43,24 @@ export class ServerAuth {
110
43
  const loopback = (h) => h === "127.0.0.1" || h === "::1" || h === "localhost";
111
44
  if (!loopback(a.hostname) || !loopback(b.hostname))
112
45
  return false;
113
- // any loopback host is interchangeable with any other, and the port is the listener's
114
46
  return a.protocol === b.protocol && a.pathname === b.pathname;
115
47
  }
116
48
  catch {
117
- return false; // unparseable record: cannot claim a match
49
+ return false;
118
50
  }
119
51
  }
120
52
  clientInformation() {
121
53
  if (!this.store.clientId)
122
54
  return undefined;
123
- /**
124
- * A registration made with a DIFFERENT redirect URI cannot be reused.
125
- *
126
- * The callback listens on an ephemeral port (`server.listen(0)`), so `redirectUrl` carries a
127
- * new port on every attempt. A registration is bound to the `redirect_uris` it was created
128
- * with, and the authorize request must match — so a stored registration from another port would
129
- * have to be re-made.
130
- *
131
- * MEASURED from this machine's real store: `clientInformation.redirect_uris` was
132
- * `["http://127.0.0.1/callback"]` — NO PORT, because it was registered from the placeholder
133
- * provider in `authFor()` — while every flow runs on `http://127.0.0.1:<port>/callback`. The
134
- * browser completed the authorization, `codeVerifier` was persisted, and no `access_token` was
135
- * ever written: the exchange never got past the URI check.
136
- *
137
- * Returning `undefined` makes the SDK register afresh against the CURRENT redirect URI, which
138
- * is the only value that can match. The client id/secret are kept, so anything the server bound
139
- * to them survives; only the redirect list is re-established.
140
- */
141
55
  const stored = this.store.clientInformation;
142
56
  if (stored) {
143
57
  const uris = Array.isArray(stored.redirect_uris)
144
58
  ? stored.redirect_uris
145
59
  : [];
146
- /**
147
- * Only a RECORDED mismatch forces a re-registration.
148
- *
149
- * An empty list means the server never told us which URI it bound — some registration
150
- * responses omit `redirect_uris` entirely, and a record written by an older teapot has none.
151
- * Treating "unknown" as "wrong" would re-register on every single flow and lose whatever the
152
- * server bound to the client id, so absence keeps the registration.
153
- */
154
60
  if (uris.length === 0)
155
61
  return stored;
156
62
  if (uris.some((u) => this.redirectMatches(String(u))))
157
63
  return stored;
158
- // a recorded URI that cannot match this run: re-register rather than send one it never saw
159
64
  return undefined;
160
65
  }
161
66
  return {
@@ -163,30 +68,11 @@ export class ServerAuth {
163
68
  ...(this.store.clientSecret ? { client_secret: this.store.clientSecret } : {}),
164
69
  };
165
70
  }
166
- /**
167
- * Is the STORED registration unusable for this run?
168
- *
169
- * True when a redirect URI was recorded and it is not the one this flow listens on — the same
170
- * condition `clientInformation()` acts on, factored out so `tokens()` cannot disagree with it.
171
- */
172
- /**
173
- * Is the STORED registration unusable for this run?
174
- *
175
- * The SAME comparison `clientInformation()` uses (`redirectMatches`), factored out so `tokens()`
176
- * cannot disagree with it about one record. Everything below used to be re-derived here, and the
177
- * two definitions drifted twice — once reporting stale where the other reused, once the reverse.
178
- *
179
- * - no recorded list → not stale. "Unknown" is not "wrong": some registration responses omit
180
- * `redirect_uris`, and treating absence as a mismatch re-registers on every flow.
181
- * - a loopback record → matches whatever port this run listens on (see `redirectMatches`), which
182
- * is what stops the re-register → new client_id → grant-dropped loop.
183
- * - anything else → stale: a record naming a different host cannot be reused.
184
- */
185
71
  registrationIsStale() {
186
72
  const stored = this.store.clientInformation;
187
73
  const uris = Array.isArray(stored?.redirect_uris) ? stored.redirect_uris : [];
188
74
  if (uris.length === 0)
189
- return false; // unknown is not wrong
75
+ return false;
190
76
  return !uris.some((u) => this.redirectMatches(String(u)));
191
77
  }
192
78
  async saveClientInformation(info, _ctx) {
@@ -196,55 +82,15 @@ export class ServerAuth {
196
82
  this.store.clientId = i.client_id;
197
83
  if (typeof i?.client_secret === "string")
198
84
  this.store.clientSecret = i.client_secret;
199
- /**
200
- * A NEW client id INVALIDATES the grant issued to the old one.
201
- *
202
- * `clientInformation()` returns undefined when the stored registration cannot match this run's
203
- * redirect URI, which makes the SDK register afresh. Keeping the old tokens across that is fatal:
204
- * the refresh request authenticates as the NEW client while presenting a refresh token the
205
- * authorization server bound to the OLD one, and the server answers
206
- * `invalid_grant: Client ID mismatch` — MEASURED against both Notion and Canva, where every
207
- * reconnect failed for exactly this reason while the store still held a valid-looking token.
208
- *
209
- * Dropping them turns that into an honest "needs a grant": the panel shows 🔑 authorize, and
210
- * completing it issues a token pair for the client that actually exists.
211
- */
212
85
  if (previous && typeof i?.client_id === "string" && i.client_id !== previous) {
213
86
  this.store.accessToken = undefined;
214
87
  this.store.refreshToken = undefined;
215
88
  this.store.expiresAt = undefined;
216
89
  }
217
- // keep the whole payload: a future field (a client id issued with metadata) survives
218
90
  this.store.clientInformation = info;
219
91
  await this.persist(this.server, this.store);
220
92
  }
221
93
  tokens(_ctx) {
222
- /**
223
- * A grant whose client can no longer be reused must not be presented.
224
- *
225
- * `clientInformation()` returns undefined when the stored registration cannot match this run's
226
- * redirect URI, which makes the SDK register afresh — but `tokens()` would still hand back the
227
- * token pair the OLD client obtained. The refresh then authenticates as the new client while
228
- * presenting the old client's refresh token, and the server answers
229
- * `invalid_grant: Client ID mismatch` (MEASURED against Notion and Canva: every reconnect failed
230
- * this way while the store held a token that looked valid).
231
- *
232
- * Returning nothing makes the flow an honest "needs a grant" instead: the panel offers 🔑, and
233
- * completing it issues a pair for the client that actually exists.
234
- */
235
- /**
236
- * A registration we cannot vouch for means DO NOT PRESENT the grant — never DELETE it.
237
- *
238
- * This used to clear the stored tokens here, and clearing is not the same as declining: the
239
- * store persists on the next write, so an "I cannot verify this" answer from a placeholder
240
- * provider ERASED a working grant minutes after it was obtained (MEASURED on this machine:
241
- * both Notion's and Canva's `accessToken`/`refreshToken` were gone from mcp-oauth.json while the
242
- * authorizations themselves had succeeded).
243
- *
244
- * Declining is reversible — the next connect that CAN verify the registration reads the tokens
245
- * again. Erasing is not: it costs the operator a full re-authorization for a transient
246
- * uncertainty.
247
- */
248
94
  if (this.store.accessToken && this.registrationIsStale())
249
95
  return undefined;
250
96
  if (!this.store.accessToken)
@@ -259,8 +105,6 @@ export class ServerAuth {
259
105
  }
260
106
  async saveTokens(tokens, _ctx) {
261
107
  this.store.accessToken = tokens.access_token;
262
- // PERSIST A ROTATED REFRESH TOKEN IMMEDIATELY: a server that rotates invalidates the
263
- // previous one, so keeping the old token breaks the NEXT refresh with invalid_grant.
264
108
  if (tokens.refresh_token)
265
109
  this.store.refreshToken = tokens.refresh_token;
266
110
  if (typeof tokens.expires_in === "number" && tokens.expires_in > 0)
@@ -277,75 +121,17 @@ export class ServerAuth {
277
121
  async saveCodeVerifier(verifier) {
278
122
  this.verifier = verifier;
279
123
  this.store.codeVerifier = verifier;
280
- /**
281
- * PERSIST it, or the exchange cannot complete after the instance is rebuilt.
282
- *
283
- * The verifier is the secret half of PKCE: the authorization request carries its SHA-256
284
- * challenge and the token request must present the verifier itself. Keeping it only in memory
285
- * means any path that constructs a fresh `ServerAuth` — a restart while the browser tab is open,
286
- * a reconnect, a second `authFor()` — exchanges with no verifier and the authorization server
287
- * answers `invalid_grant`.
288
- *
289
- * MEASURED against the real store: `clientId`/`clientSecret`/`clientInformation` were written
290
- * but `codeVerifier` was ABSENT, because this method was the one mutation that never persisted.
291
- * Notion's `/token` returns `invalid_grant` for exactly this shape.
292
- */
293
124
  await this.persist(this.server, this.store);
294
125
  }
295
126
  codeVerifier() {
296
- /**
297
- * Fall back to the STORE, not just the in-memory field.
298
- *
299
- * The field is populated by `saveCodeVerifier` (same instance) or by `hydrate` (a rebuilt one).
300
- * Reading the store as a last resort means an instance that was hydrated before this field was
301
- * restored still completes the exchange instead of throwing — the failure mode is a silent
302
- * `invalid_grant` from the server, so being generous here costs nothing.
303
- */
304
127
  const v = this.verifier ?? this.store.codeVerifier;
305
128
  if (!v)
306
129
  throw new Error("no PKCE verifier — the authorization flow did not start here");
307
130
  return v;
308
131
  }
309
- /**
310
- * The SDK's discovery cache — the reason Notion's exchange never completed.
311
- *
312
- * The OAuth flow is split across TWO transport instances: the one that hits the 401 and
313
- * redirects the browser, and the one built after the callback arrives. The SDK's own contract
314
- * says this state "MUST persist with the same durability as `codeVerifier` (survives the
315
- * redirect round-trip)", because the issuer recorded at redirect time is what the authorization
316
- * response's `iss` is validated against (RFC 9207) when the code is redeemed.
317
- *
318
- * MEASURED on this machine's real store after a BROWSER-COMPLETED Notion authorization:
319
- * `codeVerifier` present, `clientInformation.redirect_uris` a real ephemeral port
320
- * (`http://127.0.0.1:37209/callback`), and no `accessToken` — the redeeming instance had
321
- * re-discovered a different issuer binding, so the exchange never produced tokens. Both earlier
322
- * fixes (persist the verifier, re-register on a redirect mismatch) were necessary and neither was
323
- * sufficient.
324
- */
325
132
  discoveryState() {
326
133
  return this.store.discoveryState;
327
134
  }
328
- /**
329
- * The issuer this client believes it is talking to, from our OWN discovery record.
330
- *
331
- * Needed because the SDK treats a missing `iss` on the callback as an ATTACK when the server's
332
- * metadata declares `authorization_response_iss_parameter_supported` (RFC 9207), and throws
333
- * `IssuerMismatchError` before the code is ever redeemed.
334
- *
335
- * Notion declares that support and then does not send `iss`. MEASURED against
336
- * `mcp.notion.com/.well-known/oauth-authorization-server`:
337
- * `authorization_response_iss_parameter_supported: true`, while its real redirect carries only
338
- * `code` and `state`. Reproduced directly against the SDK with this machine's stored grant:
339
- * `IssuerMismatchError: expected "https://mcp.notion.com", received undefined` — i.e. every
340
- * browser-completed authorization was discarded at the last step, which is the "コールバック踏んだ
341
- * のに Unauthorized" report.
342
- *
343
- * Supplying the issuer ourselves is sound here: the code arrived on OUR loopback listener, and
344
- * this value comes from the discovery document we fetched before sending the browser out. RFC 9207
345
- * exists to stop a MIX-UP attack, and there is no mix-up to detect when the client already knows
346
- * which authorization server it directed the user to. The SDK's own check
347
- * (`iss !== expectedIssuer`) still runs, so a genuinely wrong issuer is still rejected.
348
- */
349
135
  expectedIssuer() {
350
136
  const meta = this.store.discoveryState?.authorizationServerMetadata;
351
137
  if (typeof meta?.issuer === "string" && meta.issuer)
@@ -357,11 +143,8 @@ export class ServerAuth {
357
143
  if (!state || typeof state !== "object")
358
144
  return;
359
145
  this.store.discoveryState = state;
360
- // same durability as the verifier: the instance that redeems the code is not this one
361
146
  await this.persist(this.server, this.store);
362
147
  }
363
- // ---- teapot-facing ----
364
- /** the url the browser must open, once `redirectToAuthorization` has been called */
365
148
  pendingUrl() {
366
149
  return this.flow.phase === "authorizing" ? this.flow.url : undefined;
367
150
  }
@@ -369,7 +152,6 @@ export class ServerAuth {
369
152
  this.lastError = message;
370
153
  this.flow = { phase: "error", message };
371
154
  }
372
- /** drop the grant (disconnect) */
373
155
  async clear() {
374
156
  this.store = {};
375
157
  this.verifier = undefined;
@@ -380,7 +162,6 @@ export class ServerAuth {
380
162
  }
381
163
  status() {
382
164
  const expiresAt = this.store.expiresAt ?? null;
383
- // a token with no advertised expiry counts as usable; the SDK refreshes on 401 anyway
384
165
  const fresh = Boolean(this.store.accessToken) && (expiresAt === null || Date.now() < expiresAt - 60_000);
385
166
  return {
386
167
  pending: this.flow.phase === "authorizing",
@@ -393,13 +174,6 @@ export class ServerAuth {
393
174
  };
394
175
  }
395
176
  }
396
- /**
397
- * The loopback listener an OAuth flow needs.
398
- *
399
- * Bound to 127.0.0.1, never 0.0.0.0: this endpoint accepts an authorization code and must
400
- * not be reachable from the network. The `state` check lives in the caller (the SDK
401
- * generates and verifies it), so this only has to deliver `code` and `iss`.
402
- */
403
177
  export async function listenForCallback() {
404
178
  return await new Promise((resolve, reject) => {
405
179
  const server = createServer();
@@ -421,7 +195,6 @@ export async function listenForCallback() {
421
195
  return;
422
196
  }
423
197
  if (!code) {
424
- // a favicon or a stray probe: keep listening rather than failing the flow
425
198
  res.writeHead(404, { "content-type": "text/plain" }).end("not the oauth callback");
426
199
  return;
427
200
  }
@@ -458,24 +231,6 @@ export async function listenForCallback() {
458
231
  });
459
232
  });
460
233
  }
461
- /** the page the operator sees in the callback tab */
462
- /**
463
- * The page the browser lands on after authorizing.
464
- *
465
- * ## Why it does not close itself any more
466
- *
467
- * It used to end with `setTimeout(() => window.close(), 1200)`. Reported as "Notionから認証終わって
468
- * http://127.0.0.1:41621/callback?code=xxx に飛ぶはずなのに、dev toolsを見るとリダイレクトされてる
469
- * んだけど、ブラウザ画面ではリダイレクトされてない(謎)" — the redirect DOES happen, the page renders,
470
- * and then it vanishes on its own. Nothing in DevTools distinguishes "loaded and closed itself" from
471
- * "never navigated", so the symptom reads as a broken callback.
472
- *
473
- * It also only works for a tab a script opened: `window.close()` silently fails on a
474
- * user-navigated tab, so the behaviour differed by browser and by how the flow started.
475
- *
476
- * Saying what happened and leaving the tab alone is both honest and unambiguous. The operator
477
- * closes it when they are done reading.
478
- */
479
234
  function page(title, body) {
480
235
  return `<!doctype html><meta charset="utf-8"><title>${title}</title>
481
236
  <style>body{font:15px system-ui;margin:12vh auto;max-width:34rem;text-align:center;color:#222}
@@ -483,12 +238,6 @@ h1{font-size:1.1rem}p{color:#555}</style>
483
238
  <h1>${title}</h1><p>${body}</p>
484
239
  <p>You can close this tab and return to teapot.</p>`;
485
240
  }
486
- /**
487
- * Grants for every server, persisted together.
488
- *
489
- * One file rather than one per server: the set is small, and a single atomic write keeps
490
- * a rotation from racing a second write.
491
- */
492
241
  export class McpAuthStore {
493
242
  file;
494
243
  cache = null;
@@ -500,7 +249,6 @@ export class McpAuthStore {
500
249
  return this.cache;
501
250
  try {
502
251
  const raw = JSON.parse(await readFile(this.file, "utf8"));
503
- // tolerate hand edits: keep only entries that look like ours
504
252
  const out = {};
505
253
  for (const [k, v] of Object.entries(raw ?? {}))
506
254
  if (v && typeof v === "object")
@@ -523,7 +271,6 @@ export class McpAuthStore {
523
271
  await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
524
272
  }
525
273
  catch {
526
- /* a failed write must not break a live session */
527
274
  }
528
275
  }
529
276
  }