ssh-mcp-pro 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +23 -1
  3. package/dist/config-parsers.d.ts +12 -0
  4. package/dist/config-parsers.d.ts.map +1 -0
  5. package/dist/config-parsers.js +60 -0
  6. package/dist/config-parsers.js.map +1 -0
  7. package/dist/config.d.ts +3 -2
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +3 -58
  10. package/dist/config.js.map +1 -1
  11. package/dist/ensure-pkg.d.ts +9 -0
  12. package/dist/ensure-pkg.d.ts.map +1 -0
  13. package/dist/ensure-pkg.js +105 -0
  14. package/dist/ensure-pkg.js.map +1 -0
  15. package/dist/ensure.d.ts.map +1 -1
  16. package/dist/ensure.js +2 -104
  17. package/dist/ensure.js.map +1 -1
  18. package/dist/fs-sftp.d.ts +58 -0
  19. package/dist/fs-sftp.d.ts.map +1 -0
  20. package/dist/fs-sftp.js +184 -0
  21. package/dist/fs-sftp.js.map +1 -0
  22. package/dist/fs-tools.d.ts.map +1 -1
  23. package/dist/fs-tools.js +2 -144
  24. package/dist/fs-tools.js.map +1 -1
  25. package/dist/mcp.d.ts +1 -1
  26. package/dist/mcp.js +1 -1
  27. package/dist/remote/agent-handler.d.ts +36 -0
  28. package/dist/remote/agent-handler.d.ts.map +1 -0
  29. package/dist/remote/agent-handler.js +255 -0
  30. package/dist/remote/agent-handler.js.map +1 -0
  31. package/dist/remote/control-plane.d.ts +4 -17
  32. package/dist/remote/control-plane.d.ts.map +1 -1
  33. package/dist/remote/control-plane.js +23 -657
  34. package/dist/remote/control-plane.js.map +1 -1
  35. package/dist/remote/http-util.d.ts +29 -0
  36. package/dist/remote/http-util.d.ts.map +1 -0
  37. package/dist/remote/http-util.js +159 -0
  38. package/dist/remote/http-util.js.map +1 -0
  39. package/dist/remote/oauth-handler.d.ts +47 -0
  40. package/dist/remote/oauth-handler.d.ts.map +1 -0
  41. package/dist/remote/oauth-handler.js +296 -0
  42. package/dist/remote/oauth-handler.js.map +1 -0
  43. package/dist/session-auth.d.ts +39 -0
  44. package/dist/session-auth.d.ts.map +1 -0
  45. package/dist/session-auth.js +148 -0
  46. package/dist/session-auth.js.map +1 -0
  47. package/dist/session.d.ts +0 -19
  48. package/dist/session.d.ts.map +1 -1
  49. package/dist/session.js +8 -154
  50. package/dist/session.js.map +1 -1
  51. package/docs/audit/2026-06-05-ecosystem-audit.md +1 -1
  52. package/docs/governance/issue-taxonomy.json +5 -0
  53. package/mcp.json +1 -1
  54. package/package.json +18 -12
  55. package/registry/ssh-mcp-pro/mcp.json +1 -1
  56. package/server.json +3 -3
@@ -1,168 +1,17 @@
1
- import { createPublicKey } from "node:crypto";
2
1
  import { URL } from "node:url";
3
- import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, } from "@modelcontextprotocol/sdk/types.js";
4
- import { ensurePemKeyPair, hashSecret, id, issueAccessToken, keyId, loadJwtKeyPair, nowIso, publicJwkFromPem, randomToken, sha256Base64Url, signEnvelope, verifyEnvelope, verifyRemoteAccessToken, } from "./crypto.js";
2
+ import { ensurePemKeyPair, hashSecret, id, keyId, loadJwtKeyPair, nowIso, randomToken, signEnvelope, verifyRemoteAccessToken, } from "./crypto.js";
5
3
  import { loadRemoteConfig } from "./config.js";
4
+ import { isRecord, asString, negotiateMcpProtocolVersion, quotePosixArg, quotePowerShellArg, addNoStore, safeError, isValidEd25519PublicKey, readJson, sanitizeAgent, } from "./http-util.js";
6
5
  import { listRemoteToolDescriptors } from "./mcp-tools.js";
7
6
  import { createAgentPolicy, mergeCustomPolicy } from "./policy.js";
8
- import { parseActionResultEnvelope, parseAgentHelloEnvelope, parseAgentHostMetadata, } from "./schemas.js";
9
- import { hasCapability, parseScopes } from "./scopes.js";
7
+ import { parseAgentHostMetadata } from "./schemas.js";
8
+ import { hasCapability } from "./scopes.js";
10
9
  import { RemoteStore } from "./store.js";
