agent-trellis 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/codex.js +4 -1
- package/dist/adapters/jsonMcp.js +4 -1
- package/dist/adapters/kiro.js +9 -1
- package/dist/adapters/mcpPlan.d.ts +15 -0
- package/dist/adapters/mcpPlan.js +51 -2
- package/dist/adapters/symlinkPlan.js +3 -0
- package/dist/cli.js +37 -2
- package/dist/commands/doctor.d.ts +13 -3
- package/dist/commands/doctor.js +20 -10
- package/dist/commands/mcpAuth.d.ts +28 -0
- package/dist/commands/mcpAuth.js +99 -0
- package/dist/commands/mcpGateway.d.ts +53 -0
- package/dist/commands/mcpGateway.js +148 -0
- package/dist/commands/migrate.d.ts +4 -0
- package/dist/commands/migrate.js +28 -4
- package/dist/commands/onboard.d.ts +34 -0
- package/dist/commands/onboard.js +110 -10
- package/dist/commands/onboardVerdict.d.ts +55 -0
- package/dist/commands/onboardVerdict.js +175 -0
- package/dist/core/adapter.d.ts +7 -0
- package/dist/core/canonical.js +16 -0
- package/dist/core/types.d.ts +30 -4
- package/dist/lib/backup.js +32 -3
- package/dist/lib/gatewayBackend.d.ts +75 -0
- package/dist/lib/gatewayBackend.js +131 -0
- package/dist/lib/mcpConnect.d.ts +53 -0
- package/dist/lib/mcpConnect.js +123 -0
- package/dist/lib/mcpToolRegistry.d.ts +89 -0
- package/dist/lib/mcpToolRegistry.js +94 -0
- package/dist/lib/oauth/discovery.d.ts +53 -0
- package/dist/lib/oauth/discovery.js +95 -0
- package/dist/lib/oauth/flow.d.ts +47 -0
- package/dist/lib/oauth/flow.js +193 -0
- package/dist/lib/oauth/lock.d.ts +25 -0
- package/dist/lib/oauth/lock.js +98 -0
- package/dist/lib/oauth/pkce.d.ts +27 -0
- package/dist/lib/oauth/pkce.js +40 -0
- package/dist/lib/oauth/refresh.d.ts +33 -0
- package/dist/lib/oauth/refresh.js +93 -0
- package/dist/lib/oauth/store.d.ts +55 -0
- package/dist/lib/oauth/store.js +85 -0
- package/dist/pi-bridge/bundle.js +14376 -14336
- package/dist/pi-bridge/index.d.ts +5 -9
- package/dist/pi-bridge/index.js +6 -106
- package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
- package/dist/pi-bridge/schemaTranslate.js +8 -4
- package/docs/architecture.md +88 -19
- package/docs/getting-started.md +120 -5
- package/docs/research.md +166 -0
- package/docs/roadmap.md +17 -0
- package/package.json +4 -3
- package/schema/servers.example.yaml +24 -7
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive half: dynamic client registration (RFC 7591) plus the
|
|
3
|
+
* authorization-code grant with PKCE (RFC 6749 §4.1, RFC 7636).
|
|
4
|
+
*
|
|
5
|
+
* Only ever reached from `trellis mcp auth`, run by a human. The gateway
|
|
6
|
+
* must never call into this file: it is spawned silently by an agent,
|
|
7
|
+
* typically with no terminal and no display, so it can neither show a
|
|
8
|
+
* browser nor wait for one (design.md D9).
|
|
9
|
+
*/
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { createPkcePair } from "./pkce.js";
|
|
13
|
+
export class AuthorizationError extends Error {
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* RFC 7591. Returns `undefined` when the AS advertises no registration
|
|
17
|
+
* endpoint — that is a legitimate configuration (a pre-registered
|
|
18
|
+
* client), not an error, so the caller falls back to whatever client id
|
|
19
|
+
* it was given.
|
|
20
|
+
*/
|
|
21
|
+
export async function registerClient(metadata, redirectUri, opts = {}) {
|
|
22
|
+
if (!metadata.registrationEndpoint)
|
|
23
|
+
return undefined;
|
|
24
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
25
|
+
const response = await fetchImpl(metadata.registrationEndpoint, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
client_name: "Trellis",
|
|
30
|
+
redirect_uris: [redirectUri],
|
|
31
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
32
|
+
response_types: ["code"],
|
|
33
|
+
token_endpoint_auth_method: "none",
|
|
34
|
+
...(opts.scope ? { scope: opts.scope } : {}),
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new AuthorizationError(`dynamic client registration failed with HTTP ${response.status}`);
|
|
39
|
+
}
|
|
40
|
+
const payload = (await response.json());
|
|
41
|
+
if (!payload.client_id) {
|
|
42
|
+
throw new AuthorizationError("dynamic client registration returned no client_id");
|
|
43
|
+
}
|
|
44
|
+
const out = { clientId: payload.client_id };
|
|
45
|
+
if (payload.client_secret)
|
|
46
|
+
out.clientSecret = payload.client_secret;
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** A one-shot loopback listener for the redirect. Bound to 127.0.0.1
|
|
50
|
+
* explicitly, never 0.0.0.0: the authorization code arrives in this URL,
|
|
51
|
+
* and a listener on all interfaces would accept it from the network. */
|
|
52
|
+
function listenForCallback(port) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let settle;
|
|
55
|
+
const received = new Promise((r) => {
|
|
56
|
+
settle = r;
|
|
57
|
+
});
|
|
58
|
+
const server = createServer((req, res) => {
|
|
59
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
60
|
+
const result = {};
|
|
61
|
+
const code = url.searchParams.get("code");
|
|
62
|
+
const state = url.searchParams.get("state");
|
|
63
|
+
const error = url.searchParams.get("error");
|
|
64
|
+
if (code)
|
|
65
|
+
result.code = code;
|
|
66
|
+
if (state)
|
|
67
|
+
result.state = state;
|
|
68
|
+
if (error) {
|
|
69
|
+
result.error = error;
|
|
70
|
+
const description = url.searchParams.get("error_description");
|
|
71
|
+
if (description)
|
|
72
|
+
result.errorDescription = description;
|
|
73
|
+
}
|
|
74
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
75
|
+
res.end(result.error
|
|
76
|
+
? `<html><body><h1>Authorization failed</h1><p>${escapeHtml(result.errorDescription ?? result.error)}</p></body></html>`
|
|
77
|
+
: "<html><body><h1>Authorized</h1><p>You can close this tab and return to your terminal.</p></body></html>");
|
|
78
|
+
settle(result);
|
|
79
|
+
});
|
|
80
|
+
server.on("error", reject);
|
|
81
|
+
server.listen(port, "127.0.0.1", () => {
|
|
82
|
+
resolve({ server, port: server.address().port, received });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function escapeHtml(value) {
|
|
87
|
+
return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Runs the full authorization-code-plus-PKCE exchange and returns a
|
|
91
|
+
* token ready to persist. Does not write anything — storage is the
|
|
92
|
+
* caller's decision, so this stays testable without a home directory.
|
|
93
|
+
*/
|
|
94
|
+
export async function authorize(serverName, metadata, opts = {}) {
|
|
95
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
96
|
+
const now = opts.now ?? Date.now;
|
|
97
|
+
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
98
|
+
const { server, port, received } = await listenForCallback(opts.callbackPort ?? 0);
|
|
99
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
100
|
+
try {
|
|
101
|
+
let clientId = opts.clientId;
|
|
102
|
+
let clientSecret = opts.clientSecret;
|
|
103
|
+
if (!clientId) {
|
|
104
|
+
const registered = await registerClient(metadata, redirectUri, { fetchImpl, scope: opts.scope });
|
|
105
|
+
if (!registered) {
|
|
106
|
+
throw new AuthorizationError(`"${serverName}"'s authorization server offers no dynamic client registration, and no client_id is configured for it`);
|
|
107
|
+
}
|
|
108
|
+
clientId = registered.clientId;
|
|
109
|
+
clientSecret = registered.clientSecret;
|
|
110
|
+
}
|
|
111
|
+
const pkce = createPkcePair();
|
|
112
|
+
const state = randomBytes(16).toString("base64url");
|
|
113
|
+
const authUrl = new URL(metadata.authorizationEndpoint);
|
|
114
|
+
authUrl.searchParams.set("response_type", "code");
|
|
115
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
116
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
117
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
118
|
+
authUrl.searchParams.set("code_challenge_method", pkce.method);
|
|
119
|
+
authUrl.searchParams.set("state", state);
|
|
120
|
+
if (opts.scope)
|
|
121
|
+
authUrl.searchParams.set("scope", opts.scope);
|
|
122
|
+
await (opts.openBrowser ?? defaultOpenBrowser)(authUrl.toString());
|
|
123
|
+
const callback = await withTimeout(received, timeoutMs, `timed out waiting for the authorization callback for "${serverName}"`);
|
|
124
|
+
if (callback.error) {
|
|
125
|
+
throw new AuthorizationError(`authorization for "${serverName}" was refused: ${callback.errorDescription ?? callback.error}`);
|
|
126
|
+
}
|
|
127
|
+
if (!callback.code) {
|
|
128
|
+
throw new AuthorizationError(`authorization for "${serverName}" returned no code`);
|
|
129
|
+
}
|
|
130
|
+
// CSRF: a callback carrying someone else's state is not ours to
|
|
131
|
+
// redeem, regardless of how good the code looks.
|
|
132
|
+
if (callback.state !== state) {
|
|
133
|
+
throw new AuthorizationError(`authorization callback for "${serverName}" carried an unexpected state parameter`);
|
|
134
|
+
}
|
|
135
|
+
const body = new URLSearchParams({
|
|
136
|
+
grant_type: "authorization_code",
|
|
137
|
+
code: callback.code,
|
|
138
|
+
redirect_uri: redirectUri,
|
|
139
|
+
client_id: clientId,
|
|
140
|
+
code_verifier: pkce.verifier,
|
|
141
|
+
});
|
|
142
|
+
if (clientSecret)
|
|
143
|
+
body.set("client_secret", clientSecret);
|
|
144
|
+
const response = await fetchImpl(metadata.tokenEndpoint, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
147
|
+
body: body.toString(),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new AuthorizationError(`token exchange for "${serverName}" failed with HTTP ${response.status}`);
|
|
151
|
+
}
|
|
152
|
+
const payload = (await response.json());
|
|
153
|
+
if (!payload.access_token) {
|
|
154
|
+
throw new AuthorizationError(`token exchange for "${serverName}" returned no access_token`);
|
|
155
|
+
}
|
|
156
|
+
const token = {
|
|
157
|
+
accessToken: payload.access_token,
|
|
158
|
+
tokenEndpoint: metadata.tokenEndpoint,
|
|
159
|
+
clientId,
|
|
160
|
+
};
|
|
161
|
+
if (payload.refresh_token)
|
|
162
|
+
token.refreshToken = payload.refresh_token;
|
|
163
|
+
if (payload.expires_in !== undefined)
|
|
164
|
+
token.expiresAt = now() + payload.expires_in * 1000;
|
|
165
|
+
if (clientSecret)
|
|
166
|
+
token.clientSecret = clientSecret;
|
|
167
|
+
if (payload.scope ?? opts.scope)
|
|
168
|
+
token.scope = payload.scope ?? opts.scope;
|
|
169
|
+
return token;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
server.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const timer = setTimeout(() => reject(new AuthorizationError(message)), timeoutMs);
|
|
178
|
+
promise.then((value) => {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
resolve(value);
|
|
181
|
+
}, (err) => {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
reject(err);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async function defaultOpenBrowser(url) {
|
|
188
|
+
const { spawn } = await import("node:child_process");
|
|
189
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
190
|
+
// Detached and fully ignored: a browser that outlives this command is
|
|
191
|
+
// correct, and its stdio must not be inherited onto ours.
|
|
192
|
+
spawn(command, [url], { detached: true, stdio: "ignore" }).unref();
|
|
193
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-server exclusive lock around OAuth refresh
|
|
3
|
+
* (trellis-mcp-gateway-hosting design.md D16).
|
|
4
|
+
*
|
|
5
|
+
* Why this is a correctness fix and not an optimization: most
|
|
6
|
+
* authorization servers rotate the refresh token on each
|
|
7
|
+
* `refresh_token` grant and invalidate the previous one. Two processes
|
|
8
|
+
* refreshing the same server concurrently therefore leave one of them
|
|
9
|
+
* holding a token that is already dead — and it fails later, somewhere
|
|
10
|
+
* else, looking like an unrelated auth error. This is reachable today
|
|
11
|
+
* with a single gateway, because `trellis mcp auth` can be run by hand
|
|
12
|
+
* while one is live.
|
|
13
|
+
*
|
|
14
|
+
* Scoped to one server: a slow refresh for server A must never delay
|
|
15
|
+
* connecting to server B.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Runs `fn` while holding this server's lock, releasing it even if `fn`
|
|
19
|
+
* throws. `wx` is the atomic primitive: it either creates the file or
|
|
20
|
+
* fails, with no check-then-create window between two processes.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withServerLock<T>(homeDir: string, serverName: string, fn: () => Promise<T>, opts?: {
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-server exclusive lock around OAuth refresh
|
|
3
|
+
* (trellis-mcp-gateway-hosting design.md D16).
|
|
4
|
+
*
|
|
5
|
+
* Why this is a correctness fix and not an optimization: most
|
|
6
|
+
* authorization servers rotate the refresh token on each
|
|
7
|
+
* `refresh_token` grant and invalidate the previous one. Two processes
|
|
8
|
+
* refreshing the same server concurrently therefore leave one of them
|
|
9
|
+
* holding a token that is already dead — and it fails later, somewhere
|
|
10
|
+
* else, looking like an unrelated auth error. This is reachable today
|
|
11
|
+
* with a single gateway, because `trellis mcp auth` can be run by hand
|
|
12
|
+
* while one is live.
|
|
13
|
+
*
|
|
14
|
+
* Scoped to one server: a slow refresh for server A must never delay
|
|
15
|
+
* connecting to server B.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, openSync, closeSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname } from "node:path";
|
|
19
|
+
import { lockPath } from "./store.js";
|
|
20
|
+
/** A holder that died without cleaning up would otherwise wedge every
|
|
21
|
+
* later refresh forever. Two independent recovery signals are used: the
|
|
22
|
+
* recorded pid no longer existing, and the lock simply being older than
|
|
23
|
+
* any plausible refresh. */
|
|
24
|
+
const STALE_AFTER_MS = 60_000;
|
|
25
|
+
const POLL_INTERVAL_MS = 50;
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
27
|
+
function processAlive(pid) {
|
|
28
|
+
try {
|
|
29
|
+
// Signal 0 performs the permission/existence check without
|
|
30
|
+
// delivering anything.
|
|
31
|
+
process.kill(pid, 0);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
// EPERM means it exists but belongs to another user — alive.
|
|
36
|
+
return err.code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function isStale(path, now) {
|
|
40
|
+
let contents;
|
|
41
|
+
try {
|
|
42
|
+
contents = JSON.parse(readFileSync(path, "utf-8"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Unreadable or half-written: treat as stale rather than deadlocking
|
|
46
|
+
// on a file nobody can interpret.
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
if (typeof contents.pid !== "number" || typeof contents.acquiredAt !== "number")
|
|
50
|
+
return true;
|
|
51
|
+
if (now - contents.acquiredAt > STALE_AFTER_MS)
|
|
52
|
+
return true;
|
|
53
|
+
return !processAlive(contents.pid);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Runs `fn` while holding this server's lock, releasing it even if `fn`
|
|
57
|
+
* throws. `wx` is the atomic primitive: it either creates the file or
|
|
58
|
+
* fails, with no check-then-create window between two processes.
|
|
59
|
+
*/
|
|
60
|
+
export async function withServerLock(homeDir, serverName, fn, opts = {}) {
|
|
61
|
+
const path = lockPath(homeDir, serverName);
|
|
62
|
+
const now = opts.now ?? Date.now;
|
|
63
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
64
|
+
const deadline = now() + timeoutMs;
|
|
65
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
66
|
+
for (;;) {
|
|
67
|
+
try {
|
|
68
|
+
const fd = openSync(path, "wx", 0o600);
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: now() }));
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
closeSync(fd);
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (err.code !== "EEXIST")
|
|
79
|
+
throw err;
|
|
80
|
+
if (existsSync(path) && isStale(path, now())) {
|
|
81
|
+
// Best-effort: if another process removed it first, our next
|
|
82
|
+
// `wx` attempt simply succeeds.
|
|
83
|
+
rmSync(path, { force: true });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (now() >= deadline) {
|
|
87
|
+
throw new Error(`timed out waiting for the OAuth refresh lock on "${serverName}" (${path})`);
|
|
88
|
+
}
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return await fn();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
rmSync(path, { force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636), S256 only.
|
|
3
|
+
*
|
|
4
|
+
* `plain` is deliberately not implemented. It exists in the RFC for
|
|
5
|
+
* clients that cannot compute SHA-256; Node can, and offering `plain`
|
|
6
|
+
* would only create a path where a downgrade turns the challenge into
|
|
7
|
+
* the verifier in cleartext.
|
|
8
|
+
*/
|
|
9
|
+
export interface PkcePair {
|
|
10
|
+
verifier: string;
|
|
11
|
+
challenge: string;
|
|
12
|
+
method: "S256";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 32 random bytes → 43 base64url chars, comfortably inside the RFC's
|
|
16
|
+
* 43–128 range. Longer buys nothing: the entropy is the 256 bits, not
|
|
17
|
+
* the string length.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createPkcePair(): PkcePair;
|
|
20
|
+
export declare function computeChallenge(verifier: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Constant-time comparison. Only an authorization server actually needs
|
|
23
|
+
* to verify a challenge — this exists so the fake AS the OAuth tests are
|
|
24
|
+
* written against can do the real check rather than a stubbed one, which
|
|
25
|
+
* is what makes those tests evidence that the client half is correct.
|
|
26
|
+
*/
|
|
27
|
+
export declare function verifyChallenge(verifier: string, challenge: string): boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636), S256 only.
|
|
3
|
+
*
|
|
4
|
+
* `plain` is deliberately not implemented. It exists in the RFC for
|
|
5
|
+
* clients that cannot compute SHA-256; Node can, and offering `plain`
|
|
6
|
+
* would only create a path where a downgrade turns the challenge into
|
|
7
|
+
* the verifier in cleartext.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
|
+
/** base64url per RFC 7636 §A: base64 with `+/` mapped to `-_` and no
|
|
11
|
+
* padding. `Buffer`'s own "base64url" encoding already does exactly
|
|
12
|
+
* this. */
|
|
13
|
+
function base64url(input) {
|
|
14
|
+
return input.toString("base64url");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 32 random bytes → 43 base64url chars, comfortably inside the RFC's
|
|
18
|
+
* 43–128 range. Longer buys nothing: the entropy is the 256 bits, not
|
|
19
|
+
* the string length.
|
|
20
|
+
*/
|
|
21
|
+
export function createPkcePair() {
|
|
22
|
+
const verifier = base64url(randomBytes(32));
|
|
23
|
+
return { verifier, challenge: computeChallenge(verifier), method: "S256" };
|
|
24
|
+
}
|
|
25
|
+
export function computeChallenge(verifier) {
|
|
26
|
+
return base64url(createHash("sha256").update(verifier).digest());
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Constant-time comparison. Only an authorization server actually needs
|
|
30
|
+
* to verify a challenge — this exists so the fake AS the OAuth tests are
|
|
31
|
+
* written against can do the real check rather than a stubbed one, which
|
|
32
|
+
* is what makes those tests evidence that the client half is correct.
|
|
33
|
+
*/
|
|
34
|
+
export function verifyChallenge(verifier, challenge) {
|
|
35
|
+
const expected = Buffer.from(computeChallenge(verifier));
|
|
36
|
+
const actual = Buffer.from(challenge);
|
|
37
|
+
if (expected.length !== actual.length)
|
|
38
|
+
return false;
|
|
39
|
+
return timingSafeEqual(expected, actual);
|
|
40
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `refresh_token` grant (RFC 6749 §6).
|
|
3
|
+
*
|
|
4
|
+
* Callable from two places with different rules: `trellis mcp auth`, run
|
|
5
|
+
* by a human, and the gateway subcommand, spawned silently by an agent.
|
|
6
|
+
* Both use this identical path, because refreshing needs neither a
|
|
7
|
+
* browser nor a callback listener — which is exactly why the gateway is
|
|
8
|
+
* allowed to do it and is not allowed to do the initial authorization
|
|
9
|
+
* (design.md D9).
|
|
10
|
+
*/
|
|
11
|
+
import { type StoredToken } from "./store.js";
|
|
12
|
+
import type { FetchLike } from "./discovery.js";
|
|
13
|
+
export interface RefreshOptions {
|
|
14
|
+
fetchImpl?: FetchLike;
|
|
15
|
+
/** Test seam; also lets a caller widen the early-refresh skew. */
|
|
16
|
+
now?: () => number;
|
|
17
|
+
skewMs?: number;
|
|
18
|
+
lockTimeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare class RefreshError extends Error {
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Returns a token that is valid now, refreshing first if it isn't.
|
|
24
|
+
*
|
|
25
|
+
* `undefined` means there is nothing stored for this server at all — the
|
|
26
|
+
* caller decides whether that is "skip this upstream" (the gateway) or
|
|
27
|
+
* "run the full flow" (`trellis mcp auth`). A refresh that is attempted
|
|
28
|
+
* and fails throws instead, because that is a different situation: a
|
|
29
|
+
* credential exists but is dead, and silently treating it as absent
|
|
30
|
+
* would hide a revoked grant.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ensureFreshToken(homeDir: string, serverName: string, opts?: RefreshOptions): Promise<StoredToken | undefined>;
|
|
33
|
+
export declare function performRefresh(serverName: string, token: StoredToken, opts?: RefreshOptions): Promise<StoredToken>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `refresh_token` grant (RFC 6749 §6).
|
|
3
|
+
*
|
|
4
|
+
* Callable from two places with different rules: `trellis mcp auth`, run
|
|
5
|
+
* by a human, and the gateway subcommand, spawned silently by an agent.
|
|
6
|
+
* Both use this identical path, because refreshing needs neither a
|
|
7
|
+
* browser nor a callback listener — which is exactly why the gateway is
|
|
8
|
+
* allowed to do it and is not allowed to do the initial authorization
|
|
9
|
+
* (design.md D9).
|
|
10
|
+
*/
|
|
11
|
+
import { isExpired, readToken, writeToken } from "./store.js";
|
|
12
|
+
import { withServerLock } from "./lock.js";
|
|
13
|
+
export class RefreshError extends Error {
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Returns a token that is valid now, refreshing first if it isn't.
|
|
17
|
+
*
|
|
18
|
+
* `undefined` means there is nothing stored for this server at all — the
|
|
19
|
+
* caller decides whether that is "skip this upstream" (the gateway) or
|
|
20
|
+
* "run the full flow" (`trellis mcp auth`). A refresh that is attempted
|
|
21
|
+
* and fails throws instead, because that is a different situation: a
|
|
22
|
+
* credential exists but is dead, and silently treating it as absent
|
|
23
|
+
* would hide a revoked grant.
|
|
24
|
+
*/
|
|
25
|
+
export async function ensureFreshToken(homeDir, serverName, opts = {}) {
|
|
26
|
+
const now = opts.now ?? Date.now;
|
|
27
|
+
const existing = readToken(homeDir, serverName);
|
|
28
|
+
if (!existing)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!isExpired(existing, opts.skewMs, now()))
|
|
31
|
+
return existing;
|
|
32
|
+
if (!existing.refreshToken) {
|
|
33
|
+
throw new RefreshError(`OAuth token for "${serverName}" has expired and there is no refresh token — run \`trellis mcp auth ${serverName}\``);
|
|
34
|
+
}
|
|
35
|
+
return withServerLock(homeDir, serverName, async () => {
|
|
36
|
+
// Re-read inside the lock. Whoever held it before us may have just
|
|
37
|
+
// refreshed, in which case doing it again would rotate the token
|
|
38
|
+
// out from under them for nothing.
|
|
39
|
+
const current = readToken(homeDir, serverName);
|
|
40
|
+
if (current && !isExpired(current, opts.skewMs, now()))
|
|
41
|
+
return current;
|
|
42
|
+
const source = current ?? existing;
|
|
43
|
+
if (!source.refreshToken) {
|
|
44
|
+
throw new RefreshError(`OAuth token for "${serverName}" has expired and there is no refresh token — run \`trellis mcp auth ${serverName}\``);
|
|
45
|
+
}
|
|
46
|
+
const refreshed = await performRefresh(serverName, source, opts);
|
|
47
|
+
writeToken(homeDir, serverName, refreshed);
|
|
48
|
+
return refreshed;
|
|
49
|
+
}, { timeoutMs: opts.lockTimeoutMs, now });
|
|
50
|
+
}
|
|
51
|
+
export async function performRefresh(serverName, token, opts = {}) {
|
|
52
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
53
|
+
const now = opts.now ?? Date.now;
|
|
54
|
+
if (!token.tokenEndpoint) {
|
|
55
|
+
throw new RefreshError(`OAuth token for "${serverName}" has no recorded token endpoint — run \`trellis mcp auth ${serverName}\``);
|
|
56
|
+
}
|
|
57
|
+
const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: token.refreshToken });
|
|
58
|
+
if (token.clientId)
|
|
59
|
+
body.set("client_id", token.clientId);
|
|
60
|
+
if (token.clientSecret)
|
|
61
|
+
body.set("client_secret", token.clientSecret);
|
|
62
|
+
const response = await fetchImpl(token.tokenEndpoint, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
65
|
+
body: body.toString(),
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${await safeText(response)}`);
|
|
69
|
+
}
|
|
70
|
+
const payload = (await response.json());
|
|
71
|
+
if (!payload.access_token) {
|
|
72
|
+
throw new RefreshError(`refresh_token grant for "${serverName}" returned no access_token`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
...token,
|
|
76
|
+
accessToken: payload.access_token,
|
|
77
|
+
// Rotation: a server that issues a new refresh token has invalidated
|
|
78
|
+
// the old one, so keeping the old one would be keeping a dead
|
|
79
|
+
// credential. A server that omits it is signalling the existing one
|
|
80
|
+
// stays valid.
|
|
81
|
+
refreshToken: payload.refresh_token ?? token.refreshToken,
|
|
82
|
+
expiresAt: payload.expires_in !== undefined ? now() + payload.expires_in * 1000 : undefined,
|
|
83
|
+
scope: payload.scope ?? token.scope,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function safeText(response) {
|
|
87
|
+
try {
|
|
88
|
+
return (await response.text()).slice(0, 200);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return "<unreadable body>";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth token storage: one file per server under
|
|
3
|
+
* `~/.trellis/mcp/oauth/`, mode 0600 (trellis-mcp-gateway-hosting
|
|
4
|
+
* design.md D8).
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT `resolveSecretEnv`, and deliberately NOT the OS
|
|
7
|
+
* keychain. `resolveSecretEnv`'s contract is "resolve a name the user
|
|
8
|
+
* wrote in canonical" — a token is the opposite: a value Trellis obtains
|
|
9
|
+
* and rotates on its own, which the user never names. And this change's
|
|
10
|
+
* research spike reproduced `security add-generic-password` failing
|
|
11
|
+
* (exit 152/154) in a non-interactive shell with no GUI session — exactly
|
|
12
|
+
* the environment an agent-spawned gateway subprocess runs in.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here ever touches `servers.yaml`. A canonical file must stay
|
|
15
|
+
* safe to read, diff, and commit.
|
|
16
|
+
*/
|
|
17
|
+
export interface StoredToken {
|
|
18
|
+
accessToken: string;
|
|
19
|
+
refreshToken?: string;
|
|
20
|
+
/** Epoch ms. Absent means "no expiry was advertised" — treated as
|
|
21
|
+
* never expiring, since guessing one would refresh a perfectly valid
|
|
22
|
+
* token for no reason. */
|
|
23
|
+
expiresAt?: number;
|
|
24
|
+
/** From dynamic client registration, or configured by hand. Kept so a
|
|
25
|
+
* refresh never has to re-register. */
|
|
26
|
+
clientId?: string;
|
|
27
|
+
clientSecret?: string;
|
|
28
|
+
/** Kept so a silent refresh needs no network round trip to rediscover
|
|
29
|
+
* metadata — the gateway's refresh path should be one request. */
|
|
30
|
+
tokenEndpoint?: string;
|
|
31
|
+
scope?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function oauthDir(homeDir: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Server names are YAML keys, so they can contain anything — including
|
|
36
|
+
* `/` and `..`. They are used verbatim as filenames here, so a name that
|
|
37
|
+
* could escape `oauthDir` is rejected rather than sanitized: silently
|
|
38
|
+
* mapping two different server names onto one file would cross two
|
|
39
|
+
* servers' credentials, which is worse than refusing.
|
|
40
|
+
*/
|
|
41
|
+
export declare function tokenPath(homeDir: string, serverName: string): string;
|
|
42
|
+
export declare function lockPath(homeDir: string, serverName: string): string;
|
|
43
|
+
export declare function readToken(homeDir: string, serverName: string): StoredToken | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Written 0600 from the moment it first exists, never "create then
|
|
46
|
+
* chmod" — the gap between those two is a window where another local
|
|
47
|
+
* user can read the token. `writeFileSync`'s `mode` only applies on
|
|
48
|
+
* creation, so an existing file is chmod'd explicitly to repair a
|
|
49
|
+
* previously-loose permission.
|
|
50
|
+
*/
|
|
51
|
+
export declare function writeToken(homeDir: string, serverName: string, token: StoredToken): void;
|
|
52
|
+
export declare function deleteToken(homeDir: string, serverName: string): void;
|
|
53
|
+
/** A token with no `expiresAt` never expires. `skewMs` refreshes slightly
|
|
54
|
+
* early so a token doesn't die mid-request after passing this check. */
|
|
55
|
+
export declare function isExpired(token: StoredToken, skewMs?: number, now?: number): boolean;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth token storage: one file per server under
|
|
3
|
+
* `~/.trellis/mcp/oauth/`, mode 0600 (trellis-mcp-gateway-hosting
|
|
4
|
+
* design.md D8).
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT `resolveSecretEnv`, and deliberately NOT the OS
|
|
7
|
+
* keychain. `resolveSecretEnv`'s contract is "resolve a name the user
|
|
8
|
+
* wrote in canonical" — a token is the opposite: a value Trellis obtains
|
|
9
|
+
* and rotates on its own, which the user never names. And this change's
|
|
10
|
+
* research spike reproduced `security add-generic-password` failing
|
|
11
|
+
* (exit 152/154) in a non-interactive shell with no GUI session — exactly
|
|
12
|
+
* the environment an agent-spawned gateway subprocess runs in.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here ever touches `servers.yaml`. A canonical file must stay
|
|
15
|
+
* safe to read, diff, and commit.
|
|
16
|
+
*/
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
export function oauthDir(homeDir) {
|
|
20
|
+
return join(homeDir, ".trellis", "mcp", "oauth");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Server names are YAML keys, so they can contain anything — including
|
|
24
|
+
* `/` and `..`. They are used verbatim as filenames here, so a name that
|
|
25
|
+
* could escape `oauthDir` is rejected rather than sanitized: silently
|
|
26
|
+
* mapping two different server names onto one file would cross two
|
|
27
|
+
* servers' credentials, which is worse than refusing.
|
|
28
|
+
*/
|
|
29
|
+
export function tokenPath(homeDir, serverName) {
|
|
30
|
+
assertSafeServerName(serverName);
|
|
31
|
+
return join(oauthDir(homeDir), `${serverName}.json`);
|
|
32
|
+
}
|
|
33
|
+
export function lockPath(homeDir, serverName) {
|
|
34
|
+
assertSafeServerName(serverName);
|
|
35
|
+
return join(oauthDir(homeDir), `${serverName}.lock`);
|
|
36
|
+
}
|
|
37
|
+
function assertSafeServerName(serverName) {
|
|
38
|
+
if (serverName === "" || serverName === "." || serverName === ".." || /[/\\]/.test(serverName) || serverName.startsWith(".")) {
|
|
39
|
+
throw new Error(`refusing to use MCP server name "${serverName}" as an OAuth credential filename`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function readToken(homeDir, serverName) {
|
|
43
|
+
const path = tokenPath(homeDir, serverName);
|
|
44
|
+
if (!existsSync(path))
|
|
45
|
+
return undefined;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// A corrupt credential file is treated as "no credential": the
|
|
51
|
+
// recovery is to re-run `trellis mcp auth`, and refusing to start the
|
|
52
|
+
// gateway over it would take down every other server too.
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Written 0600 from the moment it first exists, never "create then
|
|
58
|
+
* chmod" — the gap between those two is a window where another local
|
|
59
|
+
* user can read the token. `writeFileSync`'s `mode` only applies on
|
|
60
|
+
* creation, so an existing file is chmod'd explicitly to repair a
|
|
61
|
+
* previously-loose permission.
|
|
62
|
+
*/
|
|
63
|
+
export function writeToken(homeDir, serverName, token) {
|
|
64
|
+
const dir = oauthDir(homeDir);
|
|
65
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
66
|
+
const path = tokenPath(homeDir, serverName);
|
|
67
|
+
// Write-then-rename so a reader never observes a half-written file —
|
|
68
|
+
// the gateway reads these at startup while `trellis mcp auth` may be
|
|
69
|
+
// writing one.
|
|
70
|
+
const tmp = `${path}.tmp`;
|
|
71
|
+
writeFileSync(tmp, `${JSON.stringify(token, null, 2)}\n`, { mode: 0o600 });
|
|
72
|
+
chmodSync(tmp, 0o600);
|
|
73
|
+
renameSync(tmp, path);
|
|
74
|
+
chmodSync(path, 0o600);
|
|
75
|
+
}
|
|
76
|
+
export function deleteToken(homeDir, serverName) {
|
|
77
|
+
rmSync(tokenPath(homeDir, serverName), { force: true });
|
|
78
|
+
}
|
|
79
|
+
/** A token with no `expiresAt` never expires. `skewMs` refreshes slightly
|
|
80
|
+
* early so a token doesn't die mid-request after passing this check. */
|
|
81
|
+
export function isExpired(token, skewMs = 60_000, now = Date.now()) {
|
|
82
|
+
if (token.expiresAt === undefined)
|
|
83
|
+
return false;
|
|
84
|
+
return token.expiresAt - skewMs <= now;
|
|
85
|
+
}
|