host-execution-mcp 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 (126) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -0
  4. package/SECURITY.md +72 -0
  5. package/dist/access/docker-runtime.d.ts +38 -0
  6. package/dist/access/docker-runtime.js +61 -0
  7. package/dist/access/docker-runtime.js.map +1 -0
  8. package/dist/access/index.d.ts +110 -0
  9. package/dist/access/index.js +174 -0
  10. package/dist/access/index.js.map +1 -0
  11. package/dist/access/paradi-auth.d.ts +33 -0
  12. package/dist/access/paradi-auth.js +89 -0
  13. package/dist/access/paradi-auth.js.map +1 -0
  14. package/dist/access/paradi-transport.d.ts +10 -0
  15. package/dist/access/paradi-transport.js +39 -0
  16. package/dist/access/paradi-transport.js.map +1 -0
  17. package/dist/access/paradi-username.d.ts +5 -0
  18. package/dist/access/paradi-username.js +14 -0
  19. package/dist/access/paradi-username.js.map +1 -0
  20. package/dist/access/probe.d.ts +14 -0
  21. package/dist/access/probe.js +63 -0
  22. package/dist/access/probe.js.map +1 -0
  23. package/dist/access/session-pool.d.ts +25 -0
  24. package/dist/access/session-pool.js +46 -0
  25. package/dist/access/session-pool.js.map +1 -0
  26. package/dist/access/ssh-transport.d.ts +16 -0
  27. package/dist/access/ssh-transport.js +74 -0
  28. package/dist/access/ssh-transport.js.map +1 -0
  29. package/dist/access/types.d.ts +43 -0
  30. package/dist/access/types.js +21 -0
  31. package/dist/access/types.js.map +1 -0
  32. package/dist/config.d.ts +52 -0
  33. package/dist/config.js +179 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.js +19 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/instructions.d.ts +4 -0
  39. package/dist/instructions.js +93 -0
  40. package/dist/instructions.js.map +1 -0
  41. package/dist/kubectl/runner.d.ts +32 -0
  42. package/dist/kubectl/runner.js +79 -0
  43. package/dist/kubectl/runner.js.map +1 -0
  44. package/dist/server.d.ts +3 -0
  45. package/dist/server.js +24 -0
  46. package/dist/server.js.map +1 -0
  47. package/dist/ssh/access-client.d.ts +62 -0
  48. package/dist/ssh/access-client.js +470 -0
  49. package/dist/ssh/access-client.js.map +1 -0
  50. package/dist/ssh/access-errors.d.ts +53 -0
  51. package/dist/ssh/access-errors.js +180 -0
  52. package/dist/ssh/access-errors.js.map +1 -0
  53. package/dist/ssh/access-sftp.d.ts +14 -0
  54. package/dist/ssh/access-sftp.js +456 -0
  55. package/dist/ssh/access-sftp.js.map +1 -0
  56. package/dist/ssh/bastion-client.d.ts +60 -0
  57. package/dist/ssh/bastion-client.js +423 -0
  58. package/dist/ssh/bastion-client.js.map +1 -0
  59. package/dist/ssh/bastion-errors.d.ts +62 -0
  60. package/dist/ssh/bastion-errors.js +190 -0
  61. package/dist/ssh/bastion-errors.js.map +1 -0
  62. package/dist/ssh/hop1-sftp.d.ts +14 -0
  63. package/dist/ssh/hop1-sftp.js +437 -0
  64. package/dist/ssh/hop1-sftp.js.map +1 -0
  65. package/dist/tools/copy-job-schemas.d.ts +709 -0
  66. package/dist/tools/copy-job-schemas.js +48 -0
  67. package/dist/tools/copy-job-schemas.js.map +1 -0
  68. package/dist/tools/register.d.ts +14 -0
  69. package/dist/tools/register.js +619 -0
  70. package/dist/tools/register.js.map +1 -0
  71. package/dist/tools/remote-schemas.d.ts +879 -0
  72. package/dist/tools/remote-schemas.js +214 -0
  73. package/dist/tools/remote-schemas.js.map +1 -0
  74. package/dist/utils/copy-executor.d.ts +41 -0
  75. package/dist/utils/copy-executor.js +231 -0
  76. package/dist/utils/copy-executor.js.map +1 -0
  77. package/dist/utils/copy-job-manager.d.ts +34 -0
  78. package/dist/utils/copy-job-manager.js +400 -0
  79. package/dist/utils/copy-job-manager.js.map +1 -0
  80. package/dist/utils/copy-job-store.d.ts +18 -0
  81. package/dist/utils/copy-job-store.js +104 -0
  82. package/dist/utils/copy-job-store.js.map +1 -0
  83. package/dist/utils/copy-job-types.d.ts +133 -0
  84. package/dist/utils/copy-job-types.js +37 -0
  85. package/dist/utils/copy-job-types.js.map +1 -0
  86. package/dist/utils/copy-transfer.d.ts +59 -0
  87. package/dist/utils/copy-transfer.js +86 -0
  88. package/dist/utils/copy-transfer.js.map +1 -0
  89. package/dist/utils/remote-copy.d.ts +14 -0
  90. package/dist/utils/remote-copy.js +115 -0
  91. package/dist/utils/remote-copy.js.map +1 -0
  92. package/dist/utils/remote-executor.d.ts +15 -0
  93. package/dist/utils/remote-executor.js +34 -0
  94. package/dist/utils/remote-executor.js.map +1 -0
  95. package/dist/utils/remote-target.d.ts +22 -0
  96. package/dist/utils/remote-target.js +75 -0
  97. package/dist/utils/remote-target.js.map +1 -0
  98. package/dist/utils/result.d.ts +87 -0
  99. package/dist/utils/result.js +81 -0
  100. package/dist/utils/result.js.map +1 -0
  101. package/dist/utils/shell-quote.d.ts +22 -0
  102. package/dist/utils/shell-quote.js +55 -0
  103. package/dist/utils/shell-quote.js.map +1 -0
  104. package/dist/utils/timeout.d.ts +1 -0
  105. package/dist/utils/timeout.js +18 -0
  106. package/dist/utils/timeout.js.map +1 -0
  107. package/dist/utils/truncate.d.ts +5 -0
  108. package/dist/utils/truncate.js +12 -0
  109. package/dist/utils/truncate.js.map +1 -0
  110. package/dist/version.d.ts +1 -0
  111. package/dist/version.js +7 -0
  112. package/dist/version.js.map +1 -0
  113. package/docs/AGENT_GUIDE.md +161 -0
  114. package/docs/CONFIGURATION.md +130 -0
  115. package/docs/COPY_SPECIALIZED_TEST_CHECKLIST.md +9 -0
  116. package/docs/HOP1_E2E_CHECKLIST.md +128 -0
  117. package/docs/HOP1_E2E_RESULTS.json +647 -0
  118. package/docs/PARADI_ACCESS_PLAN.md +272 -0
  119. package/docs/REMOTE_COPY_JOB_API.md +92 -0
  120. package/docs/TODO.md +66 -0
  121. package/docs/USER_GUIDE.zh-CN.md +93 -0
  122. package/docs/clients/claude-desktop.json.example +12 -0
  123. package/docs/clients/cursor-npx.json.example +12 -0
  124. package/docs/clients/cursor.json.example +12 -0
  125. package/docs/clients/vscode.json.example +12 -0
  126. package/package.json +65 -0
