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 +3 -3
- package/dist/agents.d.ts +2 -1
- package/dist/agents.js +23 -4
- package/dist/api.d.ts +2 -0
- package/dist/api.js +31 -19
- package/dist/bounds.d.ts +3 -0
- package/dist/bounds.js +43 -0
- package/dist/cli.js +1 -1
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +114 -40
- package/dist/file-lock.d.ts +3 -0
- package/dist/file-lock.js +38 -0
- package/dist/fix.js +24 -28
- package/dist/proxy.js +307 -82
- package/dist/report.js +1 -1
- package/dist/state.d.ts +8 -6
- package/dist/state.js +53 -13
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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) {
|
package/dist/bounds.d.ts
ADDED
|
@@ -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
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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)}${
|
|
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(
|
|
122
|
-
.signOut(
|
|
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
|
-
//
|
|
270
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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}
|
|
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,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
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
profile =
|
|
498
|
-
|
|
499
|
-
|
|
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 {
|
|
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
|
-
|
|
40
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
41
|
+
let start = 0;
|
|
10
42
|
let newline;
|
|
11
|
-
while ((newline =
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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 (
|
|
51
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
|
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
|
|
107
|
-
|
|
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 (
|
|
225
|
+
if (listener || !sessionId || closed)
|
|
112
226
|
return;
|
|
113
|
-
|
|
227
|
+
const current = listener = new AbortController();
|
|
228
|
+
const stop = () => current.abort();
|
|
229
|
+
shutdown.signal.addEventListener("abort", stop, { once: true });
|
|
114
230
|
let lastEventId;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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" })
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
"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",
|