toolroll 0.9.48 → 0.9.49

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.49 — 2026-10-07
4
+
5
+ - **Tokens keep their limits.** A token's scope and project limits now hold
6
+ on every remote route.
7
+ - **Browser login for step-ups.** Approvals and other password step-ups need
8
+ a browser login. OAuth refuses plain HTTP off loopback or your tailnet.
9
+ - **Request budgets everywhere.** Budgets cover every remote route, and fake
10
+ account names can't lock anyone out.
11
+
3
12
  ## 0.9.48 — 2026-10-07
4
13
 
5
14
  - **Instant live views.** Changes reach open pages in about 2 ms (was about
@@ -94,20 +94,20 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
95
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
96
  export declare function desktopUpdateStatus(stateDir: string): {
97
- version?: never;
97
+ error?: never;
98
98
  active: boolean;
99
99
  phase: string;
100
100
  detail: string;
101
101
  running: boolean;
102
102
  canResume: boolean;
103
103
  canCancel: boolean;
104
- error?: never;
105
104
  automaticRecovery?: never;
106
105
  recoveryAttempts?: never;
107
106
  recoveryRepairs?: never;
108
107
  wasRunning?: never;
109
108
  backupPath?: never;
110
109
  updateId?: never;
110
+ version?: never;
111
111
  buildId?: never;
112
112
  workDir?: never;
113
113
  } | {
@@ -27,5 +27,6 @@ export type McpHttpOptions = {
27
27
  runAs?: RunOperateAs;
28
28
  /** A person token's request budget (request-budget.ts), charged once per request before its body is read. */
29
29
  admit?: (person: Person) => Admission;
30
+ requestBudgetClock?: () => number;
30
31
  };
31
32
  export declare function createMcpHttp(options: McpHttpOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
package/dist/mcp-http.js CHANGED
@@ -15,11 +15,12 @@ import { oauthProjects, oauthTokenAllowed, RESOURCE_PATH } from "./mcp-oauth.js"
15
15
  import { authenticateCoordinator } from "./coordinator.js";
16
16
  import { LEGACY, MAX_REQUEST, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
17
17
  import { callPersonTool, personTools } from "./mcp-person.js";
18
- import { limitWords } from "./request-budget.js";
18
+ import { limitWords, SOURCE_BUDGET_DEFAULTS, SourceAdmission } from "./request-budget.js";
19
19
  /** The most decisions a coordinator credential is remembered to have read (propose_answer needs one). */
20
20
  const READ_DECISIONS_KEPT = 200;
21
21
  export function createMcpHttp(options) {
22
22
  const { store } = options;
23
+ const coordinatorBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.coordinator, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
23
24
  /** Stdio remembers per connection which decisions a coordinator read; stateless HTTP remembers it per credential. */
24
25
  const readDecisions = new Map();
25
26
  const send = (response, status, body, headers = {}) => {
@@ -110,9 +111,10 @@ export function createMcpHttp(options) {
110
111
  const metadata = options.resourceMetadata?.(request) ?? null;
111
112
  return refuse(response, 401, "sign in with your API token (Authorization: Bearer so_…) or a coordinator credential — passwords and cookies are not accepted here", { "www-authenticate": `Bearer realm="toolroll-mcp"${metadata === null ? "" : `, resource_metadata="${metadata}"`}` });
112
113
  }
113
- // A person's token is counted once per request, notifications included; a coordinator keeps its own proposal limit.
114
- if (caller.kind === "person" && options.admit !== undefined) {
115
- const admitted = options.admit(caller.person);
114
+ // Count every authenticated request once, including notifications. Coordinator proposal limits still apply
115
+ // separately inside tool transactions; this admission also bounds their reads and malformed requests.
116
+ {
117
+ const admitted = caller.kind === "person" ? options.admit?.(caller.person) ?? { ok: true } : coordinatorBudget.admit(caller.cid);
116
118
  if (!admitted.ok && admitted.status === 503)
117
119
  return refuse(response, 503, "request limits could not be checked; nothing ran — try again shortly");
118
120
  if (!admitted.ok) {
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import type { IncomingMessage, ServerResponse } from "node:http";
23
23
  import type { Store } from "./store.js";
24
+ import { type Admission } from "./request-budget.js";
24
25
  /** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
25
26
  export declare const OAUTH_TIMES: {
26
27
  readonly requestMs: number;
@@ -71,6 +72,8 @@ export type OAuthHttpOptions = {
71
72
  confirm: (session: OAuthSession, typed: string) => boolean;
72
73
  /** The projects this person may choose: each a project the server knows that they can use now. */
73
74
  projectsFor: (account: string) => string[];
75
+ /** The token exchange's budget by source (request-budget.ts): charged once per request, before its body is read or any code or refresh secret is spent. */
76
+ admitToken?: (request: IncomingMessage) => Admission;
74
77
  };
75
78
  /**
76
79
  * A redirect a client may register: https anywhere, or http on 127.0.0.1 / [::1] (RFC 8252 §7.3), with no
package/dist/mcp-oauth.js CHANGED
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
23
23
  import { hashSecret, mintApiToken } from "./api-tokens.js";
24
+ import { limitWords } from "./request-budget.js";
24
25
  import { PKCE_CHALLENGE, readOAuthRegistration, readOAuthTokenRequest } from "./contracts/mcp-oauth.js";
25
26
  /** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
26
27
  export const OAUTH_TIMES = { requestMs: 10 * 60_000, codeMs: 60_000, accessSeconds: 3600, grantDays: 30 };
@@ -350,6 +351,13 @@ export function createOAuthHttp(options) {
350
351
  async function token(request, response, origin) {
351
352
  if (request.method !== "POST")
352
353
  return oauthError(response, 405, "invalid_request", "Use POST.");
354
+ const admitted = options.admitToken === undefined ? { ok: true } : options.admitToken(request);
355
+ if (!admitted.ok) {
356
+ request.resume();
357
+ if (admitted.status === 503)
358
+ return oauthError(response, 503, "temporarily_unavailable", "Request limits could not be checked; nothing was issued. Try again shortly.");
359
+ return json(response, 429, { error: "temporarily_unavailable", error_description: limitWords(admitted.limit, admitted.retryAfter) }, { "retry-after": String(admitted.retryAfter) });
360
+ }
353
361
  if (media(request) !== "application/x-www-form-urlencoded")
354
362
  return oauthError(response, 400, "invalid_request", "Send application/x-www-form-urlencoded.");
355
363
  const text = await readBody(request);
@@ -42,7 +42,9 @@ export declare function transportOf(request: {
42
42
  forwardedProto: string | string[] | undefined;
43
43
  forwarded: string | string[] | undefined;
44
44
  }): Transport;
45
- /** Requests that carry, or exist to carry, an API token: the remote CLI, the MCP gateway, and any bearer credential. */
45
+ /** The OAuth endpoints that hand out, consent to or take back a token (mcp-oauth.ts; revoke is reserved). Discovery stays public. */
46
+ export declare const OAUTH_TOKEN_PATHS: ReadonlySet<string>;
47
+ /** Requests that carry, or exist to carry, an API token: the remote CLI, the MCP gateway, MCP sign-in, and any bearer credential. */
46
48
  export declare function carriesToken(pathname: string, authorization: string | undefined): boolean;
47
49
  /** The refusal for a token sent over plain HTTP from outside this computer and the tailnet, else null. */
48
50
  export declare function plainHttpRefusal(transport: Transport, pathname: string, authorization: string | undefined): string | null;
@@ -69,10 +69,14 @@ export function transportOf(request) {
69
69
  return { source: "unknown", https, privatePath: false };
70
70
  return { source, https, privatePath: isLoopbackPeer(source) || isTailnetAddress(source) };
71
71
  }
72
- /** Requests that carry, or exist to carry, an API token: the remote CLI, the MCP gateway, and any bearer credential. */
72
+ /** The OAuth endpoints that hand out, consent to or take back a token (mcp-oauth.ts; revoke is reserved). Discovery stays public. */
73
+ export const OAUTH_TOKEN_PATHS = new Set(["/oauth/authorize", "/oauth/token", "/oauth/register", "/oauth/revoke"]);
74
+ /** Requests that carry, or exist to carry, an API token: the remote CLI, the MCP gateway, MCP sign-in, and any bearer credential. */
73
75
  export function carriesToken(pathname, authorization) {
74
76
  if (pathname === "/api/cli" || pathname.startsWith("/api/cli/") || pathname === "/mcp" || pathname.startsWith("/mcp/"))
75
77
  return true;
78
+ if (OAUTH_TOKEN_PATHS.has(pathname.replace(/\/+$/, "") || "/"))
79
+ return true;
76
80
  return authorization !== undefined && /^bearer\s/i.test(authorization);
77
81
  }
78
82
  /** The refusal for a token sent over plain HTTP from outside this computer and the tailnet, else null. */
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Request budgets for person API tokens (v112): how many requests one `so_` token may make to `/api/cli` and `/mcp`.
3
- * One budget per token, keyed by its api_token row id (never the secret, its name or an address), shared by both
2
+ * Request budgets for person API tokens (v112): how many requests one `so_` token may make across all accepting routes.
3
+ * One budget per token, keyed by its api_token row id (never the secret, its name or an address), shared by those
4
4
  * routes. A read token gets 120 requests in any sliding minute, an act token 30, and either 10,000 in a rolling day;
5
5
  * an instance operator may override any of these for the installation or for one token in the console (step-up).
6
6
  *
@@ -26,7 +26,7 @@ export declare const PER_DAY_MAX = 100000;
26
26
  export declare const TRACKED_TOKENS = 1024;
27
27
  export declare const FLUSH_MS = 5000;
28
28
  /** Which limit a refusal hit: the body of a 429 names only this and Retry-After. */
29
- export type BudgetLimit = "read-per-minute" | "act-per-minute" | "per-day";
29
+ export type BudgetLimit = "read-per-minute" | "act-per-minute" | "per-day" | "per-minute";
30
30
  export type Admission = {
31
31
  ok: true;
32
32
  } | {
@@ -95,3 +95,30 @@ export declare class RequestBudget {
95
95
  /** The tracked entry (now the most recently used), from memory or as last saved; the least recently used is saved and let go. */
96
96
  private load;
97
97
  }
98
+ /** Per-minute allowances for non-person-token identities and unauthenticated ingress. Password accounts get room
99
+ * for two-second follower polling plus ordinary commands; OAuth exchanges stay keyed by source address. */
100
+ export declare const SOURCE_BUDGET_DEFAULTS: Readonly<{
101
+ password: 120;
102
+ oauthToken: 30;
103
+ coordinator: 120;
104
+ teamsSource: 120;
105
+ teamsTenant: 600;
106
+ }>;
107
+ /**
108
+ * A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
109
+ * pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
110
+ * Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
111
+ */
112
+ export declare class SourceAdmission {
113
+ private readonly perMinute;
114
+ private readonly clock;
115
+ private readonly tracked;
116
+ private readonly usage;
117
+ constructor(options: {
118
+ perMinute: number;
119
+ clock?: () => number;
120
+ tracked?: number;
121
+ });
122
+ get size(): number;
123
+ admit(source: string): Admission;
124
+ }
@@ -22,7 +22,7 @@ export const TRACKED_TOKENS = 1024;
22
22
  export const FLUSH_MS = 5_000;
23
23
  const MINUTE = 60_000, DAY = 86_400_000;
24
24
  /** The words for a 429, in one line. */
25
- export const limitWords = (limit, retryAfter) => `Request limit reached (${limit === "per-day" ? "requests per day" : limit === "read-per-minute" ? "read requests per minute" : "act requests per minute"}). Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.`;
25
+ export const limitWords = (limit, retryAfter) => `Request limit reached (${limit === "per-day" ? "requests per day" : limit === "read-per-minute" ? "read requests per minute" : limit === "act-per-minute" ? "act requests per minute" : "requests per minute"}). Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.`;
26
26
  /** A limit typed into a form: a whole number within bounds, "" for none, or a problem. */
27
27
  export function parseLimit(raw, max) {
28
28
  const text = raw.trim();
@@ -231,3 +231,46 @@ export class RequestBudget {
231
231
  return usage;
232
232
  }
233
233
  }
234
+ /** Per-minute allowances for non-person-token identities and unauthenticated ingress. Password accounts get room
235
+ * for two-second follower polling plus ordinary commands; OAuth exchanges stay keyed by source address. */
236
+ export const SOURCE_BUDGET_DEFAULTS = Object.freeze({ password: 120, oauthToken: 30, coordinator: 120, teamsSource: 120, teamsTenant: 600 });
237
+ /**
238
+ * A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
239
+ * pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
240
+ * Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
241
+ */
242
+ export class SourceAdmission {
243
+ perMinute;
244
+ clock;
245
+ tracked;
246
+ usage = new Map();
247
+ constructor(options) {
248
+ this.perMinute = Math.max(1, Math.min(PER_MINUTE_MAX, options.perMinute));
249
+ this.clock = options.clock ?? Date.now;
250
+ this.tracked = Math.max(1, options.tracked ?? TRACKED_TOKENS);
251
+ }
252
+ get size() { return this.usage.size; }
253
+ admit(source) {
254
+ const now = this.clock();
255
+ let times = this.usage.get(source);
256
+ if (times === undefined) {
257
+ if (this.usage.size >= this.tracked) {
258
+ for (const [key, kept] of this.usage)
259
+ if (kept.every(at => at <= now - MINUTE))
260
+ this.usage.delete(key);
261
+ if (this.usage.size >= this.tracked)
262
+ this.usage.delete(this.usage.keys().next().value);
263
+ }
264
+ times = [];
265
+ }
266
+ this.usage.delete(source);
267
+ this.usage.set(source, times);
268
+ while (times.length > 0 && times[0] <= now - MINUTE)
269
+ times.shift();
270
+ if (times.length >= this.perMinute) {
271
+ return { ok: false, status: 429, limit: "per-minute", retryAfter: Math.max(1, Math.ceil((times[times.length - this.perMinute] + MINUTE - now) / 1000)) };
272
+ }
273
+ times.push(now);
274
+ return { ok: true };
275
+ }
276
+ }