11
- import { REMOTE_SCOPES, TOOL_CAPABILITY_MAP } from "./types.js";
12
- import { formDecode, jsonResponse } from "./util.js";
13
- import { acceptWebSocketUpgrade } from "./websocket.js";
10
+ import { OAuthHandler } from "./oauth-handler.js";
11
+ import { AgentWebSocketHandler, } from "./agent-handler.js";
12
+ import { TOOL_CAPABILITY_MAP } from "./types.js";
13
+ import { jsonResponse } from "./util.js";
14
14
  import { SERVER_VERSION } from "../mcp.js";
15
- const AGENT_NONCE_TTL_MS = 300_000;
16
- const MAX_AGENT_CONNECTION_NONCES = 4096;
17
- function isRecord(value) {
18
- return value !== null && typeof value === "object" && !Array.isArray(value);
19
- }
20
- function asString(value) {
21
- return typeof value === "string" ? value : undefined;
22
- }
23
- function negotiateMcpProtocolVersion(params) {
24
- const requestedVersion = isRecord(params) ? asString(params.protocolVersion) : undefined;
25
- return requestedVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
26
- ? requestedVersion
27
- : LATEST_PROTOCOL_VERSION;
28
- }
29
- function quotePosixArg(value) {
30
- return `'${value.replace(/'/gu, `'"'"'`)}'`;
31
- }
32
- function quotePowerShellArg(value) {
33
- return `'${value.replace(/'/gu, "''")}'`;
34
- }
35
- function asStringArray(value) {
36
- if (!Array.isArray(value)) {
37
- return [];
38
- }
39
- return value.filter((item) => typeof item === "string");
40
- }
41
- function pruneNonceWindow(nonces, now = Date.now()) {
42
- for (const [nonce, expiresAt] of nonces.entries()) {
43
- if (expiresAt <= now) {
44
- nonces.delete(nonce);
45
- }
46
- }
47
- }
48
- function hasSeenNonce(nonces, nonce, now = Date.now()) {
49
- pruneNonceWindow(nonces, now);
50
- return nonces.has(nonce);
51
- }
52
- function rememberNonce(nonces, nonce, now = Date.now()) {
53
- pruneNonceWindow(nonces, now);
54
- nonces.set(nonce, now + AGENT_NONCE_TTL_MS);
55
- while (nonces.size > MAX_AGENT_CONNECTION_NONCES) {
56
- const oldest = nonces.keys().next().value;
57
- if (typeof oldest !== "string") {
58
- break;
59
- }
60
- nonces.delete(oldest);
61
- }
62
- }
63
- function addNoStore(headers = {}) {
64
- return {
65
- "Cache-Control": "no-store",
66
- Pragma: "no-cache",
67
- ...headers,
68
- };
69
- }
70
- function redirect(res, location) {
71
- res.writeHead(302, { Location: location, "Cache-Control": "no-store" });
72
- res.end();
73
- }
74
- function safeError(code, message, status = 400) {
75
- return { code, message, status };
76
- }
77
- function isValidEd25519PublicKey(publicKeyPem) {
78
- try {
79
- return createPublicKey(publicKeyPem).asymmetricKeyType === "ed25519";
80
- }
81
- catch {
82
- return false;
83
- }
84
- }
85
- async function readBody(req, maxBytes = 1_000_000) {
86
- const chunks = [];
87
- let size = 0;
88
- for await (const chunk of req) {
89
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
90
- size += buffer.byteLength;
91
- if (size > maxBytes) {
92
- throw safeError("FORBIDDEN", "Request body is too large", 413);
93
- }
94
- chunks.push(buffer);
95
- }
96
- return Buffer.concat(chunks).toString("utf8");
97
- }
98
- async function readJson(req) {
99
- const raw = await readBody(req);
100
- if (!raw.trim()) {
101
- return {};
102
- }
103
- const parsed = JSON.parse(raw);
104
- if (!isRecord(parsed)) {
105
- throw safeError("INTERNAL_ERROR", "Expected JSON object");
106
- }
107
- return parsed;
108
- }
109
- async function readJsonOrForm(req) {
110
- const raw = await readBody(req);
111
- const contentType = req.headers["content-type"] ?? "";
112
- if (String(contentType).includes("application/json")) {
113
- const parsed = raw ? JSON.parse(raw) : {};
114
- if (!isRecord(parsed)) {
115
- throw safeError("INTERNAL_ERROR", "Expected JSON object");
116
- }
117
- return Object.fromEntries(Object.entries(parsed).map(([key, value]) => [
118
- key,
119
- typeof value === "string" ? value : String(value ?? ""),
120
- ]));
121
- }
122
- try {
123
- return formDecode(raw);
124
- }
125
- catch {
126
- throw safeError("INVALID_CLIENT", "Duplicate form parameter");
127
- }
128
- }
129
- function isSafeRedirectUri(uri) {
130
- try {
131
- const parsed = new URL(uri);
132
- if (parsed.protocol === "https:") {
133
- return true;
134
- }
135
- return (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname));
136
- }
137
- catch {
138
- return false;
139
- }
140
- }
141
- function pkceChallenge(verifier) {
142
- return sha256Base64Url(verifier);
143
- }
144
- function scopeList(scope) {
145
- const valid = new Set(REMOTE_SCOPES);
146
- const rawScopes = scope.split(/\s+/u).filter(Boolean);
147
- if (rawScopes.some((entry) => !valid.has(entry))) {
148
- throw safeError("INVALID_SCOPE", "Requested scope is not supported");
149
- }
150
- const scopes = parseScopes(scope);
151
- return scopes.length > 0 ? scopes : ["hosts:read", "agents:read", "status:read", "logs:read"];
152
- }
153
- function sanitizeAgent(agent) {
154
- return {
155
- id: agent.id,
156
- alias: agent.alias,
157
- status: agent.status,
158
- profile: agent.profile,
159
- policy_version: agent.policyVersion,
160
- host_metadata: agent.hostMetadata,
161
- last_seen_at: agent.lastSeenAt,
162
- created_at: agent.createdAt,
163
- updated_at: agent.updatedAt,
164
- };
165
- }
166
15
  export class RemoteControlPlane {
167
16
  config;
168
17
  store;
@@ -173,10 +22,14 @@ export class RemoteControlPlane {
173
22
  cleanupInterval;
174
23
  jwtKeyPair;
175
24
  controlPlaneKeyPair;
25
+ oauth;
26
+ agentHandler;
176
27
  constructor(config = loadRemoteConfig()) {
177
28
  this.config = config;
178
29
  this.store = new RemoteStore(config.databaseUrl);
179
30
  this.controlPlaneKeyPair = ensurePemKeyPair(config.controlPlaneSigningKeyPath);
31
+ this.oauth = new OAuthHandler(config, this.store, this.authorizeTransactions, () => this.requireJwtKeyPair(), (event) => this.audit(event));
32
+ this.agentHandler = new AgentWebSocketHandler(config, this.store, this.agentConnections, this.agentHelloNonces, this.pendingActions, (event) => this.audit(event));
180
33
  this.cleanupInterval = setInterval(() => this.cleanupEphemeralState(), 60_000);
181
34
  this.cleanupInterval.unref?.();
182
35
  }
@@ -199,48 +52,36 @@ export class RemoteControlPlane {
199
52
  this.store.close();
200
53
  }
201
54
  cleanupEphemeralState(now = Date.now()) {
202
- for (const [transactionId, transaction] of this.authorizeTransactions.entries()) {
203
- if (transaction.expiresAt <= now) {
204
- this.authorizeTransactions.delete(transactionId);
205
- }
206
- }
207
- for (const [agentId, nonces] of this.agentHelloNonces.entries()) {
208
- pruneNonceWindow(nonces, now);
209
- if (nonces.size === 0) {
210
- this.agentHelloNonces.delete(agentId);
211
- }
212
- }
213
- for (const live of this.agentConnections.values()) {
214
- pruneNonceWindow(live.seenNonces, now);
215
- }
55
+ this.oauth.cleanupExpired(now);
56
+ this.agentHandler.cleanupEphemeralState(now);
216
57
  }
217
58
  async handleHttp(req, res, pathname) {
218
59
  if (pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
219
- jsonResponse(res, 200, this.protectedResourceMetadata(), addNoStore());
60
+ jsonResponse(res, 200, this.oauth.protectedResourceMetadata(), addNoStore());
220
61
  return true;
221
62
  }
222
63
  if (pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
223
- jsonResponse(res, 200, this.authorizationServerMetadata(), addNoStore());
64
+ jsonResponse(res, 200, this.oauth.authorizationServerMetadata(), addNoStore());
224
65
  return true;
225
66
  }
226
67
  if (pathname === "/oauth/register" && req.method === "POST") {
227
- await this.handleRegister(req, res);
68
+ await this.oauth.handleRegister(req, res);
228
69
  return true;
229
70
  }
230
71
  if (pathname === "/oauth/authorize" && req.method === "GET") {
231
- await this.handleAuthorize(req, res);
72
+ await this.oauth.handleAuthorize(req, res);
232
73
  return true;
233
74
  }
234
75
  if (pathname === "/oauth/callback/github" && req.method === "GET") {
235
- await this.handleGitHubCallback(req, res);
76
+ await this.oauth.handleGitHubCallback(req, res);
236
77
  return true;
237
78
  }
238
79
  if (pathname === "/oauth/token" && req.method === "POST") {
239
- await this.handleToken(req, res);
80
+ await this.oauth.handleToken(req, res);
240
81
  return true;
241
82
  }
242
83
  if (pathname === "/oauth/jwks.json" && req.method === "GET") {
243
- await this.handleJwks(res);
84
+ await this.oauth.handleJwks(res);
244
85
  return true;
245
86
  }
246
87
  if (pathname === "/readyz" && req.method === "GET") {
@@ -248,7 +89,7 @@ export class RemoteControlPlane {
248
89
  ok: true,
249
90
  service: "ssh-mcp-pro",
250
91
  control_plane: true,
251
- agents_online: this.agentConnections.size,
92
+ agents_online: this.agentHandler.connectedAgentCount,
252
93
  });
253
94
  return true;
254
95
  }
@@ -263,281 +104,7 @@ export class RemoteControlPlane {
263
104
  return false;
264
105
  }
265
106
  handleUpgrade(req, socket, head, pathname) {
266
- if (pathname !== this.config.agentWsPath) {
267
- return false;
268
- }
269
- const connection = acceptWebSocketUpgrade(req, socket, head);
270
- connection.onText((message) => {
271
- void this.handleAgentMessage(connection, message).catch(() => {
272
- connection.sendJson({
273
- type: "error",
274
- code: "INTERNAL_ERROR",
275
- message: "Agent message failed",
276
- });
277
- connection.close();
278
- });
279
- });
280
- return true;
281
- }
282
- protectedResourceMetadata() {
283
- return {
284
- resource: this.config.mcpResourceUrl,
285
- resource_name: "SshAutomator MCP",
286
- authorization_servers: [this.config.publicBaseUrl],
287
- bearer_methods_supported: ["header"],
288
- scopes_supported: REMOTE_SCOPES,
289
- };
290
- }
291
- authorizationServerMetadata() {
292
- return {
293
- issuer: this.config.publicBaseUrl,
294
- authorization_endpoint: `${this.config.publicBaseUrl}/oauth/authorize`,
295
- token_endpoint: `${this.config.publicBaseUrl}/oauth/token`,
296
- registration_endpoint: `${this.config.publicBaseUrl}/oauth/register`,
297
- jwks_uri: `${this.config.publicBaseUrl}/oauth/jwks.json`,
298
- response_types_supported: ["code"],
299
- grant_types_supported: ["authorization_code"],
300
- code_challenge_methods_supported: ["S256"],
301
- token_endpoint_auth_methods_supported: ["none"],
302
- scopes_supported: REMOTE_SCOPES,
303
- };
304
- }
305
- async handleRegister(req, res) {
306
- const body = await readJson(req);
307
- const redirectUris = asStringArray(body.redirect_uris);
308
- if (redirectUris.length === 0 || redirectUris.some((uri) => !isSafeRedirectUri(uri))) {
309
- throw safeError("INVALID_REDIRECT_URI", "redirect_uris must contain HTTPS URLs or localhost HTTP URLs");
310
- }
311
- if (this.store.countOAuthClients() >= this.config.maxOAuthClients) {
312
- throw safeError("FORBIDDEN", "OAuth client registration limit reached", 429);
313
- }
314
- const now = nowIso();
315
- const client = {
316
- id: id("clirow"),
317
- clientId: id("cli"),
318
- clientName: asString(body.client_name) ?? "ChatGPT Connector",
319
- redirectUris,
320
- grantTypes: ["authorization_code"],
321
- responseTypes: ["code"],
322
- tokenEndpointAuthMethod: "none",
323
- createdAt: now,
324
- };
325
- this.store.insertClient(client);
326
- this.audit({
327
- eventType: "oauth_client_registered",
328
- severity: "info",
329
- metadata: { client_id: client.clientId, redirect_uri_count: redirectUris.length },
330
- });
331
- jsonResponse(res, 201, {
332
- client_id: client.clientId,
333
- client_name: client.clientName,
334
- redirect_uris: client.redirectUris,
335
- grant_types: client.grantTypes,
336
- response_types: client.responseTypes,
337
- token_endpoint_auth_method: client.tokenEndpointAuthMethod,
338
- }, addNoStore());
339
- }
340
- async handleAuthorize(req, res) {
341
- const url = new URL(req.url ?? "/oauth/authorize", this.config.publicBaseUrl);
342
- const clientId = url.searchParams.get("client_id") ?? "";
343
- const redirectUri = url.searchParams.get("redirect_uri") ?? "";
344
- const responseType = url.searchParams.get("response_type") ?? "";
345
- const codeChallenge = url.searchParams.get("code_challenge") ?? "";
346
- const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "";
347
- const state = url.searchParams.get("state") ?? "";
348
- const resource = url.searchParams.get("resource") ?? this.config.mcpResourceUrl;
349
- const scope = url.searchParams.get("scope") ?? "hosts:read agents:read status:read logs:read";
350
- this.validateAuthorizeParams(clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod, resource, scope);
351
- const pending = {
352
- clientId,
353
- redirectUri,
354
- codeChallenge,
355
- resource,
356
- scope,
357
- state,
358
- expiresAt: Date.now() + this.config.authCodeTtlSeconds * 1000,
359
- };
360
- const testUser = this.testGitHubUser();
361
- if (testUser) {
362
- const user = this.upsertGitHubUser(testUser);
363
- const code = this.issueAuthorizationCode(pending, user.id);
364
- const destination = new URL(redirectUri);
365
- destination.searchParams.set("code", code);
366
- if (state) {
367
- destination.searchParams.set("state", state);
368
- }
369
- redirect(res, destination.toString());
370
- return;
371
- }
372
- if (!this.config.githubClientId || !this.config.githubClientSecret) {
373
- throw safeError("FORBIDDEN", "GitHub OAuth is not configured", 503);
374
- }
375
- const transactionId = id("code");
376
- this.authorizeTransactions.set(transactionId, pending);
377
- const githubUrl = new URL("https://github.com/login/oauth/authorize");
378
- githubUrl.searchParams.set("client_id", this.config.githubClientId);
379
- githubUrl.searchParams.set("redirect_uri", this.config.githubCallbackUrl);
380
- githubUrl.searchParams.set("scope", "read:user");
381
- githubUrl.searchParams.set("state", transactionId);
382
- redirect(res, githubUrl.toString());
383
- }
384
- validateAuthorizeParams(clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod, resource, scope) {
385
- const client = this.store.getClient(clientId);
386
- if (!client) {
387
- throw safeError("INVALID_CLIENT", "Unknown client_id");
388
- }
389
- if (!client.redirectUris.includes(redirectUri) || !isSafeRedirectUri(redirectUri)) {
390
- throw safeError("INVALID_REDIRECT_URI", "redirect_uri is not registered");
391
- }
392
- if (responseType !== "code") {
393
- throw safeError("INVALID_CLIENT", "response_type must be code");
394
- }
395
- if (!codeChallenge || codeChallengeMethod !== "S256") {
396
- throw safeError("PKCE_VALIDATION_FAILED", "PKCE S256 is required");
397
- }
398
- if (resource !== this.config.mcpResourceUrl) {
399
- throw safeError("INVALID_TOKEN", "resource must match MCP resource URL");
400
- }
401
- scopeList(scope);
402
- }
403
- async handleGitHubCallback(req, res) {
404
- const url = new URL(req.url ?? "/oauth/callback/github", this.config.publicBaseUrl);
405
- const code = url.searchParams.get("code") ?? "";
406
- const state = url.searchParams.get("state") ?? "";
407
- const pending = this.authorizeTransactions.get(state);
408
- this.authorizeTransactions.delete(state);
409
- if (!code || !pending || pending.expiresAt < Date.now()) {
410
- throw safeError("INVALID_TOKEN", "OAuth transaction is missing or expired");
411
- }
412
- const githubUser = await this.fetchGitHubUser(code);
413
- const user = this.upsertGitHubUser(githubUser);
414
- const authCode = this.issueAuthorizationCode(pending, user.id);
415
- const destination = new URL(pending.redirectUri);
416
- destination.searchParams.set("code", authCode);
417
- if (pending.state) {
418
- destination.searchParams.set("state", pending.state);
419
- }
420
- redirect(res, destination.toString());
421
- }
422
- async fetchGitHubUser(code) {
423
- const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
424
- method: "POST",
425
- headers: { Accept: "application/json", "Content-Type": "application/json" },
426
- body: JSON.stringify({
427
- client_id: this.config.githubClientId,
428
- client_secret: this.config.githubClientSecret,
429
- code,
430
- redirect_uri: this.config.githubCallbackUrl,
431
- }),
432
- });
433
- const tokenPayload = (await tokenResponse.json());
434
- const accessToken = asString(tokenPayload.access_token);
435
- if (!accessToken) {
436
- throw safeError("INVALID_TOKEN", "GitHub OAuth token exchange failed", 502);
437
- }
438
- const userResponse = await fetch("https://api.github.com/user", {
439
- headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" },
440
- });
441
- const userPayload = (await userResponse.json());
442
- return { id: String(userPayload.id ?? ""), login: String(userPayload.login ?? "") };
443
- }
444
- testGitHubUser() {
445
- const idValue = process.env.SSHAUTOMATOR_TEST_GITHUB_ID;
446
- const login = process.env.SSHAUTOMATOR_TEST_GITHUB_LOGIN;
447
- return idValue && login ? { id: idValue, login } : undefined;
448
- }
449
- upsertGitHubUser(githubUser) {
450
- if (!this.isGitHubUserAllowed(githubUser)) {
451
- throw safeError("FORBIDDEN", "GitHub user is not allowed");
452
- }
453
- const existing = this.store.getUserByGitHubId(githubUser.id);
454
- const internalId = existing?.id ?? `github:${githubUser.id}`;
455
- this.store.upsertUser({ ...githubUser, internalId, now: nowIso() });
456
- this.audit({
457
- userId: internalId,
458
- eventType: "user_login",
459
- severity: "info",
460
- metadata: { github_id: githubUser.id, github_login: githubUser.login },
461
- });
462
- return { id: internalId, githubId: githubUser.id, githubLogin: githubUser.login };
463
- }
464
- isGitHubUserAllowed(user) {
465
- return (this.config.allowAllUsers ||
466
- this.config.allowedGitHubIds.includes(user.id) ||
467
- this.config.allowedGitHubLogins.includes(user.login));
468
- }
469
- issueAuthorizationCode(pending, userId) {
470
- const code = randomToken(32);
471
- const now = nowIso();
472
- const record = {
473
- id: id("code"),
474
- codeHash: hashSecret(code),
475
- clientId: pending.clientId,
476
- userId,
477
- redirectUri: pending.redirectUri,
478
- codeChallenge: pending.codeChallenge,
479
- codeChallengeMethod: "S256",
480
- resource: pending.resource,
481
- scope: pending.scope,
482
- expiresAt: new Date(Date.now() + this.config.authCodeTtlSeconds * 1000).toISOString(),
483
- createdAt: now,
484
- };
485
- this.store.insertAuthorizationCode(record);
486
- return code;
487
- }
488
- async handleToken(req, res) {
489
- const body = await readJsonOrForm(req);
490
- if (body.grant_type !== "authorization_code") {
491
- throw safeError("INVALID_CLIENT", "grant_type must be authorization_code");
492
- }
493
- const clientId = body.client_id ?? "";
494
- const client = this.store.getClient(clientId);
495
- if (!client) {
496
- throw safeError("INVALID_CLIENT", "Unknown client_id");
497
- }
498
- const code = body.code ?? "";
499
- const redirectUri = body.redirect_uri ?? "";
500
- const verifier = body.code_verifier ?? "";
501
- const codeRecord = this.store.getAuthorizationCodeByHash(hashSecret(code));
502
- if (codeRecord?.clientId !== clientId || codeRecord?.redirectUri !== redirectUri) {
503
- throw safeError("INVALID_TOKEN", "Invalid authorization code");
504
- }
505
- if (codeRecord.usedAt || new Date(codeRecord.expiresAt).getTime() < Date.now()) {
506
- throw safeError("INVALID_TOKEN", "Authorization code is expired or already used");
507
- }
508
- if (!verifier || pkceChallenge(verifier) !== codeRecord.codeChallenge) {
509
- throw safeError("PKCE_VALIDATION_FAILED", "Invalid PKCE code_verifier");
510
- }
511
- const jwtKeyPair = this.requireJwtKeyPair();
512
- const user = this.userFromId(codeRecord.userId);
513
- const scopes = scopeList(codeRecord.scope);
514
- try {
515
- this.store.markAuthorizationCodeUsed(codeRecord.codeHash, nowIso());
516
- }
517
- catch {
518
- throw safeError("INVALID_TOKEN", "Authorization code is expired or already used");
519
- }
520
- const token = await issueAccessToken(this.config, jwtKeyPair, user, scopes);
521
- jsonResponse(res, 200, {
522
- access_token: token.token,
523
- token_type: "Bearer",
524
- expires_in: this.config.accessTokenTtlSeconds,
525
- scope: scopes.join(" "),
526
- }, addNoStore());
527
- }
528
- userFromId(userId) {
529
- if (userId.startsWith("github:")) {
530
- const githubId = userId.slice("github:".length);
531
- const user = this.store.getUserByGitHubId(githubId);
532
- if (user) {
533
- return user;
534
- }
535
- }
536
- throw safeError("UNAUTHORIZED", "User no longer exists", 401);
537
- }
538
- async handleJwks(res) {
539
- const jwtKeyPair = this.requireJwtKeyPair();
540
- jsonResponse(res, 200, { keys: [await publicJwkFromPem(jwtKeyPair.publicKeyPem)] }, addNoStore());
107
+ return this.agentHandler.handleUpgrade(req, socket, head, pathname);
541
108
  }
