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