ssh-mcp-pro 1.0.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.
Files changed (267) hide show
  1. package/AGENTS.md +127 -0
  2. package/ARCHITECTURE.md +145 -0
  3. package/LICENSE +21 -0
  4. package/LICENSES/MIT.txt +21 -0
  5. package/MIGRATION.md +14 -0
  6. package/README.md +175 -0
  7. package/REGISTRY_SUBMISSION.md +38 -0
  8. package/SECURITY.md +40 -0
  9. package/SECURITY_DECISIONS.md +59 -0
  10. package/dist/agent-bin.d.ts +3 -0
  11. package/dist/agent-bin.d.ts.map +1 -0
  12. package/dist/agent-bin.js +8 -0
  13. package/dist/agent-bin.js.map +1 -0
  14. package/dist/audit.d.ts +25 -0
  15. package/dist/audit.d.ts.map +1 -0
  16. package/dist/audit.js +50 -0
  17. package/dist/audit.js.map +1 -0
  18. package/dist/auth.d.ts +4 -0
  19. package/dist/auth.d.ts.map +1 -0
  20. package/dist/auth.js +33 -0
  21. package/dist/auth.js.map +1 -0
  22. package/dist/cli.d.ts +16 -0
  23. package/dist/cli.d.ts.map +1 -0
  24. package/dist/cli.js +99 -0
  25. package/dist/cli.js.map +1 -0
  26. package/dist/config.d.ts +103 -0
  27. package/dist/config.d.ts.map +1 -0
  28. package/dist/config.js +490 -0
  29. package/dist/config.js.map +1 -0
  30. package/dist/connector-credentials.d.ts +8 -0
  31. package/dist/connector-credentials.d.ts.map +1 -0
  32. package/dist/connector-credentials.js +132 -0
  33. package/dist/connector-credentials.js.map +1 -0
  34. package/dist/connector-profile.d.ts +17 -0
  35. package/dist/connector-profile.d.ts.map +1 -0
  36. package/dist/connector-profile.js +81 -0
  37. package/dist/connector-profile.js.map +1 -0
  38. package/dist/container.d.ts +18 -0
  39. package/dist/container.d.ts.map +1 -0
  40. package/dist/container.js +52 -0
  41. package/dist/container.js.map +1 -0
  42. package/dist/detect.d.ts +7 -0
  43. package/dist/detect.d.ts.map +1 -0
  44. package/dist/detect.js +271 -0
  45. package/dist/detect.js.map +1 -0
  46. package/dist/ensure.d.ts +17 -0
  47. package/dist/ensure.d.ts.map +1 -0
  48. package/dist/ensure.js +531 -0
  49. package/dist/ensure.js.map +1 -0
  50. package/dist/errors.d.ts +54 -0
  51. package/dist/errors.d.ts.map +1 -0
  52. package/dist/errors.js +84 -0
  53. package/dist/errors.js.map +1 -0
  54. package/dist/fs-tools.d.ts +26 -0
  55. package/dist/fs-tools.d.ts.map +1 -0
  56. package/dist/fs-tools.js +599 -0
  57. package/dist/fs-tools.js.map +1 -0
  58. package/dist/http-rate-limit.d.ts +9 -0
  59. package/dist/http-rate-limit.d.ts.map +1 -0
  60. package/dist/http-rate-limit.js +41 -0
  61. package/dist/http-rate-limit.js.map +1 -0
  62. package/dist/http-security.d.ts +22 -0
  63. package/dist/http-security.d.ts.map +1 -0
  64. package/dist/http-security.js +88 -0
  65. package/dist/http-security.js.map +1 -0
  66. package/dist/index.d.ts +10 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +201 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/logging.d.ts +52 -0
  71. package/dist/logging.d.ts.map +1 -0
  72. package/dist/logging.js +180 -0
  73. package/dist/logging.js.map +1 -0
  74. package/dist/mcp.d.ts +16 -0
  75. package/dist/mcp.d.ts.map +1 -0
  76. package/dist/mcp.js +159 -0
  77. package/dist/mcp.js.map +1 -0
  78. package/dist/metrics.d.ts +95 -0
  79. package/dist/metrics.d.ts.map +1 -0
  80. package/dist/metrics.js +204 -0
  81. package/dist/metrics.js.map +1 -0
  82. package/dist/oauth.d.ts +14 -0
  83. package/dist/oauth.d.ts.map +1 -0
  84. package/dist/oauth.js +105 -0
  85. package/dist/oauth.js.map +1 -0
  86. package/dist/policy.d.ts +64 -0
  87. package/dist/policy.d.ts.map +1 -0
  88. package/dist/policy.js +368 -0
  89. package/dist/policy.js.map +1 -0
  90. package/dist/process.d.ts +24 -0
  91. package/dist/process.d.ts.map +1 -0
  92. package/dist/process.js +212 -0
  93. package/dist/process.js.map +1 -0
  94. package/dist/prompts.d.ts +49 -0
  95. package/dist/prompts.d.ts.map +1 -0
  96. package/dist/prompts.js +191 -0
  97. package/dist/prompts.js.map +1 -0
  98. package/dist/rate-limiter.d.ts +57 -0
  99. package/dist/rate-limiter.d.ts.map +1 -0
  100. package/dist/rate-limiter.js +141 -0
  101. package/dist/rate-limiter.js.map +1 -0
  102. package/dist/remote/agent-cli.d.ts +2 -0
  103. package/dist/remote/agent-cli.d.ts.map +1 -0
  104. package/dist/remote/agent-cli.js +270 -0
  105. package/dist/remote/agent-cli.js.map +1 -0
  106. package/dist/remote/agent-executor.d.ts +26 -0
  107. package/dist/remote/agent-executor.d.ts.map +1 -0
  108. package/dist/remote/agent-executor.js +400 -0
  109. package/dist/remote/agent-executor.js.map +1 -0
  110. package/dist/remote/config.d.ts +3 -0
  111. package/dist/remote/config.d.ts.map +1 -0
  112. package/dist/remote/config.js +52 -0
  113. package/dist/remote/config.js.map +1 -0
  114. package/dist/remote/control-plane.d.ts +57 -0
  115. package/dist/remote/control-plane.d.ts.map +1 -0
  116. package/dist/remote/control-plane.js +1248 -0
  117. package/dist/remote/control-plane.js.map +1 -0
  118. package/dist/remote/crypto.d.ts +38 -0
  119. package/dist/remote/crypto.d.ts.map +1 -0
  120. package/dist/remote/crypto.js +143 -0
  121. package/dist/remote/crypto.js.map +1 -0
  122. package/dist/remote/mcp-tools.d.ts +10 -0
  123. package/dist/remote/mcp-tools.d.ts.map +1 -0
  124. package/dist/remote/mcp-tools.js +201 -0
  125. package/dist/remote/mcp-tools.js.map +1 -0
  126. package/dist/remote/policy.d.ts +11 -0
  127. package/dist/remote/policy.d.ts.map +1 -0
  128. package/dist/remote/policy.js +94 -0
  129. package/dist/remote/policy.js.map +1 -0
  130. package/dist/remote/schemas.d.ts +298 -0
  131. package/dist/remote/schemas.d.ts.map +1 -0
  132. package/dist/remote/schemas.js +111 -0
  133. package/dist/remote/schemas.js.map +1 -0
  134. package/dist/remote/scopes.d.ts +6 -0
  135. package/dist/remote/scopes.d.ts.map +1 -0
  136. package/dist/remote/scopes.js +24 -0
  137. package/dist/remote/scopes.js.map +1 -0
  138. package/dist/remote/store.d.ts +45 -0
  139. package/dist/remote/store.d.ts.map +1 -0
  140. package/dist/remote/store.js +355 -0
  141. package/dist/remote/store.js.map +1 -0
  142. package/dist/remote/types.d.ts +183 -0
  143. package/dist/remote/types.d.ts.map +1 -0
  144. package/dist/remote/types.js +103 -0
  145. package/dist/remote/types.js.map +1 -0
  146. package/dist/remote/util.d.ts +6 -0
  147. package/dist/remote/util.d.ts.map +1 -0
  148. package/dist/remote/util.js +45 -0
  149. package/dist/remote/util.js.map +1 -0
  150. package/dist/remote/websocket.d.ts +26 -0
  151. package/dist/remote/websocket.d.ts.map +1 -0
  152. package/dist/remote/websocket.js +167 -0
  153. package/dist/remote/websocket.js.map +1 -0
  154. package/dist/render-http.d.ts +2 -0
  155. package/dist/render-http.d.ts.map +1 -0
  156. package/dist/render-http.js +14 -0
  157. package/dist/render-http.js.map +1 -0
  158. package/dist/resources.d.ts +19 -0
  159. package/dist/resources.d.ts.map +1 -0
  160. package/dist/resources.js +96 -0
  161. package/dist/resources.js.map +1 -0
  162. package/dist/retry.d.ts +45 -0
  163. package/dist/retry.d.ts.map +1 -0
  164. package/dist/retry.js +120 -0
  165. package/dist/retry.js.map +1 -0
  166. package/dist/safety.d.ts +31 -0
  167. package/dist/safety.d.ts.map +1 -0
  168. package/dist/safety.js +174 -0
  169. package/dist/safety.js.map +1 -0
  170. package/dist/server-http.d.ts +2 -0
  171. package/dist/server-http.d.ts.map +1 -0
  172. package/dist/server-http.js +432 -0
  173. package/dist/server-http.js.map +1 -0
  174. package/dist/session.d.ts +116 -0
  175. package/dist/session.d.ts.map +1 -0
  176. package/dist/session.js +666 -0
  177. package/dist/session.js.map +1 -0
  178. package/dist/shell.d.ts +10 -0
  179. package/dist/shell.d.ts.map +1 -0
  180. package/dist/shell.js +83 -0
  181. package/dist/shell.js.map +1 -0
  182. package/dist/ssh-config.d.ts +94 -0
  183. package/dist/ssh-config.d.ts.map +1 -0
  184. package/dist/ssh-config.js +234 -0
  185. package/dist/ssh-config.js.map +1 -0
  186. package/dist/streaming.d.ts +36 -0
  187. package/dist/streaming.d.ts.map +1 -0
  188. package/dist/streaming.js +140 -0
  189. package/dist/streaming.js.map +1 -0
  190. package/dist/telemetry.d.ts +17 -0
  191. package/dist/telemetry.d.ts.map +1 -0
  192. package/dist/telemetry.js +101 -0
  193. package/dist/telemetry.js.map +1 -0
  194. package/dist/tools/connector.provider.d.ts +28 -0
  195. package/dist/tools/connector.provider.d.ts.map +1 -0
  196. package/dist/tools/connector.provider.js +360 -0
  197. package/dist/tools/connector.provider.js.map +1 -0
  198. package/dist/tools/ensure.provider.d.ts +18 -0
  199. package/dist/tools/ensure.provider.d.ts.map +1 -0
  200. package/dist/tools/ensure.provider.js +173 -0
  201. package/dist/tools/ensure.provider.js.map +1 -0
  202. package/dist/tools/fs.provider.d.ts +21 -0
  203. package/dist/tools/fs.provider.d.ts.map +1 -0
  204. package/dist/tools/fs.provider.js +259 -0
  205. package/dist/tools/fs.provider.js.map +1 -0
  206. package/dist/tools/index.d.ts +4 -0
  207. package/dist/tools/index.d.ts.map +1 -0
  208. package/dist/tools/index.js +68 -0
  209. package/dist/tools/index.js.map +1 -0
  210. package/dist/tools/metadata.d.ts +11 -0
  211. package/dist/tools/metadata.d.ts.map +1 -0
  212. package/dist/tools/metadata.js +10 -0
  213. package/dist/tools/metadata.js.map +1 -0
  214. package/dist/tools/output-schemas.d.ts +217 -0
  215. package/dist/tools/output-schemas.d.ts.map +1 -0
  216. package/dist/tools/output-schemas.js +300 -0
  217. package/dist/tools/output-schemas.js.map +1 -0
  218. package/dist/tools/process.provider.d.ts +22 -0
  219. package/dist/tools/process.provider.d.ts.map +1 -0
  220. package/dist/tools/process.provider.js +146 -0
  221. package/dist/tools/process.provider.js.map +1 -0
  222. package/dist/tools/registry.d.ts +12 -0
  223. package/dist/tools/registry.d.ts.map +1 -0
  224. package/dist/tools/registry.js +163 -0
  225. package/dist/tools/registry.js.map +1 -0
  226. package/dist/tools/results.d.ts +4 -0
  227. package/dist/tools/results.d.ts.map +1 -0
  228. package/dist/tools/results.js +5 -0
  229. package/dist/tools/results.js.map +1 -0
  230. package/dist/tools/session.provider.d.ts +23 -0
  231. package/dist/tools/session.provider.d.ts.map +1 -0
  232. package/dist/tools/session.provider.js +299 -0
  233. package/dist/tools/session.provider.js.map +1 -0
  234. package/dist/tools/system.provider.d.ts +18 -0
  235. package/dist/tools/system.provider.d.ts.map +1 -0
  236. package/dist/tools/system.provider.js +81 -0
  237. package/dist/tools/system.provider.js.map +1 -0
  238. package/dist/tools/transfer.provider.d.ts +16 -0
  239. package/dist/tools/transfer.provider.d.ts.map +1 -0
  240. package/dist/tools/transfer.provider.js +85 -0
  241. package/dist/tools/transfer.provider.js.map +1 -0
  242. package/dist/tools/tunnel.provider.d.ts +18 -0
  243. package/dist/tools/tunnel.provider.d.ts.map +1 -0
  244. package/dist/tools/tunnel.provider.js +142 -0
  245. package/dist/tools/tunnel.provider.js.map +1 -0
  246. package/dist/tools/types.d.ts +16 -0
  247. package/dist/tools/types.d.ts.map +1 -0
  248. package/dist/tools/types.js +2 -0
  249. package/dist/tools/types.js.map +1 -0
  250. package/dist/transfer.d.ts +40 -0
  251. package/dist/transfer.d.ts.map +1 -0
  252. package/dist/transfer.js +363 -0
  253. package/dist/transfer.js.map +1 -0
  254. package/dist/tunnel.d.ts +37 -0
  255. package/dist/tunnel.d.ts.map +1 -0
  256. package/dist/tunnel.js +234 -0
  257. package/dist/tunnel.js.map +1 -0
  258. package/dist/types.d.ts +341 -0
  259. package/dist/types.d.ts.map +1 -0
  260. package/dist/types.js +184 -0
  261. package/dist/types.js.map +1 -0
  262. package/docs/docker.md +22 -0
  263. package/examples/README.md +77 -0
  264. package/mcp.json +21 -0
  265. package/package.json +147 -0
  266. package/registry/ssh-mcp-pro/mcp.json +21 -0
  267. package/server.json +76 -0
