toolstile 0.3.0 → 0.4.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/README.md CHANGED
@@ -165,7 +165,7 @@ values) and prints a ready-to-post link. To post the text report instead, add
165
165
 
166
166
  ## Connect your agents to Toolstile
167
167
 
168
- If you use [Toolstile](https://toolstile.com), the CLI can also sign in and set
168
+ If you use [Toolstile](https://www.toolstile.com), the CLI can also sign in and set
169
169
  up the agents on a machine for you:
170
170
 
171
171
  ```sh
@@ -211,7 +211,7 @@ dashboard.
211
211
 
212
212
  | Option | Command | Meaning |
213
213
  | --- | --- | --- |
214
- | `--server <url>` | `auth login` | Toolstile address (default `https://toolstile.com`, or `TOOLSTILE_URL`) |
214
+ | `--server <url>` | `auth login` | Toolstile address (default `https://app.toolstile.com`, or `TOOLSTILE_URL`) |
215
215
  | `--no-browser` | `auth login` | Do not try to open a browser on this machine |
216
216
  | `--enable <agents>` | `mcp setup` | Turn these on without prompting (comma-separated) |
217
217
  | `--disable <agents>` | `mcp setup` | Turn these off without prompting |
@@ -229,7 +229,7 @@ const { grade, findings } = audit();
229
229
  ## About
230
230
 
231
231
  Built by [Akshit Kr Nagpal](https://akshit.io) as part of
232
- [Toolstile](https://toolstile.com), an MCP gateway that keeps upstream
232
+ [Toolstile](https://www.toolstile.com), an MCP gateway that keeps upstream
233
233
  credentials off your machines and lets you choose which tools each agent can use.
234
234
  The audit works on its own and does not require a Toolstile account.
235
235
 
package/dist/agents.d.ts CHANGED
@@ -30,7 +30,8 @@ export declare class ManualEditError extends Error {
30
30
  constructor(target: Target, reason: string, snippet: string);
31
31
  }
32
32
  /** Replaces the file in one step and keeps its permission bits; a symlink keeps pointing at it. */
33
- export declare function writeText(link: string, text: string): void;
33
+ export declare function writeText(link: string, text: string, expected?: string): void;
34
+ export declare function updateText(link: string, update: (text: string | null) => string): void;
34
35
  /**
35
36
  * Drops `[mcp_servers.<name>]` and its sub-tables, keeping every other line and comment. With
36
37
  * `replacement`, that text goes where the table was (or at the end when there was none).
package/dist/agents.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { fileText, withFileLock } from "./file-lock.js";
2
+ import { randomUUID } from "node:crypto";
1
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
4
  import { dirname, join } from "node:path";
3
5
  import { parse as parseToml } from "smol-toml";
@@ -65,7 +67,17 @@ function readText(path) {
65
67
  }
66
68
  }
67
69
  /** Replaces the file in one step and keeps its permission bits; a symlink keeps pointing at it. */
68
- export function writeText(link, text) {
70
+ export function writeText(link, text, expected) {
71
+ return withFileLock(link, () => {
72
+ if (expected !== undefined && fileText(link) !== expected)
73
+ throw new Error("The config changed while it was being edited; run the command again.");
74
+ writeTextLocked(link, text);
75
+ });
76
+ }
77
+ export function updateText(link, update) {
78
+ return withFileLock(link, () => writeTextLocked(link, update(fileText(link))));
79
+ }
80
+ function writeTextLocked(link, text) {
69
81
  let path = link;
70
82
  let mode = 0o600;
71
83
  try {
@@ -75,7 +87,7 @@ export function writeText(link, text) {
75
87
  catch {
76
88
  mkdirSync(dirname(path), { recursive: true });
77
89
  }
78
- const temp = `${path}.toolstile-${process.pid}.tmp`;
90
+ const temp = `${path}.toolstile-${process.pid}-${randomUUID()}.tmp`;
79
91
  try {
80
92
  writeFileSync(temp, text, { mode });
81
93
  chmodSync(temp, mode);
@@ -142,6 +154,9 @@ export function readEntry(target) {
142
154
  /** `replaced` means a hand-written `toolstile` server (not the proxy) was swapped out. */
143
155
  const outcome = (command, existing) => !command ? "removed" : existing === undefined ? "added" : isProxyEntry(existing) ? "updated" : "replaced";
144
156
  export function writeEntry(target, command) {
157
+ return withFileLock(target.path, () => writeEntryLocked(target, command));
158
+ }
159
+ function writeEntryLocked(target, command) {
145
160
  const text = readText(target.path) ?? "";
146
161
  if (target.format === "codex") {
147
162
  let current;
@@ -168,7 +183,9 @@ export function writeEntry(target, command) {
168
183
  throw new ManualEditError(target, "its toolstile server is written in a form setup cannot edit", command ? tomlEntry(command) : "");
169
184
  if (next === text)
170
185
  return "unchanged";
171
- writeText(target.path, next);
186
+ if ((readText(target.path) ?? "") !== text)
187
+ throw new Error("The agent config changed; run setup again.");
188
+ writeTextLocked(target.path, next);
172
189
  return outcome(command, existing);
173
190
  }
174
191
  const key = serversKey(target.format);
@@ -201,6 +218,8 @@ export function writeEntry(target, command) {
201
218
  delete servers[ENTRY];
202
219
  object[key] = servers;
203
220
  const indent = text.match(/\n([ \t]+)"/)?.[1] ?? " ";
204
- writeText(target.path, `${JSON.stringify(object, null, indent)}\n`);
221
+ if ((readText(target.path) ?? "") !== text)
222
+ throw new Error("The agent config changed; run setup again.");
223
+ writeTextLocked(target.path, `${JSON.stringify(object, null, indent)}\n`);
205
224
  return outcome(entry, existing);
206
225
  }
package/dist/api.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface Account {
20
20
  id: string;
21
21
  label: string;
22
22
  } | null;
23
+ /** The gateway URL agents use; it can be on another host than this API. */
24
+ mcpUrl?: string;
23
25
  }
24
26
  export interface DeviceCode {
25
27
  device_code: string;
package/dist/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { boundedText, deadline } from "./bounds.js";
1
2
  export const CLIENT_ID = "toolstile-cli";
2
3
  export class ApiError extends Error {
3
4
  status;
@@ -10,26 +11,37 @@ export class ApiError extends Error {
10
11
  }
11
12
  }
12
13
  async function request(fetcher, url, init = {}) {
13
- let response;
14
- try {
15
- response = await fetcher(url, {
16
- ...init,
17
- // Never follow a redirect with a session token to another address.
18
- redirect: "error",
19
- headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
20
- });
21
- }
22
- catch {
14
+ return deadline(async (signal) => {
15
+ let response;
16
+ try {
17
+ response = await fetcher(url, {
18
+ ...init,
19
+ signal,
20
+ // Never follow a redirect with a session token to another address.
21
+ redirect: "error",
22
+ headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
23
+ });
24
+ }
25
+ catch {
26
+ throw new ApiError(0, `Could not reach ${new URL(url).origin}. Check your connection.`);
27
+ }
28
+ if (response.status === 204)
29
+ return undefined;
30
+ const body = (await boundedText(response).then((text) => JSON.parse(text)).catch((error) => {
31
+ if (error instanceof SyntaxError)
32
+ return null;
33
+ throw error;
34
+ }));
35
+ if (!response.ok) {
36
+ const message = body?.error_description ?? body?.message;
37
+ throw new ApiError(response.status, typeof message === "string" ? message : `Request failed (${response.status})`, typeof body?.error === "string" ? body.error : undefined);
38
+ }
39
+ return body;
40
+ }, 30_000, init.signal ?? undefined).catch((error) => {
41
+ if (error instanceof ApiError)
42
+ throw error;
23
43
  throw new ApiError(0, `Could not reach ${new URL(url).origin}. Check your connection.`);
24
- }
25
- if (response.status === 204)
26
- return undefined;
27
- const body = (await response.json().catch(() => null));
28
- if (!response.ok) {
29
- const message = body?.error_description ?? body?.message;
30
- throw new ApiError(response.status, typeof message === "string" ? message : `Request failed (${response.status})`, typeof body?.error === "string" ? body.error : undefined);
31
- }
32
- return body;
44
+ });
33
45
  }
34
46
  /** The Toolstile endpoints the CLI uses. Session calls carry the device-flow session token. */
35
47
  export function createApi(server, fetcher = fetch) {
@@ -0,0 +1,3 @@
1
+ /** Bounds short operations even when a transport ignores its abort signal. */
2
+ export declare function deadline<T>(run: (signal: AbortSignal) => Promise<T>, ms: number, parent?: AbortSignal): Promise<T>;
3
+ export declare function boundedText(response: Response, limit?: number): Promise<string>;
package/dist/bounds.js ADDED
@@ -0,0 +1,43 @@
1
+ /** Bounds short operations even when a transport ignores its abort signal. */
2
+ export async function deadline(run, ms, parent) {
3
+ const controller = new AbortController();
4
+ let timer;
5
+ let abort = () => { };
6
+ const stopped = new Promise((_, reject) => {
7
+ abort = () => { controller.abort(); reject(new Error("Request cancelled or timed out")); };
8
+ parent?.addEventListener("abort", abort, { once: true });
9
+ timer = setTimeout(abort, ms);
10
+ });
11
+ try {
12
+ if (parent?.aborted)
13
+ abort();
14
+ return await Promise.race([stopped, Promise.resolve().then(() => run(controller.signal))]);
15
+ }
16
+ finally {
17
+ clearTimeout(timer);
18
+ parent?.removeEventListener("abort", abort);
19
+ }
20
+ }
21
+ export async function boundedText(response, limit = 1024 * 1024) {
22
+ if (!response.body)
23
+ return "";
24
+ const reader = response.body.getReader();
25
+ const decoder = new TextDecoder();
26
+ let size = 0;
27
+ let text = "";
28
+ try {
29
+ while (true) {
30
+ const { done, value } = await reader.read();
31
+ if (done)
32
+ return text + decoder.decode();
33
+ size += value.byteLength;
34
+ if (size > limit)
35
+ throw new Error("Response exceeded the size limit");
36
+ text += decoder.decode(value, { stream: true });
37
+ }
38
+ }
39
+ finally {
40
+ void reader.cancel().catch(() => { });
41
+ reader.releaseLock();
42
+ }
43
+ }
package/dist/cli.js CHANGED
@@ -68,7 +68,7 @@ login prints a link and a code. Open the link on any device with a browser
68
68
  reach this machine, so it works over SSH and on servers without a browser.
69
69
 
70
70
  Options
71
- --server <url> Toolstile address (default https://toolstile.com, or TOOLSTILE_URL)
71
+ --server <url> Toolstile address (default https://app.toolstile.com, or TOOLSTILE_URL)
72
72
  --no-browser Do not try to open a browser on this machine
73
73
  `;
74
74
  const MCP_HELP = `toolstile mcp: connect the agents on this machine to Toolstile
@@ -23,6 +23,8 @@ export declare class UserError extends Error {
23
23
  }
24
24
  /** HTTPS, except a loopback server for local development. */
25
25
  export declare function normalizeServer(value: string): string;
26
+ /** A gateway URL the server reported: HTTPS (or loopback HTTP) with no credentials, else undefined. */
27
+ export declare function gatewayUrl(value: unknown): string | undefined;
26
28
  export declare function login(ctx: Context, options: {
27
29
  server?: string;
28
30
  browser: boolean;
package/dist/commands.js CHANGED
@@ -6,13 +6,24 @@ import { checklist } from "./prompt.js";
6
6
  import { runProxy } from "./proxy.js";
7
7
  import { tildify } from "./report.js";
8
8
  import { CLIENT_NAMES } from "./sources.js";
9
- import { loadState, saveState } from "./state.js";
9
+ import { loadState, saveState, withCurrentState } from "./state.js";
10
10
  /** A failure explained to the user; exits 1 without a stack trace. */
11
11
  export class UserError extends Error {
12
12
  }
13
13
  const stateEnv = (ctx) => ({ home: ctx.home, env: ctx.env, platform: ctx.platform });
14
14
  const print = (ctx, text = "") => ctx.stdout.write(`${text}\n`);
15
15
  const interactive = (ctx) => ctx.stdin.isTTY === true && ctx.stdout.isTTY === true;
16
+ /** A failed local save must not silently abandon a newly issued remote credential. */
17
+ async function compensate(error, cleanup, remedy) {
18
+ try {
19
+ await cleanup();
20
+ }
21
+ catch (cleanupError) {
22
+ if (!(cleanupError instanceof ApiError && cleanupError.status === 404))
23
+ throw new UserError(`${error instanceof Error ? error.message : "Could not save credentials."} ${remedy}`);
24
+ }
25
+ throw error;
26
+ }
16
27
  /** HTTPS, except a loopback server for local development. */
17
28
  export function normalizeServer(value) {
18
29
  let url;
@@ -20,13 +31,31 @@ export function normalizeServer(value) {
20
31
  url = new URL(value);
21
32
  }
22
33
  catch {
23
- throw new UserError(`--server must be a URL, for example https://toolstile.com`);
34
+ throw new UserError(`--server must be a URL, for example https://app.toolstile.com`);
24
35
  }
25
36
  const loopback = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(url.hostname);
26
37
  if (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
27
38
  throw new UserError("--server must use https://");
28
39
  return url.origin;
29
40
  }
41
+ /** A gateway URL the server reported: HTTPS (or loopback HTTP) with no credentials, else undefined. */
42
+ export function gatewayUrl(value) {
43
+ if (typeof value !== "string")
44
+ return undefined;
45
+ let url;
46
+ try {
47
+ url = new URL(value);
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ const loopback = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(url.hostname);
53
+ if (url.username || url.password || url.search || url.hash)
54
+ return undefined;
55
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
56
+ return undefined;
57
+ return url.href;
58
+ }
30
59
  function defaultOpenBrowser(ctx) {
31
60
  return (url) => {
32
61
  // Over SSH or without a display the URL and code are enough; nothing to open.
@@ -89,25 +118,37 @@ export async function login(ctx, options) {
89
118
  throw error;
90
119
  }
91
120
  }
92
- const account = await api.account(token);
93
121
  const previous = state.session;
94
- // The old session is ended where it was issued, never sent to the new server.
95
- if (previous)
96
- await ctx.api(previous.server ?? state.server).signOut(previous.token).catch(() => undefined);
97
- if (server !== state.server || (previous && previous.email !== account.email)) {
98
- // Another account's workspace and device do not apply; enabled agents keep their own access.
99
- delete state.workspace;
100
- delete state.device;
122
+ const previousServer = previous?.server ?? state.server;
123
+ let deviceLabel;
124
+ try {
125
+ const account = await api.account(token);
126
+ deviceLabel = account.device?.label;
127
+ if (server !== state.server || (previous && previous.email !== account.email)) {
128
+ // Another account's workspace and device do not apply; agents keep their own access.
129
+ delete state.workspace;
130
+ delete state.device;
131
+ }
132
+ state.server = server;
133
+ state.session = { token, email: account.email, expiresAt: account.expiresAt, server };
134
+ if (account.device)
135
+ state.device = account.device;
136
+ saveState(state, stateEnv(ctx));
101
137
  }
102
- state.server = server;
103
- state.session = { token, email: account.email, expiresAt: account.expiresAt, server };
104
- // The device named on the approval page. Agents still on an older device move to it at
105
- // the next `mcp setup`.
106
- if (account.device)
107
- state.device = account.device;
108
- saveState(state, stateEnv(ctx));
138
+ catch (error) {
139
+ await compensate(error, async () => {
140
+ // Only discard this sign-in, never the session a concurrent command saved.
141
+ const winner = loadState(stateEnv(ctx)).session;
142
+ if (winner?.token !== token || winner.server !== server)
143
+ await api.signOut(token);
144
+ }, "Sign-in cleanup failed. Remove this new CLI session in the Toolstile dashboard.");
145
+ }
146
+ // End the previous session only after the replacement was safely saved.
147
+ if (previous && (previous.token !== token || previousServer !== server))
148
+ await ctx.api(previousServer).signOut(previous.token).catch(() => undefined);
149
+ const account = state.session;
109
150
  print(ctx);
110
- print(ctx, `Signed in as ${s.bold(account.email)}${account.device ? ` on ${s.bold(account.device.label)}` : ""}. Next: ${s.bold("toolstile mcp setup")}`);
151
+ print(ctx, `Signed in as ${s.bold(account.email)}${deviceLabel ? ` on ${s.bold(deviceLabel)}` : ""}. Next: ${s.bold("toolstile mcp setup")}`);
111
152
  return 0;
112
153
  }
113
154
  export async function logout(ctx) {
@@ -116,17 +157,18 @@ export async function logout(ctx) {
116
157
  print(ctx, "Not signed in.");
117
158
  return 0;
118
159
  }
160
+ const session = state.session;
161
+ delete state.session;
162
+ saveState(state, stateEnv(ctx));
119
163
  let reached = true;
120
164
  await ctx
121
- .api(state.session.server ?? state.server)
122
- .signOut(state.session.token)
165
+ .api(session.server ?? state.server)
166
+ .signOut(session.token)
123
167
  .catch((error) => {
124
168
  // 401 means the session had already ended; anything else is worth saying.
125
169
  if (!(error instanceof ApiError && error.status === 401))
126
170
  reached = false;
127
171
  });
128
- delete state.session;
129
- saveState(state, stateEnv(ctx));
130
172
  print(ctx, reached ? "Signed out." : "Signed out on this machine. Toolstile could not be reached, so the session ends when it expires.");
131
173
  const enabled = Object.keys(state.agents).length;
132
174
  if (enabled)
@@ -180,6 +222,8 @@ export async function setup(ctx, options) {
180
222
  const both = enable.find((c) => disable.includes(c));
181
223
  if (both)
182
224
  throw new UserError(`${CLIENT_NAMES[both]} is in both --enable and --disable.`);
225
+ // The gateway can be on another host; only an HTTPS URL from this server is used.
226
+ const endpoint = gatewayUrl(account.mcpUrl) ?? `${state.server}/mcp`;
183
227
  const workspace = await chooseWorkspace(ctx, state, account.workspaces, options.workspace);
184
228
  state.workspace = { id: workspace.id, slug: workspace.slug, name: workspace.name };
185
229
  saveState(state, stateEnv(ctx));
@@ -254,23 +298,38 @@ export async function setup(ctx, options) {
254
298
  const want = wanted.has(target.client);
255
299
  const link = state.agents[target.client];
256
300
  if (want) {
301
+ let replacedAuthorization = false;
302
+ if (live(target.client) && link && link.endpoint !== endpoint) {
303
+ link.endpoint = endpoint;
304
+ saveState(state, stateEnv(ctx));
305
+ }
257
306
  if (!live(target.client)) {
258
- if (link)
259
- await removeRemote(link).catch(() => undefined);
260
307
  const { id: deviceId } = await ensureDevice();
261
308
  const created = await api.addAgent(session.token, { workspaceId: workspace.id, deviceId, label: target.name });
262
309
  state.agents[target.client] = {
263
310
  id: created.id,
264
311
  workspaceId: workspace.id,
265
312
  server: state.server,
313
+ endpoint,
266
314
  label: created.label,
267
315
  token: created.token,
268
316
  };
269
- // Saved before touching the agent's config, so a token is never lost.
270
- saveState(state, stateEnv(ctx));
317
+ // Commit before editing config or revoking any previous authorization.
318
+ try {
319
+ saveState(state, stateEnv(ctx));
320
+ }
321
+ catch (error) {
322
+ await compensate(error, async () => {
323
+ const winner = loadState(stateEnv(ctx));
324
+ const retained = Object.values(winner.agents).some((agent) => agent?.id === created.id && agent.workspaceId === workspace.id && agent.server === state.server);
325
+ if (!retained)
326
+ await api.removeAgent(session.token, workspace.id, created.id);
327
+ }, `Agent cleanup failed. Remove the new ${target.name} authorization (${created.id}) in the Toolstile dashboard.`);
328
+ }
329
+ replacedAuthorization = !!link;
271
330
  }
272
331
  try {
273
- const result = writeEntry(target, proxyCommand(target.client, ctx.version, ctx.platform));
332
+ const result = withCurrentState(state, stateEnv(ctx), () => writeEntry(target, proxyCommand(target.client, ctx.version, ctx.platform)));
274
333
  if (!live(target.client) || result !== "unchanged")
275
334
  changes.push(`${s.bold("+")} ${target.name} ${s.dim(tildify(target.path, ctx.home))}`);
276
335
  if (result === "replaced")
@@ -281,20 +340,35 @@ export async function setup(ctx, options) {
281
340
  throw error;
282
341
  manual.push({ target, error, enable: true });
283
342
  }
343
+ finally {
344
+ // Finish both local writes before yielding to remote cleanup. A later
345
+ // disable must not be followed by this operation recreating its config.
346
+ if (replacedAuthorization && link)
347
+ await removeRemote(link).catch(() => undefined);
348
+ }
284
349
  }
285
350
  else if (link || isProxyEntry(readEntry(target))) {
286
- try {
287
- writeEntry(target, null);
288
- }
289
- catch (error) {
290
- if (!(error instanceof ManualEditError))
291
- throw error;
292
- manual.push({ target, error, enable: false });
293
- }
351
+ const removeLocal = () => {
352
+ try {
353
+ writeEntry(target, null);
354
+ }
355
+ catch (error) {
356
+ if (!(error instanceof ManualEditError))
357
+ throw error;
358
+ manual.push({ target, error, enable: false });
359
+ }
360
+ if (link)
361
+ delete state.agents[target.client];
362
+ };
363
+ // Both local writes form one transition, even across separate processes.
364
+ if (link)
365
+ saveState(state, stateEnv(ctx), removeLocal);
366
+ else
367
+ withCurrentState(state, stateEnv(ctx), removeLocal);
294
368
  if (link) {
295
- await removeRemote(link);
296
- delete state.agents[target.client];
297
- saveState(state, stateEnv(ctx));
369
+ await removeRemote(link).catch(() => {
370
+ throw new UserError(`Disabled locally, but remote cleanup failed. Remove ${target.name} authorization (${link.id}) in the Toolstile dashboard.`);
371
+ });
298
372
  }
299
373
  changes.push(`${s.bold("-")} ${target.name}`);
300
374
  }
@@ -324,7 +398,7 @@ export async function setup(ctx, options) {
324
398
  }
325
399
  if ([...wanted].length) {
326
400
  print(ctx);
327
- print(ctx, `Choose which MCP servers they can use: ${state.server}/app/${workspace.slug}/access`);
401
+ print(ctx, `Choose which MCP servers they can use: ${state.server}/${workspace.slug}/access`);
328
402
  }
329
403
  return manual.length ? 1 : 0;
330
404
  }
@@ -338,7 +412,7 @@ export async function proxy(ctx, agent) {
338
412
  throw new UserError(`${CLIENT_NAMES[client]} is not enabled on this machine. Run toolstile mcp setup.`);
339
413
  await runProxy({
340
414
  // The token goes only to the server that issued it, whatever the environment says.
341
- endpoint: `${link.server}/mcp`,
415
+ endpoint: link.endpoint ?? `${link.server}/mcp`,
342
416
  token: link.token,
343
417
  input: ctx.stdin,
344
418
  output: ctx.stdout,
@@ -0,0 +1,3 @@
1
+ export declare function fileText(path: string): string | null;
2
+ /** Fail closed rather than steal a lock from a possibly live command. */
3
+ export declare function withFileLock<T>(path: string, run: () => T): T;
@@ -0,0 +1,38 @@
1
+ import { mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ export function fileText(path) {
4
+ try {
5
+ return readFileSync(path, "utf8");
6
+ }
7
+ catch (error) {
8
+ if (error.code === "ENOENT")
9
+ return null;
10
+ throw error;
11
+ }
12
+ }
13
+ /** Fail closed rather than steal a lock from a possibly live command. */
14
+ export function withFileLock(path, run) {
15
+ mkdirSync(dirname(path), { recursive: true });
16
+ let canonical;
17
+ try {
18
+ canonical = realpathSync(path);
19
+ }
20
+ catch {
21
+ canonical = resolve(realpathSync(dirname(path)), path.split(/[\\/]/).pop());
22
+ }
23
+ const lock = `${canonical}.toolstile-lock`;
24
+ try {
25
+ mkdirSync(lock, { mode: 0o700 });
26
+ }
27
+ catch (error) {
28
+ if (error.code === "EEXIST")
29
+ throw new Error(`Another command is updating ${path}. Retry when it finishes. If it crashed, remove ${lock}.`);
30
+ throw error;
31
+ }
32
+ try {
33
+ return run();
34
+ }
35
+ finally {
36
+ rmSync(lock, { recursive: true });
37
+ }
38
+ }
package/dist/fix.js CHANGED
@@ -2,7 +2,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { applyEdits, modify } from "jsonc-parser";
4
4
  import { stringify as stringifyToml } from "smol-toml";
5
- import { withoutTomlTable, writeText } from "./agents.js";
5
+ import { withoutTomlTable, writeText, updateText } from "./agents.js";
6
6
  import { isPinned, packageOf, secretsOf, shellPath, words } from "./checks.js";
7
7
  import { audit } from "./index.js";
8
8
  import { parseConfig } from "./parse.js";
@@ -474,35 +474,31 @@ export function applyFixes(plan, chosen, opts) {
474
474
  const secrets = ok.flatMap((f) => f.secrets);
475
475
  let profileAdded = false;
476
476
  if (secrets.length) {
477
- const stored = readSecrets(plan.secretsFile);
478
- let text = "";
479
- try {
480
- text = readFileSync(plan.secretsFile, "utf8");
481
- }
482
- catch {
483
- text = "# MCP server secrets moved out of agent configs by toolstile audit --fix.\n# Only your user can read this file. Loaded by your shell; see the line in your shell profile.\n";
484
- }
485
- for (const { name, value } of secrets) {
486
- if (stored.get(name) === value)
487
- continue;
488
- stored.set(name, value);
489
- text = `${text.replace(/\n?$/, "\n")}export ${name}=${quote(value)}\n`;
490
- }
491
477
  mkdirSync(dirname(plan.secretsFile), { recursive: true, mode: 0o700 });
492
- writeText(plan.secretsFile, text);
478
+ updateText(plan.secretsFile, (current) => {
479
+ const stored = readSecrets(plan.secretsFile);
480
+ let text = current ?? "# MCP server secrets moved out of agent configs by toolstile audit --fix.\n# Only your user can read this file. Loaded by your shell; see the line in your shell profile.\n";
481
+ for (const { name, value } of secrets) {
482
+ if (stored.get(name) === value)
483
+ continue;
484
+ if (stored.has(name))
485
+ throw new Error("A saved secret changed; run --fix again.");
486
+ stored.set(name, value);
487
+ text = `${text.replace(/\n?$/, "\n")}export ${name}=${quote(value)}\n`;
488
+ }
489
+ return text;
490
+ });
493
491
  chmodSync(plan.secretsFile, 0o600);
494
492
  if (plan.profile) {
495
- let profile = "";
496
- try {
497
- profile = readFileSync(plan.profile.path, "utf8");
498
- }
499
- catch { }
500
- if (!profile.includes(shellPath(plan.secretsFile, opts.home)) && !profile.includes(plan.secretsFile)) {
501
- const mode = existsSync(plan.profile.path) ? statSync(plan.profile.path).mode & 0o777 : 0o644;
502
- writeText(plan.profile.path, `${profile}${profile && !profile.endsWith("\n") ? "\n" : ""}\n${plan.profile.line}\n`);
503
- chmodSync(plan.profile.path, mode);
493
+ const mode = existsSync(plan.profile.path) ? statSync(plan.profile.path).mode & 0o777 : 0o644;
494
+ updateText(plan.profile.path, (current) => {
495
+ const profile = current ?? "";
496
+ if (profile.includes(shellPath(plan.secretsFile, opts.home)) || profile.includes(plan.secretsFile))
497
+ return profile;
504
498
  profileAdded = true;
505
- }
499
+ return `${profile}${profile && !profile.endsWith("\n") ? "\n" : ""}\n${plan.profile.line}\n`;
500
+ });
501
+ chmodSync(plan.profile.path, mode);
506
502
  }
507
503
  }
508
504
  let backups = null;
@@ -521,8 +517,8 @@ export function applyFixes(plan, chosen, opts) {
521
517
  copyFileSync(file, copy);
522
518
  chmodSync(copy, 0o600);
523
519
  }
524
- for (const [file, { after }] of texts)
525
- writeText(file, after);
520
+ for (const [file, { before, after }] of texts)
521
+ writeText(file, after, before);
526
522
  }
527
523
  for (const fix of ok)
528
524
  for (const op of fix.ops)
package/dist/proxy.js CHANGED
@@ -1,22 +1,61 @@
1
- import { createInterface } from "node:readline";
1
+ import { boundedText, deadline } from "./bounds.js";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ // Replies and notifications can unblock an active request, even during initialization.
4
+ const urgent = (message) => message.method === undefined ||
5
+ (message.id === undefined && message.method !== "notifications/initialized");
6
+ function isMessage(value) {
7
+ if (!value || typeof value !== "object" || Array.isArray(value))
8
+ return false;
9
+ const m = value;
10
+ if (m.jsonrpc !== "2.0")
11
+ return false;
12
+ if ("id" in m && m.id !== null && typeof m.id !== "string" && !(typeof m.id === "number" && Number.isFinite(m.id)))
13
+ return false;
14
+ if ("method" in m)
15
+ return typeof m.method === "string" && !("result" in m) && !("error" in m) &&
16
+ (!("params" in m) || (m.params !== null && typeof m.params === "object"));
17
+ if (!("id" in m) || (("result" in m) === ("error" in m)))
18
+ return false;
19
+ if ("error" in m) {
20
+ const e = m.error;
21
+ return !!e && typeof e === "object" && Number.isInteger(e.code) && typeof e.message === "string";
22
+ }
23
+ return true;
24
+ }
2
25
  /** Yields each SSE event's data and id. */
3
26
  async function* events(body) {
4
27
  const decoder = new TextDecoder();
5
28
  let buffer = "";
6
29
  let data = [];
7
30
  let id;
31
+ let eventBytes = 0;
32
+ let lineBytes = 0;
33
+ const append = (part) => {
34
+ lineBytes += Buffer.byteLength(part, "utf8");
35
+ if (lineBytes > 1024 * 1024)
36
+ throw new Error("SSE line exceeded the size limit");
37
+ buffer += part;
38
+ };
8
39
  for await (const chunk of body) {
9
- buffer += decoder.decode(chunk, { stream: true });
40
+ const text = decoder.decode(chunk, { stream: true });
41
+ let start = 0;
10
42
  let newline;
11
- while ((newline = buffer.search(/\r?\n/)) !== -1) {
12
- const line = buffer.slice(0, newline);
13
- buffer = buffer.slice(buffer[newline] === "\r" ? newline + 2 : newline + 1);
43
+ while ((newline = text.indexOf("\n", start)) !== -1) {
44
+ append(text.slice(start, newline));
45
+ const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
46
+ buffer = "";
47
+ lineBytes = 0;
48
+ start = newline + 1;
14
49
  if (line === "") {
15
50
  if (data.length)
16
51
  yield { data: data.join("\n"), id };
17
52
  data = [];
53
+ eventBytes = 0;
18
54
  continue;
19
55
  }
56
+ eventBytes += Buffer.byteLength(line, "utf8");
57
+ if (eventBytes > 1024 * 1024)
58
+ throw new Error("SSE event exceeded the size limit");
20
59
  if (line.startsWith(":"))
21
60
  continue;
22
61
  const colon = line.indexOf(":");
@@ -27,10 +66,37 @@ async function* events(body) {
27
66
  else if (field === "id")
28
67
  id = value;
29
68
  }
69
+ append(text.slice(start));
30
70
  }
71
+ append(decoder.decode());
31
72
  if (data.length)
32
73
  yield { data: data.join("\n"), id };
33
74
  }
75
+ /** Frame incrementally rather than letting readline queue unbounded complete lines. */
76
+ async function* inputLines(input) {
77
+ const decoder = new StringDecoder("utf8");
78
+ let buffer = "";
79
+ const append = (part) => {
80
+ if (Buffer.byteLength(buffer) + Buffer.byteLength(part) > 1024 * 1024)
81
+ throw new Error("Input line exceeded the size limit");
82
+ buffer += part;
83
+ };
84
+ for await (const chunk of input) {
85
+ const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
86
+ let start = 0;
87
+ let newline;
88
+ while ((newline = text.indexOf("\n", start)) !== -1) {
89
+ append(text.slice(start, newline));
90
+ yield buffer;
91
+ buffer = "";
92
+ start = newline + 1;
93
+ }
94
+ append(text.slice(start));
95
+ }
96
+ append(decoder.end());
97
+ if (buffer)
98
+ yield buffer;
99
+ }
34
100
  /** How long in-flight requests may finish after the agent closes stdin. */
35
101
  const CLOSE_GRACE_MS = 3000;
36
102
  export function runProxy(options) {
@@ -39,16 +105,51 @@ export function runProxy(options) {
39
105
  let protocolVersion;
40
106
  /** The agent's own `initialize`, replayed if the gateway forgets the session. */
41
107
  let opening;
42
- let listening = false;
108
+ let listener;
109
+ let recovering;
43
110
  let closed = false;
44
111
  /** Aborts every request and the listener's waits once the agent has gone. */
45
112
  const shutdown = new AbortController();
46
113
  const pending = new Set();
47
114
  /** Later messages wait for `initialize`, so they carry the session it opens. */
48
115
  let initialized = Promise.resolve();
116
+ // Requests and control messages have independent budgets. No waiting input queue:
117
+ // overload is answered locally, and exhausted control/output budgets close the proxy.
118
+ const LIMIT = 64;
119
+ let requestCount = 0;
120
+ let controlCount = 0;
121
+ let finishForced;
122
+ const forced = new Promise((resolve) => { finishForced = resolve; });
123
+ const stop = () => {
124
+ closed = true;
125
+ shutdown.abort();
126
+ options.input.destroy();
127
+ finishForced();
128
+ };
129
+ let writes = Promise.resolve();
130
+ let queuedWrites = 0;
131
+ let queuedBytes = 0;
49
132
  const write = (message) => {
50
- if (!options.output.writableEnded)
51
- options.output.write(`${JSON.stringify(message)}\n`);
133
+ if (closed)
134
+ return Promise.resolve();
135
+ const text = `${JSON.stringify(message)}\n`;
136
+ const bytes = Buffer.byteLength(text);
137
+ if (queuedWrites >= 128 || queuedBytes + bytes > 8 * 1024 * 1024) {
138
+ options.log("Proxy output limit exceeded; closing the connection.");
139
+ stop();
140
+ return Promise.resolve();
141
+ }
142
+ queuedWrites++;
143
+ queuedBytes += bytes;
144
+ writes = writes.then(() => new Promise((resolve, reject) => {
145
+ if (closed || options.output.writableEnded)
146
+ return resolve();
147
+ const finish = (error) => error ? reject(error) : resolve();
148
+ options.output.write(text, finish);
149
+ })).finally(() => { queuedWrites--; queuedBytes -= bytes; });
150
+ // Observe immediately; send/readBody await the same promise to apply backpressure.
151
+ void writes.catch(stop);
152
+ return writes;
52
153
  };
53
154
  const headers = (extra = {}) => ({
54
155
  Authorization: `Bearer ${options.token}`,
@@ -57,7 +158,9 @@ export function runProxy(options) {
57
158
  ...extra,
58
159
  });
59
160
  /** Never follow a redirect: the token is only for the gateway endpoint. */
60
- const request = (init) => fetcher(options.endpoint, { ...init, redirect: "error", signal: shutdown.signal });
161
+ const request = (init) => shutdown.signal.aborted
162
+ ? Promise.reject(new Error("The agent closed the connection"))
163
+ : fetcher(options.endpoint, { ...init, redirect: "error", signal: init.signal ?? shutdown.signal });
61
164
  const post = (body) => request({
62
165
  method: "POST",
63
166
  headers: headers({ "Content-Type": "application/json", Accept: "application/json, text/event-stream" }),
@@ -78,17 +181,24 @@ export function runProxy(options) {
78
181
  }
79
182
  if (message.method === undefined && message.id !== undefined)
80
183
  answered?.add(message.id);
81
- write(message);
184
+ return write(message);
82
185
  };
83
186
  async function readBody(response, answered, quiet = false) {
84
187
  const type = response.headers.get("content-type") ?? "";
85
- const take = (message) => (quiet ? answered.add(message.id) : deliver(message, answered));
188
+ const take = async (message) => {
189
+ if (!isMessage(message))
190
+ throw new Error("Invalid JSON-RPC response");
191
+ if (quiet)
192
+ answered.add(message.id);
193
+ else
194
+ await deliver(message, answered);
195
+ };
86
196
  if (!response.body)
87
197
  return;
88
198
  if (type.includes("text/event-stream")) {
89
199
  for await (const event of events(response.body)) {
90
200
  try {
91
- take(JSON.parse(event.data));
201
+ await take(JSON.parse(event.data));
92
202
  }
93
203
  catch {
94
204
  options.log("Ignored a malformed message from Toolstile.");
@@ -97,55 +207,79 @@ export function runProxy(options) {
97
207
  return;
98
208
  }
99
209
  if (type.includes("application/json")) {
100
- const body = (await response.json());
210
+ const body = JSON.parse(await boundedText(response));
101
211
  for (const message of Array.isArray(body) ? body : [body])
102
- take(message);
212
+ await take(message);
103
213
  }
104
214
  }
105
215
  const wait = (ms) => new Promise((resolve) => {
106
- const timer = setTimeout(resolve, ms);
107
- shutdown.signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
216
+ const done = () => { clearTimeout(timer); shutdown.signal.removeEventListener("abort", done); resolve(); };
217
+ const timer = setTimeout(done, ms);
218
+ if (shutdown.signal.aborted)
219
+ done();
220
+ else
221
+ shutdown.signal.addEventListener("abort", done, { once: true });
108
222
  });
109
223
  /** Server-initiated messages (list changes, approvals) arrive on a long-lived GET stream. */
110
224
  async function listen() {
111
- if (listening || !sessionId)
225
+ if (listener || !sessionId || closed)
112
226
  return;
113
- listening = true;
227
+ const current = listener = new AbortController();
228
+ const stop = () => current.abort();
229
+ shutdown.signal.addEventListener("abort", stop, { once: true });
114
230
  let lastEventId;
115
- for (let attempt = 0; !closed && attempt < 8; attempt++) {
116
- try {
117
- const response = await request({
118
- method: "GET",
119
- headers: headers({ Accept: "text/event-stream", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) }),
120
- });
121
- // 405: this server offers no stream; that is allowed.
122
- if (response.status === 405 || !response.ok || !response.body)
123
- break;
124
- attempt = 0;
125
- for await (const event of events(response.body)) {
126
- if (event.id)
127
- lastEventId = event.id;
128
- try {
129
- deliver(JSON.parse(event.data));
231
+ try {
232
+ for (let attempt = 0; !closed && !current.signal.aborted && attempt < 8; attempt++) {
233
+ try {
234
+ const response = await request({
235
+ method: "GET",
236
+ signal: current.signal,
237
+ headers: headers({ Accept: "text/event-stream", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) }),
238
+ });
239
+ // 405: this server offers no stream; that is allowed.
240
+ if (response.status === 405 || !response.ok || !response.body) {
241
+ void response.body?.cancel().catch(() => undefined);
242
+ break;
243
+ }
244
+ attempt = 0;
245
+ for await (const event of events(response.body)) {
246
+ if (current.signal.aborted)
247
+ break;
248
+ if (event.id)
249
+ lastEventId = event.id;
250
+ try {
251
+ const message = JSON.parse(event.data);
252
+ if (isMessage(message))
253
+ await deliver(message);
254
+ }
255
+ catch { }
130
256
  }
131
- catch { }
132
257
  }
133
- }
134
- catch {
135
- if (closed)
258
+ catch {
259
+ if (closed)
260
+ break;
261
+ }
262
+ if (current.signal.aborted)
136
263
  break;
264
+ await wait(Math.min(30_000, 1000 * 2 ** attempt));
137
265
  }
138
- await wait(Math.min(30_000, 1000 * 2 ** attempt));
139
266
  }
140
- listening = false;
267
+ finally {
268
+ current.abort();
269
+ shutdown.signal.removeEventListener("abort", stop);
270
+ if (listener === current)
271
+ listener = undefined;
272
+ }
141
273
  }
142
274
  /**
143
275
  * The gateway answers 404 for a session it no longer has (for example after a restart).
144
276
  * Open a new one with the agent's original `initialize`, without showing its reply again.
145
277
  */
146
- async function reopen() {
278
+ async function replaceSession() {
147
279
  if (!opening)
148
280
  return false;
281
+ listener?.abort();
282
+ listener = undefined;
149
283
  sessionId = undefined;
150
284
  const response = await post(opening);
151
285
  sessionId = response.headers.get("mcp-session-id") ?? undefined;
@@ -153,83 +287,174 @@ export function runProxy(options) {
153
287
  return false;
154
288
  await readBody(response, new Set(), true);
155
289
  await post(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })).then((r) => r.body?.cancel());
156
- void listen();
290
+ void listen().catch((error) => options.log(String(error)));
157
291
  return true;
158
292
  }
159
- async function send(line) {
160
- let messages;
161
- try {
162
- const parsed = JSON.parse(line);
163
- messages = Array.isArray(parsed) ? parsed : [parsed];
164
- }
165
- catch {
166
- write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
167
- return;
168
- }
293
+ function reopen(stale) {
294
+ if (recovering)
295
+ return recovering;
296
+ if (sessionId !== stale)
297
+ return Promise.resolve(true);
298
+ recovering = replaceSession().finally(() => { recovering = undefined; });
299
+ return recovering;
300
+ }
301
+ async function send(messages, batch) {
302
+ const line = JSON.stringify(batch ? messages : messages[0]);
169
303
  const requests = messages.filter((m) => m.method !== undefined && m.id !== undefined);
170
304
  const answered = new Set();
171
- const fail = (message) => {
305
+ const fail = async (message) => {
172
306
  for (const request of requests)
173
307
  if (!answered.has(request.id))
174
- write({ jsonrpc: "2.0", id: request.id, error: { code: -32000, message } });
308
+ await write({ jsonrpc: "2.0", id: request.id, error: { code: -32000, message } });
175
309
  };
176
310
  if (messages.some((m) => m.method === "initialize"))
177
311
  opening = line;
178
312
  try {
313
+ if (recovering && !messages.every(urgent))
314
+ await recovering;
315
+ const stale = sessionId;
179
316
  let response = await post(line);
180
- if (response.status === 404 && sessionId && !messages.some((m) => m.method === "initialize")) {
317
+ if (response.status === 404 && stale && !messages.some((m) => m.method === "initialize")) {
181
318
  await response.body?.cancel();
182
- if (await reopen())
319
+ if (await reopen(stale))
183
320
  response = await post(line);
184
321
  }
185
322
  const session = response.headers.get("mcp-session-id");
186
- if (session)
323
+ if (session && (sessionId === stale || messages.some((m) => m.method === "initialize")))
187
324
  sessionId = session;
188
325
  if (!response.ok) {
189
- const text = (await response.text().catch(() => "")).trim().slice(0, 300);
326
+ const text = (await boundedText(response, 4096).catch(() => "")).trim().slice(0, 300);
190
327
  const message = explain(response.status, text);
191
328
  options.log(message);
192
- fail(message);
329
+ await fail(message);
193
330
  return;
194
331
  }
195
332
  await readBody(response, answered);
196
333
  if (messages.some((m) => m.method === "notifications/initialized"))
197
- void listen();
334
+ void listen().catch((error) => options.log(String(error)));
198
335
  // A stream that ended early must still answer every request, or the agent waits forever.
199
- fail("Toolstile closed the connection before answering. Try again.");
336
+ await fail("Toolstile closed the connection before answering. Try again.");
200
337
  }
201
338
  catch (error) {
202
339
  if (closed)
203
340
  return fail("The agent closed the connection.");
204
341
  const message = `Could not reach Toolstile: ${error.message}`;
205
342
  options.log(message);
206
- fail(message);
343
+ await fail(message);
207
344
  }
208
345
  }
209
- return new Promise((resolve) => {
210
- const lines = createInterface({ input: options.input, crlfDelay: Number.POSITIVE_INFINITY });
211
- lines.on("line", (line) => {
212
- if (!line.trim())
213
- return;
214
- const opens = /"method"\s*:\s*"initialize"/.test(line);
215
- const task = (opens ? send(line) : initialized.then(() => send(line))).finally(() => pending.delete(task));
216
- if (opens)
217
- initialized = task;
218
- pending.add(task);
346
+ const invalid = (code, message, id = null) => {
347
+ // Immediate observation plus the global write budgets also bound parse/overload errors.
348
+ void write({ jsonrpc: "2.0", id, error: { code, message } }).catch(stop);
349
+ };
350
+ const accept = (line) => {
351
+ let parsed;
352
+ try {
353
+ parsed = JSON.parse(line);
354
+ }
355
+ catch {
356
+ invalid(-32700, "Parse error");
357
+ return;
358
+ }
359
+ const batch = Array.isArray(parsed);
360
+ const values = Array.isArray(parsed) ? parsed : [parsed];
361
+ if (values.length > LIMIT) {
362
+ invalid(-32600, "Batch exceeds the request limit");
363
+ return;
364
+ }
365
+ if (!values.length) {
366
+ invalid(-32600, "Invalid Request");
367
+ return;
368
+ }
369
+ const messages = [];
370
+ let requests = 0;
371
+ let controls = 0;
372
+ for (const value of values) {
373
+ if (!isMessage(value)) {
374
+ invalid(-32600, "Invalid Request");
375
+ continue;
376
+ }
377
+ if (value.method !== undefined && value.id !== undefined) {
378
+ if (requestCount + requests >= LIMIT) {
379
+ invalid(-32000, "Proxy request limit reached; try again after a request finishes.", value.id);
380
+ continue;
381
+ }
382
+ requests++;
383
+ }
384
+ else {
385
+ if (controlCount + controls >= LIMIT) {
386
+ options.log("Proxy control limit exceeded; closing the connection.");
387
+ stop();
388
+ return;
389
+ }
390
+ controls++;
391
+ }
392
+ messages.push(value);
393
+ }
394
+ if (!messages.length || closed)
395
+ return;
396
+ // A mixed batch must not leave a server reply behind the initialize it unblocks.
397
+ const immediate = messages.filter(urgent);
398
+ const regular = messages.filter((message) => !urgent(message));
399
+ if (regular.length)
400
+ schedule(regular, batch, requests, controls - immediate.length);
401
+ if (immediate.length)
402
+ schedule(immediate, batch, 0, immediate.length);
403
+ };
404
+ const schedule = (messages, batch, requests, controls) => {
405
+ requestCount += requests;
406
+ controlCount += controls;
407
+ const opens = messages.some((message) => message.method === "initialize");
408
+ const task = (opens || messages.every(urgent) ? send(messages, batch) : initialized.then(() => send(messages, batch)))
409
+ .catch((error) => options.log(`Proxy task failed: ${String(error)}`))
410
+ .finally(() => {
411
+ pending.delete(task);
412
+ requestCount -= requests;
413
+ controlCount -= controls;
219
414
  });
220
- lines.on("close", async () => {
221
- // Let replies already on their way finish, but never hang on a long call or stream.
222
- const grace = setTimeout(() => {
223
- closed = true;
224
- shutdown.abort();
225
- }, CLOSE_GRACE_MS);
415
+ if (opens)
416
+ initialized = task;
417
+ pending.add(task);
418
+ };
419
+ let closeTimer;
420
+ const inputEnded = () => {
421
+ closeTimer = setTimeout(stop, CLOSE_GRACE_MS);
422
+ };
423
+ options.input.once("end", inputEnded);
424
+ const outputError = () => stop();
425
+ options.output.on("error", outputError);
426
+ const work = (async () => {
427
+ try {
428
+ for await (const line of inputLines(options.input)) {
429
+ if (closed)
430
+ break;
431
+ if (line.trim())
432
+ accept(line);
433
+ }
434
+ }
435
+ catch (error) {
436
+ options.log(`Proxy input failed: ${String(error)}`);
437
+ stop();
438
+ }
439
+ // One total deadline covers in-flight work, blocked stdout and best-effort DELETE.
440
+ await deadline(async (signal) => {
226
441
  await Promise.allSettled([...pending]);
227
- clearTimeout(grace);
442
+ await writes;
228
443
  closed = true;
229
444
  shutdown.abort();
230
- if (sessionId)
231
- await fetcher(options.endpoint, { method: "DELETE", headers: headers(), redirect: "error" }).catch(() => undefined);
232
- resolve();
233
- });
445
+ if (sessionId) {
446
+ const response = await fetcher(options.endpoint, { method: "DELETE", headers: headers(), redirect: "error", signal });
447
+ void response.body?.cancel().catch(() => { });
448
+ }
449
+ }, CLOSE_GRACE_MS).catch(() => { });
450
+ closed = true;
451
+ shutdown.abort();
452
+ })();
453
+ return Promise.race([work, forced]).finally(() => {
454
+ clearTimeout(closeTimer);
455
+ options.input.removeListener("end", inputEnded);
456
+ closed = true;
457
+ shutdown.abort();
458
+ void writes.then(() => options.output.removeListener("error", outputError), () => options.output.removeListener("error", outputError));
234
459
  });
235
460
  }
package/dist/report.js CHANGED
@@ -179,7 +179,7 @@ export function renderReport(r, home, s, redacted = false, columns = 80) {
179
179
  lines.push(s.dim(wrap("Posting this? Run with --redact to hide your paths, or --card for an image with only the grade.", width)));
180
180
  else
181
181
  lines.push(s.dim("Share an image with only the grade: npx toolstile audit --card"));
182
- lines.push(s.dim(wrap("Keep credentials off your machines and control each agent's tools: https://toolstile.com/?ref=cli", width)), "");
182
+ lines.push(s.dim(wrap("Keep credentials off your machines and control each agent's tools: https://www.toolstile.com/?ref=cli", width)), "");
183
183
  return lines.join("\n");
184
184
  }
185
185
  /** JSON output for scripts. Paths are included unless redacted; secret values never are. */
package/dist/state.d.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import type { ClientId } from "./types.js";
2
- export declare const DEFAULT_SERVER = "https://toolstile.com";
2
+ export declare const DEFAULT_SERVER = "https://app.toolstile.com";
3
+ /** Toolstile's gateway, on its own host since the app moved off the bare domain. */
4
+ export declare const DEFAULT_MCP_URL = "https://mcp.toolstile.com/mcp";
3
5
  export interface AgentLink {
4
6
  /** The agent's authorization ID in Toolstile, valid in one workspace. */
5
7
  id: string;
6
8
  workspaceId: string;
7
9
  /** The Toolstile origin that issued the token; the proxy sends it nowhere else. */
8
10
  server: string;
11
+ /** That server's gateway URL, as it reported it; `${server}/mcp` when absent. */
12
+ endpoint?: string;
9
13
  label: string;
10
14
  /** The agent's own gateway token; only `toolstile mcp proxy` reads it. */
11
15
  token: string;
@@ -39,10 +43,8 @@ export interface StateEnv {
39
43
  /** `TOOLSTILE_CONFIG_DIR`, else the platform's per-user config folder. */
40
44
  export declare function stateDir({ home, env, platform }?: StateEnv): string;
41
45
  export declare const statePath: (opts?: StateEnv) => string;
42
- /**
43
- * The saved server always wins over `TOOLSTILE_URL`, which only picks the server for a new
44
- * `auth login`: an environment variable set by a project must not redirect saved tokens.
45
- */
46
46
  export declare function loadState(opts?: StateEnv): State;
47
+ /** Hold the credential revision stable during a synchronous config mutation. */
48
+ export declare function withCurrentState<T>(state: State, opts: StateEnv | undefined, run: () => T): T;
47
49
  /** Written to a temporary file and renamed, so a crash never leaves half a file. */
48
- export declare function saveState(state: State, opts?: StateEnv): void;
50
+ export declare function saveState(state: State, opts?: StateEnv, beforeWrite?: () => void): void;
package/dist/state.js CHANGED
@@ -1,7 +1,13 @@
1
+ import { fileText, withFileLock } from "./file-lock.js";
2
+ import { randomUUID } from "node:crypto";
1
3
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
4
  import { homedir } from "node:os";
3
5
  import { join } from "node:path";
4
- export const DEFAULT_SERVER = "https://toolstile.com";
6
+ export const DEFAULT_SERVER = "https://app.toolstile.com";
7
+ /** Toolstile's gateway, on its own host since the app moved off the bare domain. */
8
+ export const DEFAULT_MCP_URL = "https://mcp.toolstile.com/mcp";
9
+ /** Where Toolstile served the app and gateway before; it now only redirects. */
10
+ const LEGACY_SERVER = "https://toolstile.com";
5
11
  /** `TOOLSTILE_CONFIG_DIR`, else the platform's per-user config folder. */
6
12
  export function stateDir({ home = homedir(), env = process.env, platform = process.platform } = {}) {
7
13
  if (env.TOOLSTILE_CONFIG_DIR)
@@ -15,6 +21,7 @@ export const statePath = (opts) => join(stateDir(opts), "credentials.json");
15
21
  * The saved server always wins over `TOOLSTILE_URL`, which only picks the server for a new
16
22
  * `auth login`: an environment variable set by a project must not redirect saved tokens.
17
23
  */
24
+ const revisions = new WeakMap();
18
25
  export function loadState(opts) {
19
26
  const fallback = { server: DEFAULT_SERVER, agents: {} };
20
27
  let text;
@@ -22,30 +29,63 @@ export function loadState(opts) {
22
29
  text = readFileSync(statePath(opts), "utf8");
23
30
  }
24
31
  catch (error) {
25
- if (error.code === "ENOENT")
32
+ if (error.code === "ENOENT") {
33
+ revisions.set(fallback, null);
26
34
  return fallback;
35
+ }
27
36
  throw error;
28
37
  }
29
38
  try {
30
39
  const data = JSON.parse(text);
31
- return { ...fallback, ...data, agents: data.agents ?? {} };
40
+ const state = moveLegacyServer({ ...fallback, ...data, agents: data.agents ?? {} });
41
+ revisions.set(state, text);
42
+ return state;
32
43
  }
33
44
  catch {
34
45
  throw new Error(`${statePath(opts)} is not valid JSON. Delete it and run toolstile auth login again.`);
35
46
  }
36
47
  }
48
+ /** Hold the credential revision stable during a synchronous config mutation. */
49
+ export function withCurrentState(state, opts, run) {
50
+ return withFileLock(statePath(opts), () => {
51
+ if (!revisions.has(state) || revisions.get(state) !== fileText(statePath(opts)))
52
+ throw new Error("Toolstile credentials changed in another command. Run this command again.");
53
+ return run();
54
+ });
55
+ }
37
56
  /** Written to a temporary file and renamed, so a crash never leaves half a file. */
38
- export function saveState(state, opts) {
57
+ export function saveState(state, opts, beforeWrite) {
39
58
  const dir = stateDir(opts);
40
59
  mkdirSync(dir, { recursive: true, mode: 0o700 });
41
60
  const path = statePath(opts);
42
- const temp = `${path}.${process.pid}.tmp`;
43
- try {
44
- writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
45
- chmodSync(temp, 0o600);
46
- renameSync(temp, path);
47
- }
48
- finally {
49
- rmSync(temp, { force: true });
50
- }
61
+ withFileLock(path, () => {
62
+ const before = fileText(path);
63
+ if (revisions.has(state) && revisions.get(state) !== before)
64
+ throw new Error("Toolstile credentials changed in another command. Run this command again.");
65
+ // Optional synchronous config/state transition shares this credential lock.
66
+ beforeWrite?.();
67
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
68
+ try {
69
+ writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
70
+ chmodSync(temp, 0o600);
71
+ renameSync(temp, path);
72
+ revisions.set(state, fileText(path));
73
+ }
74
+ finally {
75
+ rmSync(temp, { force: true });
76
+ }
77
+ });
78
+ }
79
+ /** Sign-ins and agents saved for the old bare-domain address keep working on the new hosts. */
80
+ function moveLegacyServer(state) {
81
+ if (state.server === LEGACY_SERVER)
82
+ state.server = DEFAULT_SERVER;
83
+ if (state.session?.server === LEGACY_SERVER)
84
+ state.session.server = DEFAULT_SERVER;
85
+ for (const link of Object.values(state.agents))
86
+ if (link?.server === LEGACY_SERVER) {
87
+ link.server = DEFAULT_SERVER;
88
+ link.endpoint ??= DEFAULT_MCP_URL;
89
+ }
90
+ return state;
51
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolstile",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups, and connect those agents to Toolstile without putting tokens in their configs.",
5
5
  "keywords": [
6
6
  "mcp",