@@ -0,0 +1,79 @@
1
+ import { assertNamespaceAllowed } from "../config.js";
2
+ import { bashSingleQuote, shellEscapeArg } from "../utils/shell-quote.js";
3
+ export function buildListPodsCommand(namespace, options) {
4
+ const parts = ["kubectl get pods", `-n ${shellEscapeArg(namespace)}`];
5
+ if (options.labelSelector) {
6
+ parts.push(`-l ${shellEscapeArg(options.labelSelector)}`);
7
+ }
8
+ if (options.fieldSelector) {
9
+ parts.push(`--field-selector=${shellEscapeArg(options.fieldSelector)}`);
10
+ }
11
+ const output = options.output ?? "wide";
12
+ if (output === "json") {
13
+ parts.push("-o json");
14
+ }
15
+ else if (output === "name") {
16
+ parts.push("-o name");
17
+ }
18
+ else {
19
+ parts.push("-o wide");
20
+ }
21
+ return parts.join(" ");
22
+ }
23
+ export function buildKubectlExecCommand(namespace, pod, command, container) {
24
+ const parts = [
25
+ "kubectl exec",
26
+ `-n ${shellEscapeArg(namespace)}`,
27
+ shellEscapeArg(pod),
28
+ ];
29
+ if (container) {
30
+ parts.push(`-c ${shellEscapeArg(container)}`);
31
+ }
32
+ parts.push("--", "bash", "-lc", bashSingleQuote(command));
33
+ return parts.join(" ");
34
+ }
35
+ export function buildKubectlLogsCommand(namespace, pod, options) {
36
+ const parts = [
37
+ "kubectl logs",
38
+ `-n ${shellEscapeArg(namespace)}`,
39
+ shellEscapeArg(pod),
40
+ ];
41
+ if (options.container) {
42
+ parts.push(`-c ${shellEscapeArg(options.container)}`);
43
+ }
44
+ if (options.since) {
45
+ parts.push(`--since=${shellEscapeArg(options.since)}`);
46
+ }
47
+ parts.push(`--tail=${options.tail ?? 200}`);
48
+ return parts.join(" ");
49
+ }
50
+ export async function runOnAccess(client, config, command, timeoutMs, cwd) {
51
+ return client.exec(command, timeoutMs ?? config.defaultTimeoutMs, cwd);
52
+ }
53
+ export function validateNamespace(config, namespace) {
54
+ assertNamespaceAllowed(config, namespace);
55
+ }
56
+ export class KubectlRunner {
57
+ client;
58
+ config;
59
+ constructor(client, config) {
60
+ this.client = client;
61
+ this.config = config;
62
+ }
63
+ listPods(namespace, options, timeoutMs) {
64
+ validateNamespace(this.config, namespace);
65
+ const command = buildListPodsCommand(namespace, options);
66
+ return runOnAccess(this.client, this.config, command, timeoutMs);
67
+ }
68
+ exec(namespace, pod, command, container, timeoutMs) {
69
+ validateNamespace(this.config, namespace);
70
+ const kubectlCommand = buildKubectlExecCommand(namespace, pod, command, container);
71
+ return runOnAccess(this.client, this.config, kubectlCommand, timeoutMs);
72
+ }
73
+ logs(namespace, pod, options, timeoutMs) {
74
+ validateNamespace(this.config, namespace);
75
+ const kubectlCommand = buildKubectlLogsCommand(namespace, pod, options);
76
+ return runOnAccess(this.client, this.config, kubectlCommand, timeoutMs);
77
+ }
78
+ }
79
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../../src/kubectl/runner.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { AppConfig } from "./config.js";
3
+ export declare function createServer(config: AppConfig): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,24 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { VERSION } from "./version.js";
3
+ import { AccessRuntime } from "./access/index.js";
4
+ import { buildServerInstructions } from "./instructions.js";
5
+ import { KubectlRunner } from "./kubectl/runner.js";
6
+ import { AccessClient } from "./ssh/access-client.js";
7
+ import { registerPrompts, registerTools } from "./tools/register.js";
8
+ import { RemoteExecutor } from "./utils/remote-executor.js";
9
+ export function createServer(config) {
10
+ const server = new McpServer({
11
+ name: "host-execution-mcp",
12
+ version: VERSION,
13
+ }, {
14
+ instructions: buildServerInstructions(config),
15
+ });
16
+ const accessClient = new AccessClient(config);
17
+ const access = new AccessRuntime(config, accessClient);
18
+ const kubectl = new KubectlRunner(accessClient, config);
19
+ const remote = new RemoteExecutor(access, kubectl, config);
20
+ registerTools(server, { config, accessClient, access, kubectl, remote });
21
+ registerPrompts(server);
22
+ return server;
23
+ }
24
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACrE,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE5D,MAAM,UAAU,YAAY,CAAC,MAAiB;IAC5C,MAAM,MAAM,GAAG,IAAI,SAAS,CAC1B;QACE,IAAI,EAAE,oBAAoB;QAC1B,OAAO,EAAE,OAAO;KACjB,EACD;QACE,YAAY,EAAE,uBAAuB,CAAC,MAAM,CAAC;KAC9C,CACF,CAAC;IAEF,MAAM,YAAY,GAAG,IAAI,YAAY,CAAC,MAAM,CAAC,CAAC;IAC9C,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IACvD,MAAM,OAAO,GAAG,IAAI,aAAa,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;IACxD,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IAE3D,aAAa,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;IACzE,eAAe,CAAC,MAAM,CAAC,CAAC;IAExB,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,62 @@
1
+ import type { AppConfig } from "../config.js";
2
+ import { type SftpCopyOptions, type SftpTransferResult } from "../utils/copy-transfer.js";
3
+ import { type AccessCheckResult } from "./access-errors.js";
4
+ export interface ExecOutput {
5
+ stdout: string;
6
+ stderr: string;
7
+ exitCode: number;
8
+ durationMs: number;
9
+ }
10
+ export declare class AccessClient {
11
+ private client;
12
+ private connecting;
13
+ /** Serializes ensureAccessReady / check cache updates. */
14
+ private gate;
15
+ private readonly config;
16
+ private readyUntil;
17
+ private unavailableUntil;
18
+ private lastUnavailable;
19
+ constructor(config: AppConfig);
20
+ /**
21
+ * Ensure accessClient is reachable (TTL-cached). Uses an ephemeral SSH probe ?
22
+ * never disconnects an in-flight shared exec session on success.
23
+ */
24
+ ensureAccessReady(): Promise<void>;
25
+ /**
26
+ * Short-timeout Local to ACCESS probe (ephemeral client; <=10s worst case).
27
+ * Respects negative TTL (returns cached failure). Does not sever shared exec SSH.
28
+ */
29
+ check(): Promise<AccessCheckResult>;
30
+ exec(command: string, timeoutMs?: number, cwd?: string, signal?: AbortSignal): Promise<ExecOutput>;
31
+ disconnect(): void;
32
+ /**
33
+ * Upload local path (file or directory) ? accessClient via independent Hop1 SFTP.
34
+ * Transfer-phase errors are not reclassified into ACCESS_UNAVAILABLE.
35
+ */
36
+ uploadPath(localPath: string, remotePath: string, options?: SftpCopyOptions): Promise<SftpTransferResult>;
37
+ /**
38
+ * Download accessClient path (file or directory) ? local via independent Hop1 SFTP.
39
+ */
40
+ downloadPath(remotePath: string, localPath: string, options?: SftpCopyOptions): Promise<SftpTransferResult>;
41
+ private withGate;
42
+ private markReady;
43
+ /**
44
+ * Record a connect-phase failure. Negative TTL only for classified causes
45
+ * (not UNKNOWN), so transient unknowns are not blackholed for 15s.
46
+ */
47
+ private markConnectUnavailable;
48
+ /** Ephemeral SSH + shell probe; never assigns or disconnects `this.client`. */
49
+ private probeEphemeral;
50
+ private remoteSha256;
51
+ private ensureConnected;
52
+ private ensureConnectedUnlocked;
53
+ private requireClient;
54
+ /**
55
+ * Open a dedicated ssh2 Client with readyTimeout + hard deadline
56
+ * (covers TCP blackhole where ssh2 readyTimeout alone is insufficient).
57
+ * Paradi auto: try slash then compound on AUTH_FAILED; pin winner for this process.
58
+ */
59
+ private openSshClient;
60
+ private connectOnce;
61
+ private execOnClient;
62
+ }
@@ -0,0 +1,470 @@
1
+ import { Client } from "ssh2";
2
+ import { resolveAccessSshIdentity, resolveSshAuthOptions } from "../config.js";
3
+ import { getPinnedParadiAuth, isParadiAuthFailureMessage, pinParadiAuth, resolveParadiAuthModesToTry, resolveParadiConnectParams, } from "../access/paradi-auth.js";
4
+ import { bashSingleQuote, buildBashLcCommand } from "../utils/shell-quote.js";
5
+ import { throwIfAborted, } from "../utils/copy-transfer.js";
6
+ import { transferLocalAccess } from "./access-sftp.js";
7
+ import { AccessUnavailableError, isAccessUnavailableError, shouldRetryAccessConnect, toAccessUnavailableError, } from "./access-errors.js";
8
+ /** Positive readiness TTL after a successful probe / connect. */
9
+ const READY_TTL_MS = 30_000;
10
+ /** Negative cache after classified connect failure (avoid hammering). */
11
+ const UNAVAILABLE_TTL_MS = 15_000;
12
+ /** SSH readyTimeout + hard deadline for probe / check. */
13
+ const PROBE_READY_TIMEOUT_MS = 5_000;
14
+ /** Shell verify after SSH ready (within overall ?10s probe budget). */
15
+ const PROBE_SHELL_TIMEOUT_MS = 5_000;
16
+ /** Shared exec session connect timeout. */
17
+ const EXEC_CONNECT_TIMEOUT_MS = 20_000;
18
+ /** Max reconnects for transient stream-phase drops during exec. */
19
+ const MAX_RECONNECT_ATTEMPTS = 3;
20
+ export class AccessClient {
21
+ client = null;
22
+ connecting = null;
23
+ /** Serializes ensureAccessReady / check cache updates. */
24
+ gate = Promise.resolve();
25
+ config;
26
+ readyUntil = 0;
27
+ unavailableUntil = 0;
28
+ lastUnavailable = null;
29
+ constructor(config) {
30
+ this.config = config;
31
+ }
32
+ /**
33
+ * Ensure accessClient is reachable (TTL-cached). Uses an ephemeral SSH probe ?
34
+ * never disconnects an in-flight shared exec session on success.
35
+ */
36
+ async ensureAccessReady() {
37
+ await this.withGate(async () => {
38
+ const now = Date.now();
39
+ if (now < this.unavailableUntil && this.lastUnavailable) {
40
+ throw this.lastUnavailable;
41
+ }
42
+ if (this.client && now < this.readyUntil) {
43
+ return;
44
+ }
45
+ try {
46
+ await this.probeEphemeral();
47
+ this.markReady();
48
+ if (!this.client) {
49
+ await this.ensureConnectedUnlocked();
50
+ }
51
+ }
52
+ catch (error) {
53
+ throw this.markConnectUnavailable(error);
54
+ }
55
+ });
56
+ }
57
+ /**
58
+ * Short-timeout Local to ACCESS probe (ephemeral client; <=10s worst case).
59
+ * Respects negative TTL (returns cached failure). Does not sever shared exec SSH.
60
+ */
61
+ async check() {
62
+ return this.withGate(async () => {
63
+ const identity = resolveAccessSshIdentity(this.config);
64
+ const host = identity.host;
65
+ const port = identity.port;
66
+ const now = Date.now();
67
+ if (now < this.unavailableUntil && this.lastUnavailable) {
68
+ return this.lastUnavailable.toJSON();
69
+ }
70
+ const started = Date.now();
71
+ try {
72
+ await this.probeEphemeral();
73
+ this.markReady();
74
+ const pinned = getPinnedParadiAuth(this.config);
75
+ const kindHint = this.config.accessKind === "paradi-gateway"
76
+ ? ` (paradi → ${this.config.targetHost}${pinned ? `, auth=${pinned}` : ""})`
77
+ : "";
78
+ return {
79
+ reachable: true,
80
+ latencyMs: Date.now() - started,
81
+ host,
82
+ port,
83
+ auth: "ok",
84
+ ...(pinned ? { paradiAuth: pinned } : {}),
85
+ message: `ACCESS OK: ${host}:${port}${kindHint}`,
86
+ };
87
+ }
88
+ catch (error) {
89
+ return this.markConnectUnavailable(error).toJSON();
90
+ }
91
+ });
92
+ }
93
+ async exec(command, timeoutMs, cwd, signal) {
94
+ const effectiveTimeout = timeoutMs ?? this.config.defaultTimeoutMs;
95
+ const remoteCommand = buildBashLcCommand(command, cwd);
96
+ const started = Date.now();
97
+ await this.ensureAccessReady();
98
+ let lastError;
99
+ for (let attempt = 1; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
100
+ try {
101
+ throwIfAborted(signal);
102
+ await this.ensureConnected();
103
+ const result = await this.execOnClient(this.requireClient(), remoteCommand, {
104
+ timeoutMs: effectiveTimeout,
105
+ signal,
106
+ });
107
+ this.markReady();
108
+ return { ...result, durationMs: Date.now() - started };
109
+ }
110
+ catch (error) {
111
+ lastError = error;
112
+ const msg = error instanceof Error ? error.message : String(error);
113
+ if (signal?.aborted || msg === "Copy aborted") {
114
+ break;
115
+ }
116
+ // Stream-phase: only elevate true accessClient-down signals (not ECONNRESET/EPIPE).
117
+ const unavailable = toAccessUnavailableError(error, this.config.accessHost, this.config.accessPort, "stream");
118
+ if (unavailable) {
119
+ throw this.markConnectUnavailable(unavailable);
120
+ }
121
+ if (!shouldRetryAccessConnect(error)) {
122
+ break;
123
+ }
124
+ this.disconnect();
125
+ if (attempt === MAX_RECONNECT_ATTEMPTS) {
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
131
+ }
132
+ disconnect() {
133
+ if (this.client) {
134
+ this.client.end();
135
+ this.client = null;
136
+ }
137
+ this.connecting = null;
138
+ }
139
+ /**
140
+ * Upload local path (file or directory) ? accessClient via independent Hop1 SFTP.
141
+ * Transfer-phase errors are not reclassified into ACCESS_UNAVAILABLE.
142
+ */
143
+ async uploadPath(localPath, remotePath, options) {
144
+ await this.ensureAccessReady();
145
+ try {
146
+ return await transferLocalAccess({
147
+ direction: "upload",
148
+ localPath,
149
+ remotePath,
150
+ config: this.config,
151
+ options,
152
+ }, (remote, timeoutMs) => this.remoteSha256(remote, timeoutMs));
153
+ }
154
+ catch (error) {
155
+ // Only connect-time AccessUnavailableError from Hop1 SFTP connect is cached.
156
+ if (isAccessUnavailableError(error)) {
157
+ throw this.markConnectUnavailable(error);
158
+ }
159
+ throw error;
160
+ }
161
+ }
162
+ /**
163
+ * Download accessClient path (file or directory) ? local via independent Hop1 SFTP.
164
+ */
165
+ async downloadPath(remotePath, localPath, options) {
166
+ await this.ensureAccessReady();
167
+ try {
168
+ return await transferLocalAccess({
169
+ direction: "download",
170
+ localPath,
171
+ remotePath,
172
+ config: this.config,
173
+ options,
174
+ }, (remote, timeoutMs) => this.remoteSha256(remote, timeoutMs));
175
+ }
176
+ catch (error) {
177
+ if (isAccessUnavailableError(error)) {
178
+ throw this.markConnectUnavailable(error);
179
+ }
180
+ throw error;
181
+ }
182
+ }
183
+ async withGate(fn) {
184
+ const prev = this.gate;
185
+ let release;
186
+ this.gate = new Promise((resolve) => {
187
+ release = resolve;
188
+ });
189
+ await prev;
190
+ try {
191
+ return await fn();
192
+ }
193
+ finally {
194
+ release();
195
+ }
196
+ }
197
+ markReady() {
198
+ this.readyUntil = Date.now() + READY_TTL_MS;
199
+ this.unavailableUntil = 0;
200
+ this.lastUnavailable = null;
201
+ }
202
+ /**
203
+ * Record a connect-phase failure. Negative TTL only for classified causes
204
+ * (not UNKNOWN), so transient unknowns are not blackholed for 15s.
205
+ */
206
+ markConnectUnavailable(error) {
207
+ const unavailable = (isAccessUnavailableError(error) ? error : null) ??
208
+ toAccessUnavailableError(error, this.config.accessHost, this.config.accessPort, "connect") ??
209
+ new AccessUnavailableError({
210
+ causeCode: "UNKNOWN",
211
+ host: this.config.accessHost,
212
+ port: this.config.accessPort,
213
+ detail: error instanceof Error ? error.message : String(error),
214
+ cause: error,
215
+ });
216
+ this.disconnect();
217
+ this.readyUntil = 0;
218
+ if (unavailable.causeCode === "UNKNOWN") {
219
+ this.unavailableUntil = 0;
220
+ this.lastUnavailable = null;
221
+ }
222
+ else {
223
+ this.unavailableUntil = Date.now() + UNAVAILABLE_TTL_MS;
224
+ this.lastUnavailable = unavailable;
225
+ }
226
+ return unavailable;
227
+ }
228
+ /** Ephemeral SSH + shell probe; never assigns or disconnects `this.client`. */
229
+ async probeEphemeral() {
230
+ const client = await this.openSshClient(PROBE_READY_TIMEOUT_MS);
231
+ try {
232
+ const result = await this.execOnClient(client, "echo access_ok", {
233
+ timeoutMs: PROBE_SHELL_TIMEOUT_MS,
234
+ });
235
+ if (result.exitCode !== 0 || !result.stdout.includes("access_ok")) {
236
+ throw new AccessUnavailableError({
237
+ causeCode: "SSH_HANDSHAKE",
238
+ host: this.config.accessHost,
239
+ port: this.config.accessPort,
240
+ detail: `shell probe failed: exit=${result.exitCode} stdout=${result.stdout.trim()}`,
241
+ });
242
+ }
243
+ }
244
+ finally {
245
+ try {
246
+ client.end();
247
+ }
248
+ catch {
249
+ // ignore
250
+ }
251
+ }
252
+ }
253
+ async remoteSha256(remotePath, timeoutMs) {
254
+ const raw = await this.exec(`sha256sum ${bashSingleQuote(remotePath)} | awk '{print $1}'`, timeoutMs);
255
+ const hash = raw.stdout.trim().split(/\s+/)[0];
256
+ if (!/^[a-f0-9]{64}$/i.test(hash)) {
257
+ throw new Error(`Failed to compute remote sha256 for ${remotePath}: ${raw.stdout || raw.stderr}`);
258
+ }
259
+ return hash.toLowerCase();
260
+ }
261
+ async ensureConnected() {
262
+ await this.withGate(() => this.ensureConnectedUnlocked());
263
+ }
264
+ async ensureConnectedUnlocked() {
265
+ if (this.client) {
266
+ return;
267
+ }
268
+ if (this.connecting) {
269
+ await this.connecting;
270
+ return;
271
+ }
272
+ this.connecting = (async () => {
273
+ try {
274
+ const client = await this.openSshClient(EXEC_CONNECT_TIMEOUT_MS);
275
+ this.client = client;
276
+ client.on("close", () => {
277
+ if (this.client === client) {
278
+ this.client = null;
279
+ }
280
+ });
281
+ }
282
+ catch (error) {
283
+ throw this.markConnectUnavailable(error);
284
+ }
285
+ })();
286
+ try {
287
+ await this.connecting;
288
+ }
289
+ finally {
290
+ this.connecting = null;
291
+ }
292
+ }
293
+ requireClient() {
294
+ if (!this.client) {
295
+ throw new Error("SSH client is not connected");
296
+ }
297
+ return this.client;
298
+ }
299
+ /**
300
+ * Open a dedicated ssh2 Client with readyTimeout + hard deadline
301
+ * (covers TCP blackhole where ssh2 readyTimeout alone is insufficient).
302
+ * Paradi auto: try slash then compound on AUTH_FAILED; pin winner for this process.
303
+ */
304
+ async openSshClient(readyTimeoutMs) {
305
+ if (this.config.accessKind !== "paradi-gateway") {
306
+ const auth = resolveSshAuthOptions(this.config);
307
+ const identity = resolveAccessSshIdentity(this.config);
308
+ return this.connectOnce(readyTimeoutMs, {
309
+ host: identity.host,
310
+ port: identity.port,
311
+ username: identity.username,
312
+ password: auth.password,
313
+ privateKey: auth.privateKey,
314
+ agent: auth.agent,
315
+ });
316
+ }
317
+ const baseAuth = resolveSshAuthOptions(this.config);
318
+ const modes = resolveParadiAuthModesToTry(this.config);
319
+ let lastError;
320
+ for (let i = 0; i < modes.length; i++) {
321
+ const mode = modes[i];
322
+ const params = resolveParadiConnectParams(this.config, mode);
323
+ try {
324
+ const client = await this.connectOnce(readyTimeoutMs, {
325
+ host: params.host,
326
+ port: params.port,
327
+ username: params.username,
328
+ // Compound replaces ACCESS_PASSWORD; slash keeps it (and keys/agent still tried).
329
+ password: params.password ?? baseAuth.password,
330
+ privateKey: baseAuth.privateKey,
331
+ agent: baseAuth.agent,
332
+ });
333
+ pinParadiAuth(this.config, mode);
334
+ return client;
335
+ }
336
+ catch (error) {
337
+ lastError = error;
338
+ const msg = error instanceof Error ? error.message : String(error);
339
+ const authFail = isParadiAuthFailureMessage(msg);
340
+ const hasNext = i < modes.length - 1;
341
+ if (authFail && hasNext) {
342
+ continue;
343
+ }
344
+ throw error;
345
+ }
346
+ }
347
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
348
+ }
349
+ connectOnce(readyTimeoutMs, opts) {
350
+ const connectConfig = {
351
+ host: opts.host,
352
+ port: opts.port,
353
+ username: opts.username,
354
+ readyTimeout: readyTimeoutMs,
355
+ };
356
+ if (opts.agent) {
357
+ connectConfig.agent = opts.agent;
358
+ }
359
+ if (opts.privateKey) {
360
+ connectConfig.privateKey = opts.privateKey;
361
+ }
362
+ if (opts.password) {
363
+ connectConfig.password = opts.password;
364
+ }
365
+ return new Promise((resolve, reject) => {
366
+ const client = new Client();
367
+ let settled = false;
368
+ const finish = (fn) => {
369
+ if (settled) {
370
+ return;
371
+ }
372
+ settled = true;
373
+ clearTimeout(timer);
374
+ fn();
375
+ };
376
+ const timer = setTimeout(() => {
377
+ finish(() => {
378
+ try {
379
+ client.end();
380
+ }
381
+ catch {
382
+ // ignore
383
+ }
384
+ reject(Object.assign(new Error("Timed out while waiting for handshake"), {
385
+ code: "ETIMEDOUT",
386
+ }));
387
+ });
388
+ }, readyTimeoutMs);
389
+ client
390
+ .on("ready", () => {
391
+ finish(() => resolve(client));
392
+ })
393
+ .on("error", (error) => {
394
+ finish(() => reject(error));
395
+ })
396
+ .on("close", () => {
397
+ finish(() => reject(new Error("SSH closed before ready")));
398
+ })
399
+ .connect(connectConfig);
400
+ });
401
+ }
402
+ execOnClient(client, command, options = { timeoutMs: 60_000 }) {
403
+ throwIfAborted(options.signal);
404
+ return new Promise((resolve, reject) => {
405
+ let settled = false;
406
+ let stream;
407
+ const cleanup = () => {
408
+ clearTimeout(timer);
409
+ options.signal?.removeEventListener("abort", onAbort);
410
+ };
411
+ const fail = (error) => {
412
+ if (settled) {
413
+ return;
414
+ }
415
+ settled = true;
416
+ cleanup();
417
+ try {
418
+ stream?.close?.();
419
+ stream?.destroy?.();
420
+ }
421
+ catch {
422
+ // ignore
423
+ }
424
+ reject(error instanceof Error ? error : new Error(String(error)));
425
+ };
426
+ const onAbort = () => {
427
+ fail(new Error("Copy aborted"));
428
+ };
429
+ const timer = setTimeout(() => {
430
+ fail(new Error(`Operation timed out after ${options.timeoutMs}ms`));
431
+ }, options.timeoutMs);
432
+ if (options.signal) {
433
+ if (options.signal.aborted) {
434
+ onAbort();
435
+ return;
436
+ }
437
+ options.signal.addEventListener("abort", onAbort, { once: true });
438
+ }
439
+ client.exec(command, (error, execStream) => {
440
+ if (error) {
441
+ fail(error);
442
+ return;
443
+ }
444
+ stream = execStream;
445
+ let stdout = "";
446
+ let stderr = "";
447
+ execStream
448
+ .on("close", (code) => {
449
+ if (settled) {
450
+ return;
451
+ }
452
+ settled = true;
453
+ cleanup();
454
+ resolve({
455
+ stdout,
456
+ stderr,
457
+ exitCode: code ?? 1,
458
+ });
459
+ })
460
+ .on("data", (chunk) => {
461
+ stdout += chunk.toString();
462
+ });
463
+ execStream.stderr.on("data", (chunk) => {
464
+ stderr += chunk.toString();
465
+ });
466
+ });
467
+ });
468
+ }
469
+ }
470
+ //# sourceMappingURL=access-client.js.map