542
109
  async handleMcp(req, res) {
543
110
  if (req.method === "OPTIONS") {
@@ -706,207 +273,6 @@ export class RemoteControlPlane {
706
273
  control_plane_public_key: this.controlPlaneKeyPair.publicKeyPem,
707
274
  }, addNoStore());
708
275
  }
709
- async handleAgentMessage(connection, message) {
710
- const payload = JSON.parse(message);
711
- if (!isRecord(payload)) {
712
- connection.sendJson({ type: "error", code: "INTERNAL_ERROR", message: "Invalid message" });
713
- connection.close();
714
- return;
715
- }
716
- if (payload.type === "agent.hello") {
717
- await this.handleAgentHello(connection, parseAgentHelloEnvelope(payload));
718
- return;
719
- }
720
- if (payload.type === "action.result") {
721
- await this.handleActionResult(connection, parseActionResultEnvelope(payload));
722
- return;
723
- }
724
- connection.sendJson({ type: "error", code: "INTERNAL_ERROR", message: "Unknown message type" });
725
- connection.close();
726
- }
727
- async handleAgentHello(connection, hello) {
728
- const agent = this.store.getAgent(hello.agent_id);
729
- if (!agent || agent.status === "revoked" || !agent.publicKey) {
730
- connection.sendJson({
731
- type: "error",
732
- code: "AGENT_NOT_FOUND",
733
- message: "Agent is not enrolled",
734
- });
735
- connection.close();
736
- return;
737
- }
738
- if (!verifyEnvelope(hello, agent.publicKey)) {
739
- this.audit({
740
- userId: agent.userId,
741
- agentId: agent.id,
742
- eventType: "agent_signature_invalid",
743
- severity: "warn",
744
- metadata: { message_type: "agent.hello" },
745
- });
746
- connection.sendJson({
747
- type: "error",
748
- code: "SIGNATURE_INVALID",
749
- message: "Agent signature is invalid",
750
- });
751
- connection.close();
752
- return;
753
- }
754
- const timestampAgeMs = Math.abs(Date.now() - new Date(hello.timestamp).getTime());
755
- if (timestampAgeMs > 300_000) {
756
- this.audit({
757
- userId: agent.userId,
758
- agentId: agent.id,
759
- eventType: "agent_hello_expired",
760
- severity: "warn",
761
- metadata: {},
762
- });
763
- connection.sendJson({
764
- type: "error",
765
- code: "ACTION_EXPIRED",
766
- message: "Agent hello timestamp is stale",
767
- });
768
- connection.close();
769
- return;
770
- }
771
- const now = Date.now();
772
- this.cleanupEphemeralState(now);
773
- const existingConnection = this.agentConnections.get(agent.id);
774
- if (existingConnection?.connection === connection) {
775
- this.audit({
776
- userId: agent.userId,
777
- agentId: agent.id,
778
- eventType: "agent_duplicate_hello_rejected",
779
- severity: "warn",
780
- metadata: {},
781
- });
782
- connection.sendJson({
783
- type: "error",
784
- code: "ACTION_REPLAY_DETECTED",
785
- message: "Agent hello was already processed on this connection",
786
- });
787
- connection.close();
788
- return;
789
- }
790
- const helloNonces = this.agentHelloNonces.get(agent.id) ?? new Map();
791
- if (helloNonces.has(hello.nonce)) {
792
- this.audit({
793
- userId: agent.userId,
794
- agentId: agent.id,
795
- eventType: "agent_hello_replay_detected",
796
- severity: "warn",
797
- metadata: {},
798
- });
799
- connection.sendJson({
800
- type: "error",
801
- code: "ACTION_REPLAY_DETECTED",
802
- message: "Agent hello nonce was already used",
803
- });
804
- connection.close();
805
- return;
806
- }
807
- helloNonces.set(hello.nonce, now + AGENT_NONCE_TTL_MS);
808
- this.agentHelloNonces.set(agent.id, helloNonces);
809
- if (existingConnection) {
810
- existingConnection.connection.close();
811
- }
812
- const seenNonces = new Map();
813
- rememberNonce(seenNonces, hello.nonce, now);
814
- this.agentConnections.set(agent.id, { agent, connection, seenNonces });
815
- const online = {
816
- ...agent,
817
- status: "online",
818
- lastSeenAt: nowIso(),
819
- hostMetadata: hello.host,
820
- updatedAt: nowIso(),
821
- };
822
- this.store.updateAgent(online);
823
- connection.onClose(() => {
824
- const live = this.agentConnections.get(agent.id);
825
- if (live?.connection !== connection) {
826
- return;
827
- }
828
- this.agentConnections.delete(agent.id);
829
- const latest = this.store.getAgent(agent.id);
830
- if (latest && latest.status !== "revoked") {
831
- this.store.updateAgent({ ...latest, status: "offline", updatedAt: nowIso() });
832
- }
833
- this.audit({
834
- userId: agent.userId,
835
- agentId: agent.id,
836
- eventType: "agent_disconnected",
837
- severity: "info",
838
- metadata: {},
839
- });
840
- });
841
- this.audit({
842
- userId: agent.userId,
843
- agentId: agent.id,
844
- eventType: "agent_connected",
845
- severity: "info",
846
- metadata: { agent_version: hello.agent_version, host: hello.host.hostname },
847
- });
848
- connection.sendJson({ type: "agent.ready", agent_id: agent.id, policy: agent.policy });
849
- }
850
- async handleActionResult(connection, result) {
851
- const pending = this.pendingActions.get(result.action_id);
852
- if (!pending) {
853
- return;
854
- }
855
- const agent = this.store.getAgent(result.agent_id);
856
- if (!agent?.publicKey ||
857
- !verifyEnvelope(result, agent.publicKey)) {
858
- clearTimeout(pending.timeout);
859
- this.pendingActions.delete(result.action_id);
860
- this.audit({
861
- userId: pending.action.userId,
862
- agentId: pending.action.agentId,
863
- actionId: pending.action.id,
864
- eventType: "agent_result_signature_invalid",
865
- severity: "warn",
866
- metadata: {},
867
- });
868
- pending.reject(new Error("Agent result signature is invalid"));
869
- return;
870
- }
871
- const live = this.agentConnections.get(result.agent_id);
872
- if (pending.action.agentId !== result.agent_id || live?.connection !== connection) {
873
- clearTimeout(pending.timeout);
874
- this.pendingActions.delete(result.action_id);
875
- this.audit({
876
- userId: pending.action.userId,
877
- agentId: pending.action.agentId,
878
- actionId: pending.action.id,
879
- eventType: "agent_result_connection_invalid",
880
- severity: "warn",
881
- metadata: {},
882
- });
883
- pending.reject(Object.assign(new Error("Agent result came from an unexpected connection"), {
884
- code: "SIGNATURE_INVALID",
885
- }));
886
- return;
887
- }
888
- const now = Date.now();
889
- if (hasSeenNonce(live.seenNonces, result.nonce, now)) {
890
- clearTimeout(pending.timeout);
891
- this.pendingActions.delete(result.action_id);
892
- this.audit({
893
- userId: pending.action.userId,
894
- agentId: pending.action.agentId,
895
- actionId: pending.action.id,
896
- eventType: "agent_result_replay_detected",
897
- severity: "warn",
898
- metadata: {},
899
- });
900
- pending.reject(Object.assign(new Error("Agent result nonce was already used"), {
901
- code: "ACTION_REPLAY_DETECTED",
902
- }));
903
- return;
904
- }
905
- rememberNonce(live.seenNonces, result.nonce, now);
906
- clearTimeout(pending.timeout);
907
- this.pendingActions.delete(result.action_id);
908
- pending.resolve(result);
909
- }
910
276
  async authenticate(req) {
911
277
  try {
912
278
  return await verifyRemoteAccessToken(req.headers.authorization, this.config, this.requireJwtKeyPair());