@@ -0,0 +1,1248 @@
1
+ import { createPublicKey } from "node:crypto";
2
+ import { URL } from "node:url";
3
+ import { ensurePemKeyPair, hashSecret, id, issueAccessToken, keyId, loadJwtKeyPair, nowIso, publicJwkFromPem, randomToken, sha256Base64Url, signEnvelope, verifyEnvelope, verifyRemoteAccessToken, } from "./crypto.js";
4
+ import { loadRemoteConfig } from "./config.js";
5
+ import { listRemoteToolDescriptors } from "./mcp-tools.js";
6
+ import { createAgentPolicy, mergeCustomPolicy } from "./policy.js";
7
+ import { parseActionResultEnvelope, parseAgentHelloEnvelope, parseAgentHostMetadata, } from "./schemas.js";
8
+ import { hasCapability, parseScopes } from "./scopes.js";
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";
13
+ 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
+ export class RemoteControlPlane {
160
+ config;
161
+ store;
162
+ authorizeTransactions = new Map();
163
+ agentConnections = new Map();
164
+ agentHelloNonces = new Map();
165
+ pendingActions = new Map();
166
+ cleanupInterval;
167
+ jwtKeyPair;
168
+ controlPlaneKeyPair;
169
+ constructor(config = loadRemoteConfig()) {
170
+ this.config = config;
171
+ this.store = new RemoteStore(config.databaseUrl);
172
+ this.controlPlaneKeyPair = ensurePemKeyPair(config.controlPlaneSigningKeyPath);
173
+ this.cleanupInterval = setInterval(() => this.cleanupEphemeralState(), 60_000);
174
+ this.cleanupInterval.unref?.();
175
+ }
176
+ async initialize() {
177
+ this.jwtKeyPair = await loadJwtKeyPair(this.config.jwtSigningKeyPath);
178
+ }
179
+ close() {
180
+ clearInterval(this.cleanupInterval);
181
+ for (const pending of this.pendingActions.values()) {
182
+ clearTimeout(pending.timeout);
183
+ pending.reject(new Error("Control plane shutting down"));
184
+ }
185
+ this.pendingActions.clear();
186
+ for (const entry of this.agentConnections.values()) {
187
+ entry.connection.close();
188
+ }
189
+ this.agentConnections.clear();
190
+ this.agentHelloNonces.clear();
191
+ this.authorizeTransactions.clear();
192
+ this.store.close();
193
+ }
194
+ 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
+ }
209
+ }
210
+ async handleHttp(req, res, pathname) {
211
+ if (pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
212
+ jsonResponse(res, 200, this.protectedResourceMetadata(), addNoStore());
213
+ return true;
214
+ }
215
+ if (pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
216
+ jsonResponse(res, 200, this.authorizationServerMetadata(), addNoStore());
217
+ return true;
218
+ }
219
+ if (pathname === "/oauth/register" && req.method === "POST") {
220
+ await this.handleRegister(req, res);
221
+ return true;
222
+ }
223
+ if (pathname === "/oauth/authorize" && req.method === "GET") {
224
+ await this.handleAuthorize(req, res);
225
+ return true;
226
+ }
227
+ if (pathname === "/oauth/callback/github" && req.method === "GET") {
228
+ await this.handleGitHubCallback(req, res);
229
+ return true;
230
+ }
231
+ if (pathname === "/oauth/token" && req.method === "POST") {
232
+ await this.handleToken(req, res);
233
+ return true;
234
+ }
235
+ if (pathname === "/oauth/jwks.json" && req.method === "GET") {
236
+ await this.handleJwks(res);
237
+ return true;
238
+ }
239
+ if (pathname === "/readyz" && req.method === "GET") {
240
+ jsonResponse(res, 200, {
241
+ ok: true,
242
+ service: "ssh-mcp-pro",
243
+ control_plane: true,
244
+ agents_online: this.agentConnections.size,
245
+ });
246
+ return true;
247
+ }
248
+ if (pathname === "/mcp") {
249
+ await this.handleMcp(req, res);
250
+ return true;
251
+ }
252
+ if (pathname.startsWith("/api/agents") || pathname === "/api/audit") {
253
+ await this.handleApi(req, res, pathname);
254
+ return true;
255
+ }
256
+ return false;
257
+ }
258
+ 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());
534
+ }
535
+ async handleMcp(req, res) {
536
+ if (req.method === "OPTIONS") {
537
+ res.writeHead(204);
538
+ res.end();
539
+ return;
540
+ }
541
+ if (req.method !== "POST") {
542
+ this.sendUnauthorized(res);
543
+ return;
544
+ }
545
+ let principal;
546
+ try {
547
+ principal = await this.authenticate(req);
548
+ }
549
+ catch {
550
+ this.sendUnauthorized(res);
551
+ return;
552
+ }
553
+ const body = await readJson(req);
554
+ const method = asString(body.method) ?? "";
555
+ const rpcId = body.id ?? null;
556
+ if (method === "initialize") {
557
+ jsonResponse(res, 200, {
558
+ jsonrpc: "2.0",
559
+ id: rpcId,
560
+ result: {
561
+ protocolVersion: "2025-06-18",
562
+ capabilities: { tools: {} },
563
+ serverInfo: { name: "sshautomator-remote-agent", version: SERVER_VERSION },
564
+ },
565
+ });
566
+ return;
567
+ }
568
+ if (method === "tools/list") {
569
+ jsonResponse(res, 200, {
570
+ jsonrpc: "2.0",
571
+ id: rpcId,
572
+ result: { tools: listRemoteToolDescriptors(principal.capabilities) },
573
+ });
574
+ return;
575
+ }
576
+ if (method === "tools/call") {
577
+ const params = isRecord(body.params) ? body.params : {};
578
+ const name = asString(params.name);
579
+ const args = isRecord(params.arguments) ? params.arguments : {};
580
+ if (!name || !(name in TOOL_CAPABILITY_MAP)) {
581
+ jsonResponse(res, 200, {
582
+ jsonrpc: "2.0",
583
+ id: rpcId,
584
+ error: { code: -32602, message: "Unknown tool" },
585
+ });
586
+ return;
587
+ }
588
+ const result = await this.callRemoteTool(principal, name, args);
589
+ jsonResponse(res, 200, {
590
+ jsonrpc: "2.0",
591
+ id: rpcId,
592
+ result: {
593
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
594
+ structuredContent: result,
595
+ },
596
+ });
597
+ return;
598
+ }
599
+ jsonResponse(res, 200, {
600
+ jsonrpc: "2.0",
601
+ id: rpcId,
602
+ error: { code: -32601, message: "Method not found" },
603
+ });
604
+ }
605
+ async handleApi(req, res, pathname) {
606
+ if (pathname === "/api/agents/enroll" && req.method === "POST") {
607
+ await this.handleAgentEnroll(req, res);
608
+ return;
609
+ }
610
+ const principal = await this.authenticate(req);
611
+ if (pathname === "/api/agents/enrollment-tokens" && req.method === "POST") {
612
+ const body = await readJson(req);
613
+ const result = this.createEnrollmentToken(principal, body);
614
+ jsonResponse(res, 201, result, addNoStore());
615
+ return;
616
+ }
617
+ if (pathname === "/api/agents" && req.method === "GET") {
618
+ jsonResponse(res, 200, {
619
+ agents: this.store.listAgents(principal.userId).map(sanitizeAgent),
620
+ });
621
+ return;
622
+ }
623
+ const agentMatch = /^\/api\/agents\/([^/]+)(?:\/(policy|revoke))?$/u.exec(pathname);
624
+ if (agentMatch) {
625
+ const agent = this.resolveAgent(principal.userId, agentMatch[1] ?? "");
626
+ if (!agent) {
627
+ throw safeError("AGENT_NOT_FOUND", "Agent not found", 404);
628
+ }
629
+ if (!agentMatch[2] && req.method === "GET") {
630
+ jsonResponse(res, 200, { agent: sanitizeAgent(agent) });
631
+ return;
632
+ }
633
+ if ((!agentMatch[2] || agentMatch[2] === "policy") && req.method === "PATCH") {
634
+ const body = await readJson(req);
635
+ jsonResponse(res, 200, {
636
+ agent: sanitizeAgent(this.updateAgentPolicy(principal, agent, body.policy)),
637
+ });
638
+ return;
639
+ }
640
+ if (agentMatch[2] === "revoke" && req.method === "POST") {
641
+ jsonResponse(res, 200, { agent: sanitizeAgent(this.revokeAgent(principal, agent)) });
642
+ return;
643
+ }
644
+ }
645
+ if (pathname === "/api/audit" && req.method === "GET") {
646
+ const url = new URL(req.url ?? "/api/audit", this.config.publicBaseUrl);
647
+ const limit = Number(url.searchParams.get("limit") ?? 50);
648
+ jsonResponse(res, 200, { events: this.store.listAudit(principal.userId, undefined, limit) });
649
+ return;
650
+ }
651
+ jsonResponse(res, 404, { error: "Not found" });
652
+ }
653
+ async handleAgentEnroll(req, res) {
654
+ const body = await readJson(req);
655
+ const token = asString(body.token) ?? "";
656
+ const publicKey = asString(body.public_key) ?? "";
657
+ const host = isRecord(body.host) ? parseAgentHostMetadata(body.host) : undefined;
658
+ if (!token || !publicKey || !host) {
659
+ throw safeError("FORBIDDEN", "token, public_key, and host are required");
660
+ }
661
+ if (!isValidEd25519PublicKey(publicKey)) {
662
+ throw safeError("FORBIDDEN", "public_key must be an Ed25519 SPKI PEM public key");
663
+ }
664
+ const enrollment = this.store.getEnrollmentTokenByHash(hashSecret(token));
665
+ if (!enrollment || enrollment.usedAt || new Date(enrollment.expiresAt).getTime() < Date.now()) {
666
+ throw safeError("INVALID_TOKEN", "Enrollment token is expired or invalid", 401);
667
+ }
668
+ const agent = this.store.getAgent(enrollment.agentId);
669
+ if (!agent || agent.status === "revoked") {
670
+ throw safeError("AGENT_NOT_FOUND", "Pending agent not found", 404);
671
+ }
672
+ const now = nowIso();
673
+ try {
674
+ this.store.markEnrollmentTokenUsed(enrollment.tokenHash, now);
675
+ }
676
+ catch {
677
+ throw safeError("INVALID_TOKEN", "Enrollment token is expired or invalid", 401);
678
+ }
679
+ const updated = {
680
+ ...agent,
681
+ status: "offline",
682
+ publicKey,
683
+ hostMetadata: host,
684
+ updatedAt: now,
685
+ };
686
+ this.store.updateAgent(updated);
687
+ this.audit({
688
+ userId: agent.userId,
689
+ agentId: agent.id,
690
+ eventType: "agent_enrolled",
691
+ severity: "info",
692
+ metadata: { alias: agent.alias, host: host.hostname },
693
+ });
694
+ jsonResponse(res, 200, {
695
+ agent_id: agent.id,
696
+ alias: agent.alias,
697
+ policy: agent.policy,
698
+ websocket_url: `${this.config.publicBaseUrl.replace(/^http/u, "ws")}${this.config.agentWsPath}`,
699
+ control_plane_public_key: this.controlPlaneKeyPair.publicKeyPem,
700
+ }, addNoStore());
701
+ }
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
+ async authenticate(req) {
904
+ try {
905
+ return await verifyRemoteAccessToken(req.headers.authorization, this.config, this.requireJwtKeyPair());
906
+ }
907
+ catch {
908
+ this.audit({
909
+ eventType: "token_validation_failure",
910
+ severity: "warn",
911
+ metadata: {},
912
+ });
913
+ throw safeError("UNAUTHORIZED", "Missing or invalid bearer token", 401);
914
+ }
915
+ }
916
+ sendUnauthorized(res) {
917
+ jsonResponse(res, 401, { error: "Missing or invalid bearer token" }, {
918
+ "WWW-Authenticate": `Bearer resource_metadata="${this.config.publicBaseUrl}/.well-known/oauth-protected-resource"`,
919
+ });
920
+ }
921
+ async callRemoteTool(principal, tool, args) {
922
+ const capability = TOOL_CAPABILITY_MAP[tool];
923
+ if (!hasCapability(principal.capabilities, capability)) {
924
+ throw safeError("INVALID_SCOPE", `Scope does not grant ${capability}`, 403);
925
+ }
926
+ if (tool === "list_hosts") {
927
+ return {
928
+ hosts: this.store.listAgents(principal.userId).map((agent) => ({
929
+ id: agent.id,
930
+ alias: agent.alias,
931
+ status: agent.status,
932
+ host: agent.hostMetadata,
933
+ })),
934
+ };
935
+ }
936
+ if (tool === "list_agents") {
937
+ return { agents: this.store.listAgents(principal.userId).map(sanitizeAgent) };
938
+ }
939
+ if (tool === "create_enrollment_token") {
940
+ return this.createEnrollmentToken(principal, args);
941
+ }
942
+ if (tool === "get_agent_install_command") {
943
+ const agent = this.requireAgent(principal.userId, args);
944
+ return this.installCommand(agent, undefined);
945
+ }
946
+ if (tool === "update_agent_policy") {
947
+ const agent = this.requireAgent(principal.userId, args);
948
+ return { agent: sanitizeAgent(this.updateAgentPolicy(principal, agent, args.policy)) };
949
+ }
950
+ if (tool === "revoke_agent") {
951
+ const agent = this.requireAgent(principal.userId, args);
952
+ return { agent: sanitizeAgent(this.revokeAgent(principal, agent)) };
953
+ }
954
+ if (tool === "get_audit_events") {
955
+ const agentId = asString(args.agent_id_or_alias)
956
+ ? this.resolveAgent(principal.userId, String(args.agent_id_or_alias))?.id
957
+ : undefined;
958
+ return { events: this.store.listAudit(principal.userId, agentId, Number(args.limit ?? 50)) };
959
+ }
960
+ const agent = this.requireAgent(principal.userId, args);
961
+ if (!agent.policy.capabilities[capability]) {
962
+ this.audit({
963
+ userId: principal.userId,
964
+ agentId: agent.id,
965
+ eventType: "action_denied",
966
+ severity: "warn",
967
+ metadata: { tool, capability, reason: "capability disabled by agent policy" },
968
+ });
969
+ throw safeError("CAPABILITY_DENIED", `Agent policy does not allow ${capability}`, 403);
970
+ }
971
+ const actionResult = await this.dispatchAction(principal, agent, tool, capability, args);
972
+ return { action: actionResult };
973
+ }
974
+ createEnrollmentToken(principal, args) {
975
+ if (!hasCapability(principal.capabilities, "agents.admin")) {
976
+ throw safeError("INVALID_SCOPE", "agents:admin scope is required", 403);
977
+ }
978
+ const alias = asString(args.alias)?.trim();
979
+ if (!alias) {
980
+ throw safeError("FORBIDDEN", "alias is required");
981
+ }
982
+ const profile = asString(args.requested_profile) ?? "read-only";
983
+ const policy = createAgentPolicy(profile === "operations" || profile === "full-admin" ? profile : "read-only");
984
+ const now = nowIso();
985
+ const existing = this.store.getAgentByAlias(principal.userId, alias);
986
+ if (existing && existing.status !== "revoked") {
987
+ throw safeError("FORBIDDEN", "Agent alias already exists");
988
+ }
989
+ const agent = {
990
+ id: id("agt"),
991
+ userId: principal.userId,
992
+ alias,
993
+ status: "pending",
994
+ profile: policy.profile,
995
+ policy,
996
+ policyVersion: policy.version,
997
+ createdAt: now,
998
+ updatedAt: now,
999
+ };
1000
+ const token = randomToken(32);
1001
+ this.store.insertAgent(agent);
1002
+ this.store.insertEnrollmentToken({
1003
+ id: id("enr"),
1004
+ agentId: agent.id,
1005
+ userId: principal.userId,
1006
+ tokenHash: hashSecret(token),
1007
+ expiresAt: new Date(Date.now() + this.config.enrollmentTokenTtlSeconds * 1000).toISOString(),
1008
+ createdAt: now,
1009
+ });
1010
+ this.audit({
1011
+ userId: principal.userId,
1012
+ agentId: agent.id,
1013
+ eventType: "enrollment_token_created",
1014
+ severity: "info",
1015
+ metadata: { alias, profile: policy.profile },
1016
+ });
1017
+ return { ...this.installCommand(agent, token), enrollment_token: token };
1018
+ }
1019
+ installCommand(agent, token) {
1020
+ const tokenArgument = token ?? "<create-a-new-enrollment-token>";
1021
+ const posixBase = [
1022
+ "npx --yes --package ssh-mcp-pro@latest ssh-mcp-pro-agent enroll",
1023
+ `--server ${quotePosixArg(this.config.publicBaseUrl)}`,
1024
+ `--token ${quotePosixArg(tokenArgument)}`,
1025
+ `--alias ${quotePosixArg(agent.alias)}`,
1026
+ ].join(" ");
1027
+ const powershellBase = [
1028
+ "npx --yes --package ssh-mcp-pro@latest ssh-mcp-pro-agent enroll",
1029
+ `--server ${quotePowerShellArg(this.config.publicBaseUrl)}`,
1030
+ `--token ${quotePowerShellArg(tokenArgument)}`,
1031
+ `--alias ${quotePowerShellArg(agent.alias)}`,
1032
+ ].join(" ");
1033
+ return {
1034
+ agent_id: agent.id,
1035
+ alias: agent.alias,
1036
+ token_recoverable: Boolean(token),
1037
+ commands: {
1038
+ npm: posixBase,
1039
+ run: "npx --yes --package ssh-mcp-pro@latest ssh-mcp-pro-agent run",
1040
+ windows: powershellBase,
1041
+ },
1042
+ expires_in_seconds: token ? this.config.enrollmentTokenTtlSeconds : undefined,
1043
+ };
1044
+ }
1045
+ updateAgentPolicy(principal, agent, value) {
1046
+ if (!hasCapability(principal.capabilities, "agents.admin")) {
1047
+ throw safeError("INVALID_SCOPE", "agents:admin scope is required", 403);
1048
+ }
1049
+ const nextVersion = agent.policyVersion + 1;
1050
+ const merged = mergeCustomPolicy(isRecord(value) ? value : {});
1051
+ const policy = {
1052
+ ...merged,
1053
+ maxActionTimeoutSeconds: Math.min(merged.maxActionTimeoutSeconds, this.config.maxActionTimeoutSeconds),
1054
+ maxOutputBytes: Math.min(merged.maxOutputBytes, this.config.maxOutputBytes),
1055
+ version: nextVersion,
1056
+ };
1057
+ const updated = {
1058
+ ...agent,
1059
+ profile: policy.profile,
1060
+ policy,
1061
+ policyVersion: nextVersion,
1062
+ updatedAt: nowIso(),
1063
+ };
1064
+ this.store.updateAgent(updated);
1065
+ const live = this.agentConnections.get(agent.id);
1066
+ if (live) {
1067
+ const envelope = {
1068
+ type: "policy.update",
1069
+ agent_id: agent.id,
1070
+ policy,
1071
+ policy_version: nextVersion,
1072
+ issued_at: nowIso(),
1073
+ nonce: randomToken(16),
1074
+ signature: "",
1075
+ };
1076
+ envelope.signature = signEnvelope(envelope, this.controlPlaneKeyPair.privateKeyPem);
1077
+ live.connection.sendJson(envelope);
1078
+ }
1079
+ this.audit({
1080
+ userId: principal.userId,
1081
+ agentId: agent.id,
1082
+ eventType: "policy_updated",
1083
+ severity: "warn",
1084
+ metadata: { profile: updated.profile, policy_version: updated.policyVersion },
1085
+ });
1086
+ return updated;
1087
+ }
1088
+ revokeAgent(principal, agent) {
1089
+ if (!hasCapability(principal.capabilities, "agents.admin")) {
1090
+ throw safeError("INVALID_SCOPE", "agents:admin scope is required", 403);
1091
+ }
1092
+ const updated = { ...agent, status: "revoked", updatedAt: nowIso() };
1093
+ this.store.updateAgent(updated);
1094
+ this.agentConnections.get(agent.id)?.connection.close();
1095
+ this.agentConnections.delete(agent.id);
1096
+ this.audit({
1097
+ userId: principal.userId,
1098
+ agentId: agent.id,
1099
+ eventType: "agent_revoked",
1100
+ severity: "warn",
1101
+ metadata: {},
1102
+ });
1103
+ return updated;
1104
+ }
1105
+ async dispatchAction(principal, agent, tool, capability, args) {
1106
+ if (agent.status === "revoked") {
1107
+ throw safeError("AGENT_REVOKED", "Agent is revoked", 410);
1108
+ }
1109
+ const live = this.agentConnections.get(agent.id);
1110
+ if (!live) {
1111
+ throw safeError("AGENT_OFFLINE", "Agent is offline", 503);
1112
+ }
1113
+ const actionId = id("act");
1114
+ const timeoutSeconds = Math.min(Number(args.timeout_seconds ?? agent.policy.maxActionTimeoutSeconds), agent.policy.maxActionTimeoutSeconds, this.config.maxActionTimeoutSeconds);
1115
+ const issuedAt = nowIso();
1116
+ const deadline = new Date(Date.now() + timeoutSeconds * 1000).toISOString();
1117
+ const envelope = {
1118
+ type: "action.request",
1119
+ action_id: actionId,
1120
+ agent_id: agent.id,
1121
+ user_id: principal.userId,
1122
+ tool,
1123
+ capability,
1124
+ args,
1125
+ policy_version: agent.policyVersion,
1126
+ issued_at: issuedAt,
1127
+ deadline,
1128
+ nonce: randomToken(16),
1129
+ signature: "",
1130
+ };
1131
+ envelope.signature = signEnvelope(envelope, this.controlPlaneKeyPair.privateKeyPem);
1132
+ const action = {
1133
+ id: actionId,
1134
+ userId: principal.userId,
1135
+ agentId: agent.id,
1136
+ tool,
1137
+ capability,
1138
+ args,
1139
+ status: "sent",
1140
+ issuedAt,
1141
+ deadline,
1142
+ };
1143
+ this.store.insertAction(action);
1144
+ this.audit({
1145
+ userId: principal.userId,
1146
+ agentId: agent.id,
1147
+ actionId,
1148
+ eventType: "action_requested",
1149
+ severity: "info",
1150
+ metadata: { tool, capability },
1151
+ });
1152
+ this.audit({
1153
+ userId: principal.userId,
1154
+ agentId: agent.id,
1155
+ actionId,
1156
+ eventType: "action_allowed",
1157
+ severity: "info",
1158
+ metadata: { tool, capability },
1159
+ });
1160
+ let result;
1161
+ try {
1162
+ result = await new Promise((resolve, reject) => {
1163
+ const timeout = setTimeout(() => {
1164
+ this.pendingActions.delete(actionId);
1165
+ reject(Object.assign(new Error("Agent timed out"), { code: "AGENT_TIMEOUT" }));
1166
+ }, timeoutSeconds * 1000 + 2000);
1167
+ this.pendingActions.set(actionId, { action, resolve, reject, timeout });
1168
+ live.connection.sendJson(envelope);
1169
+ });
1170
+ }
1171
+ catch (error) {
1172
+ const code = error && typeof error === "object" && "code" in error
1173
+ ? error.code
1174
+ : "INTERNAL_ERROR";
1175
+ this.store.updateAction({
1176
+ ...action,
1177
+ status: code === "AGENT_TIMEOUT" ? "timeout" : "error",
1178
+ completedAt: nowIso(),
1179
+ errorCode: code,
1180
+ });
1181
+ this.audit({
1182
+ userId: principal.userId,
1183
+ agentId: agent.id,
1184
+ actionId,
1185
+ eventType: "action_denied_or_failed",
1186
+ severity: "warn",
1187
+ metadata: { status: "error", error_code: code },
1188
+ });
1189
+ throw safeError(code, code === "AGENT_TIMEOUT" ? "Agent timed out" : "Agent action failed", 504);
1190
+ }
1191
+ this.store.updateAction({
1192
+ ...action,
1193
+ status: result.status === "ok" ? "completed" : "error",
1194
+ completedAt: nowIso(),
1195
+ result,
1196
+ errorCode: result.error_code,
1197
+ });
1198
+ this.audit({
1199
+ userId: principal.userId,
1200
+ agentId: agent.id,
1201
+ actionId,
1202
+ eventType: result.status === "ok" ? "action_completed" : "action_denied_or_failed",
1203
+ severity: result.status === "ok" ? "info" : "warn",
1204
+ metadata: { status: result.status, error_code: result.error_code },
1205
+ });
1206
+ return result;
1207
+ }
1208
+ requireAgent(userId, args) {
1209
+ const agent = this.resolveAgent(userId, asString(args.agent_id_or_alias) ?? "");
1210
+ if (!agent) {
1211
+ throw safeError("AGENT_NOT_FOUND", "Agent not found", 404);
1212
+ }
1213
+ return agent;
1214
+ }
1215
+ resolveAgent(userId, value) {
1216
+ if (!value) {
1217
+ return undefined;
1218
+ }
1219
+ const byId = this.store.getAgent(value);
1220
+ if (byId?.userId === userId) {
1221
+ return byId;
1222
+ }
1223
+ return this.store.getAgentByAlias(userId, value);
1224
+ }
1225
+ audit(input) {
1226
+ this.store.insertAudit({
1227
+ id: id("aud"),
1228
+ createdAt: nowIso(),
1229
+ ...input,
1230
+ metadata: input.metadata,
1231
+ });
1232
+ }
1233
+ requireJwtKeyPair() {
1234
+ if (!this.jwtKeyPair) {
1235
+ throw new Error("Remote control plane was not initialized");
1236
+ }
1237
+ return this.jwtKeyPair;
1238
+ }
1239
+ }
1240
+ export async function createRemoteControlPlane() {
1241
+ const controlPlane = new RemoteControlPlane();
1242
+ await controlPlane.initialize();
1243
+ return controlPlane;
1244
+ }
1245
+ export function controlPlanePublicKeyId(controlPlaneKeyPair) {
1246
+ return keyId(controlPlaneKeyPair.publicKeyPem);
1247
+ }
1248
+ //# sourceMappingURL=control-plane.js.map