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.
- package/CHANGELOG.md +330 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/SECURITY.md +72 -0
- package/dist/access/docker-runtime.d.ts +38 -0
- package/dist/access/docker-runtime.js +61 -0
- package/dist/access/docker-runtime.js.map +1 -0
- package/dist/access/index.d.ts +110 -0
- package/dist/access/index.js +174 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/paradi-auth.d.ts +33 -0
- package/dist/access/paradi-auth.js +89 -0
- package/dist/access/paradi-auth.js.map +1 -0
- package/dist/access/paradi-transport.d.ts +10 -0
- package/dist/access/paradi-transport.js +39 -0
- package/dist/access/paradi-transport.js.map +1 -0
- package/dist/access/paradi-username.d.ts +5 -0
- package/dist/access/paradi-username.js +14 -0
- package/dist/access/paradi-username.js.map +1 -0
- package/dist/access/probe.d.ts +14 -0
- package/dist/access/probe.js +63 -0
- package/dist/access/probe.js.map +1 -0
- package/dist/access/session-pool.d.ts +25 -0
- package/dist/access/session-pool.js +46 -0
- package/dist/access/session-pool.js.map +1 -0
- package/dist/access/ssh-transport.d.ts +16 -0
- package/dist/access/ssh-transport.js +74 -0
- package/dist/access/ssh-transport.js.map +1 -0
- package/dist/access/types.d.ts +43 -0
- package/dist/access/types.js +21 -0
- package/dist/access/types.js.map +1 -0
- package/dist/config.d.ts +52 -0
- package/dist/config.js +179 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/instructions.d.ts +4 -0
- package/dist/instructions.js +93 -0
- package/dist/instructions.js.map +1 -0
- package/dist/kubectl/runner.d.ts +32 -0
- package/dist/kubectl/runner.js +79 -0
- package/dist/kubectl/runner.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +24 -0
- package/dist/server.js.map +1 -0
- package/dist/ssh/access-client.d.ts +62 -0
- package/dist/ssh/access-client.js +470 -0
- package/dist/ssh/access-client.js.map +1 -0
- package/dist/ssh/access-errors.d.ts +53 -0
- package/dist/ssh/access-errors.js +180 -0
- package/dist/ssh/access-errors.js.map +1 -0
- package/dist/ssh/access-sftp.d.ts +14 -0
- package/dist/ssh/access-sftp.js +456 -0
- package/dist/ssh/access-sftp.js.map +1 -0
- package/dist/ssh/bastion-client.d.ts +60 -0
- package/dist/ssh/bastion-client.js +423 -0
- package/dist/ssh/bastion-client.js.map +1 -0
- package/dist/ssh/bastion-errors.d.ts +62 -0
- package/dist/ssh/bastion-errors.js +190 -0
- package/dist/ssh/bastion-errors.js.map +1 -0
- package/dist/ssh/hop1-sftp.d.ts +14 -0
- package/dist/ssh/hop1-sftp.js +437 -0
- package/dist/ssh/hop1-sftp.js.map +1 -0
- package/dist/tools/copy-job-schemas.d.ts +709 -0
- package/dist/tools/copy-job-schemas.js +48 -0
- package/dist/tools/copy-job-schemas.js.map +1 -0
- package/dist/tools/register.d.ts +14 -0
- package/dist/tools/register.js +619 -0
- package/dist/tools/register.js.map +1 -0
- package/dist/tools/remote-schemas.d.ts +879 -0
- package/dist/tools/remote-schemas.js +214 -0
- package/dist/tools/remote-schemas.js.map +1 -0
- package/dist/utils/copy-executor.d.ts +41 -0
- package/dist/utils/copy-executor.js +231 -0
- package/dist/utils/copy-executor.js.map +1 -0
- package/dist/utils/copy-job-manager.d.ts +34 -0
- package/dist/utils/copy-job-manager.js +400 -0
- package/dist/utils/copy-job-manager.js.map +1 -0
- package/dist/utils/copy-job-store.d.ts +18 -0
- package/dist/utils/copy-job-store.js +104 -0
- package/dist/utils/copy-job-store.js.map +1 -0
- package/dist/utils/copy-job-types.d.ts +133 -0
- package/dist/utils/copy-job-types.js +37 -0
- package/dist/utils/copy-job-types.js.map +1 -0
- package/dist/utils/copy-transfer.d.ts +59 -0
- package/dist/utils/copy-transfer.js +86 -0
- package/dist/utils/copy-transfer.js.map +1 -0
- package/dist/utils/remote-copy.d.ts +14 -0
- package/dist/utils/remote-copy.js +115 -0
- package/dist/utils/remote-copy.js.map +1 -0
- package/dist/utils/remote-executor.d.ts +15 -0
- package/dist/utils/remote-executor.js +34 -0
- package/dist/utils/remote-executor.js.map +1 -0
- package/dist/utils/remote-target.d.ts +22 -0
- package/dist/utils/remote-target.js +75 -0
- package/dist/utils/remote-target.js.map +1 -0
- package/dist/utils/result.d.ts +87 -0
- package/dist/utils/result.js +81 -0
- package/dist/utils/result.js.map +1 -0
- package/dist/utils/shell-quote.d.ts +22 -0
- package/dist/utils/shell-quote.js +55 -0
- package/dist/utils/shell-quote.js.map +1 -0
- package/dist/utils/timeout.d.ts +1 -0
- package/dist/utils/timeout.js +18 -0
- package/dist/utils/timeout.js.map +1 -0
- package/dist/utils/truncate.d.ts +5 -0
- package/dist/utils/truncate.js +12 -0
- package/dist/utils/truncate.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/docs/AGENT_GUIDE.md +161 -0
- package/docs/CONFIGURATION.md +130 -0
- package/docs/COPY_SPECIALIZED_TEST_CHECKLIST.md +9 -0
- package/docs/HOP1_E2E_CHECKLIST.md +128 -0
- package/docs/HOP1_E2E_RESULTS.json +647 -0
- package/docs/PARADI_ACCESS_PLAN.md +272 -0
- package/docs/REMOTE_COPY_JOB_API.md +92 -0
- package/docs/TODO.md +66 -0
- package/docs/USER_GUIDE.zh-CN.md +93 -0
- package/docs/clients/claude-desktop.json.example +12 -0
- package/docs/clients/cursor-npx.json.example +12 -0
- package/docs/clients/cursor.json.example +12 -0
- package/docs/clients/vscode.json.example +12 -0
- package/package.json +65 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
|
|
3
|
+
* Domain errors for Hop1 ACCESS connectivity (environment vs business failure).
|
|
4
|
+
|
|
5
|
+
*
|
|
6
|
+
|
|
7
|
+
* phase=connect: TCP/SSH handshake / auth — may become ACCESS_UNAVAILABLE + neg-cache
|
|
8
|
+
|
|
9
|
+
* phase=stream: mid-session channel/exec — ECONNRESET/EPIPE stay retryable, not access-down
|
|
10
|
+
|
|
11
|
+
*/
|
|
12
|
+
export class AccessUnavailableError extends Error {
|
|
13
|
+
code = "ACCESS_UNAVAILABLE";
|
|
14
|
+
causeCode;
|
|
15
|
+
/** Always false today — Agents must not auto-retry access-down. */
|
|
16
|
+
retryable = false;
|
|
17
|
+
hints;
|
|
18
|
+
host;
|
|
19
|
+
port;
|
|
20
|
+
constructor(input) {
|
|
21
|
+
const hints = defaultHints(input.causeCode);
|
|
22
|
+
const message = buildMessage(input.causeCode, input.host, input.port, hints, input.detail);
|
|
23
|
+
super(message, input.cause !== undefined ? { cause: input.cause } : undefined);
|
|
24
|
+
this.name = "AccessUnavailableError";
|
|
25
|
+
this.causeCode = input.causeCode;
|
|
26
|
+
this.hints = hints;
|
|
27
|
+
this.host = input.host;
|
|
28
|
+
this.port = input.port;
|
|
29
|
+
}
|
|
30
|
+
toJSON() {
|
|
31
|
+
return {
|
|
32
|
+
reachable: false,
|
|
33
|
+
host: this.host,
|
|
34
|
+
port: this.port,
|
|
35
|
+
reason: this.causeCode,
|
|
36
|
+
code: this.code,
|
|
37
|
+
retryable: this.retryable,
|
|
38
|
+
message: this.message,
|
|
39
|
+
hints: this.hints,
|
|
40
|
+
auth: this.causeCode === "AUTH_FAILED" ? "failed" : undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function isAccessUnavailableError(error) {
|
|
45
|
+
return error instanceof AccessUnavailableError;
|
|
46
|
+
}
|
|
47
|
+
/** Exec/command wall-clock timeout — not an ACCESS connectivity failure. */
|
|
48
|
+
export function isCommandTimeoutError(error) {
|
|
49
|
+
return /Operation timed out after/i.test(errorMessage(error));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
|
|
53
|
+
* Whether the exec reconnect loop should try again (stream-phase only).
|
|
54
|
+
|
|
55
|
+
* Connect failures never enter this path — they become AccessUnavailableError first.
|
|
56
|
+
|
|
57
|
+
*/
|
|
58
|
+
export function shouldRetryBastionConnect(error) {
|
|
59
|
+
if (isAccessUnavailableError(error) || isCommandTimeoutError(error)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const code = errorCode(error);
|
|
63
|
+
// Mid-session drops: allow short reconnect.
|
|
64
|
+
if (code === "ECONNRESET" || code === "EPIPE") {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (code === "ETIMEDOUT" ||
|
|
68
|
+
code === "ECONNREFUSED" ||
|
|
69
|
+
code === "EHOSTUNREACH" ||
|
|
70
|
+
code === "ENETUNREACH" ||
|
|
71
|
+
code === "ENOTFOUND") {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const msg = errorMessage(error);
|
|
75
|
+
if (/Timed out while waiting for handshake|Ready timeout|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|ENOTFOUND|All configured authentication methods failed|Authentication failed|Permission denied \(publickey|password|keyboard-interactive\)/i.test(msg)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
|
|
82
|
+
* Map an error to AccessUnavailableError, or null if it is not an access-down signal.
|
|
83
|
+
|
|
84
|
+
* Connect-phase includes ECONNRESET/EPIPE; stream-phase does not (those stay retryable).
|
|
85
|
+
|
|
86
|
+
*/
|
|
87
|
+
export function toAccessUnavailableError(error, host, port, phase) {
|
|
88
|
+
if (isAccessUnavailableError(error)) {
|
|
89
|
+
return error;
|
|
90
|
+
}
|
|
91
|
+
if (isCommandTimeoutError(error)) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const code = errorCode(error);
|
|
95
|
+
const msg = errorMessage(error);
|
|
96
|
+
if (isSshAuthFailure(msg)) {
|
|
97
|
+
return new AccessUnavailableError({
|
|
98
|
+
causeCode: "AUTH_FAILED",
|
|
99
|
+
host,
|
|
100
|
+
port,
|
|
101
|
+
detail: msg,
|
|
102
|
+
cause: error,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (phase === "connect" && (code === "ECONNRESET" || code === "EPIPE")) {
|
|
106
|
+
return new AccessUnavailableError({
|
|
107
|
+
causeCode: "VPN_OR_NETWORK",
|
|
108
|
+
host,
|
|
109
|
+
port,
|
|
110
|
+
detail: msg,
|
|
111
|
+
cause: error,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (code === "ETIMEDOUT" ||
|
|
115
|
+
code === "ECONNREFUSED" ||
|
|
116
|
+
code === "EHOSTUNREACH" ||
|
|
117
|
+
code === "ENETUNREACH" ||
|
|
118
|
+
code === "ENOTFOUND" ||
|
|
119
|
+
/Timed out while waiting for handshake|Ready timeout|SSH closed before ready|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|getaddrinfo|connect ETIMEDOUT/i.test(msg)) {
|
|
120
|
+
return new AccessUnavailableError({
|
|
121
|
+
causeCode: "VPN_OR_NETWORK",
|
|
122
|
+
host,
|
|
123
|
+
port,
|
|
124
|
+
detail: msg,
|
|
125
|
+
cause: error,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (phase === "connect" && /Connection lost before handshake/i.test(msg)) {
|
|
129
|
+
return new AccessUnavailableError({
|
|
130
|
+
causeCode: "SSH_HANDSHAKE",
|
|
131
|
+
host,
|
|
132
|
+
port,
|
|
133
|
+
detail: msg,
|
|
134
|
+
cause: error,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
/** ssh2 auth failures only — not SFTP/file "Permission denied". */
|
|
140
|
+
function isSshAuthFailure(msg) {
|
|
141
|
+
return /All configured authentication methods failed|Authentication failed|Permission denied \(publickey|password|keyboard-interactive\)/i.test(msg);
|
|
142
|
+
}
|
|
143
|
+
function defaultHints(cause) {
|
|
144
|
+
switch (cause) {
|
|
145
|
+
case "VPN_OR_NETWORK":
|
|
146
|
+
return [
|
|
147
|
+
"检查本地 VPN 是否已连接",
|
|
148
|
+
"确认 ACCESS_HOST / ACCESS_PORT 是否正确",
|
|
149
|
+
"确认本机到 ACCESS 网络可达(路由/防火墙)",
|
|
150
|
+
];
|
|
151
|
+
case "AUTH_FAILED":
|
|
152
|
+
return [
|
|
153
|
+
"检查 ACCESS_USER / ACCESS_PASSWORD 或私钥配置",
|
|
154
|
+
"确认 SSH agent / ACCESS_PRIVATE_KEY_PATH 是否有效",
|
|
155
|
+
];
|
|
156
|
+
case "SSH_HANDSHAKE":
|
|
157
|
+
return ["检查 VPN 与 ACCESS 地址", "网络不稳定时可稍后重试,或先调用 access_check"];
|
|
158
|
+
default:
|
|
159
|
+
return ["先调用 access_check 诊断本地到 ACCESS 连通性", "确认 VPN 与凭据配置"];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function buildMessage(cause, host, port, hints, detail) {
|
|
163
|
+
const title = cause === "AUTH_FAILED"
|
|
164
|
+
? "ACCESS 认证失败"
|
|
165
|
+
: cause === "SSH_HANDSHAKE"
|
|
166
|
+
? "ACCESS SSH 握手失败"
|
|
167
|
+
: cause === "UNKNOWN"
|
|
168
|
+
? "ACCESS 连接失败"
|
|
169
|
+
: "无法连接 ACCESS(网络/VPN 可能不通)";
|
|
170
|
+
const lines = [
|
|
171
|
+
`${title}: ${host}:${port}`,
|
|
172
|
+
`原因分类: ${cause}`,
|
|
173
|
+
...hints.map((h, i) => `${i + 1}. ${h}`),
|
|
174
|
+
];
|
|
175
|
+
if (detail) {
|
|
176
|
+
lines.push(`详情: ${detail}`);
|
|
177
|
+
}
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
function errorCode(error) {
|
|
181
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
182
|
+
const c = error.code;
|
|
183
|
+
return typeof c === "string" ? c : undefined;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
function errorMessage(error) {
|
|
188
|
+
return error instanceof Error ? error.message : String(error);
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=bastion-errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bastion-errors.js","sourceRoot":"","sources":["../../src/ssh/bastion-errors.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AppConfig } from "../config.js";
|
|
2
|
+
import { type SftpCopyOptions, type SftpTransferResult } from "../utils/copy-transfer.js";
|
|
3
|
+
export type Hop1Direction = "upload" | "download";
|
|
4
|
+
export interface TransferLocalBastionParams {
|
|
5
|
+
direction: Hop1Direction;
|
|
6
|
+
localPath: string;
|
|
7
|
+
remotePath: string;
|
|
8
|
+
config: AppConfig;
|
|
9
|
+
options?: SftpCopyOptions;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Transfer a local file or directory to/from bastion over an independent SFTP connection.
|
|
13
|
+
*/
|
|
14
|
+
export declare function transferLocalBastion(params: TransferLocalBastionParams, execSha256: (remotePath: string, timeoutMs: number) => Promise<string>): Promise<SftpTransferResult>;
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hop1 local ↔ bastion transfers via ssh2-sftp-client (independent TCP).
|
|
3
|
+
* Directory = list + per-file put/get (not uploadDir/downloadDir).
|
|
4
|
+
*
|
|
5
|
+
* skipIfComplete gate strength = verify mode (no auto-upgrade to sha256).
|
|
6
|
+
* verify fail on skip → fallthrough rewrite (never throw-only).
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import SftpClient from "ssh2-sftp-client";
|
|
11
|
+
import { resolveAccessSshIdentity, resolveSshAuthOptions } from "../config.js";
|
|
12
|
+
import { toAccessUnavailableError } from "./bastion-errors.js";
|
|
13
|
+
import { createProgressReporter, createTransferId, DEFAULT_CONNECTION_TIMEOUT_MS, DEFAULT_PROGRESS_INTERVAL_MS, resolveEffectiveVerify, sha256File, throwIfAborted, transferSkipFields, } from "../utils/copy-transfer.js";
|
|
14
|
+
function posixJoin(...parts) {
|
|
15
|
+
return parts
|
|
16
|
+
.join("/")
|
|
17
|
+
.replace(/\\/g, "/")
|
|
18
|
+
.replace(/\/+/g, "/")
|
|
19
|
+
.replace(/\/$/, "") || "/";
|
|
20
|
+
}
|
|
21
|
+
function isAbortError(error, signal) {
|
|
22
|
+
return signal?.aborted === true || (error instanceof Error && error.message === "Copy aborted");
|
|
23
|
+
}
|
|
24
|
+
async function walkLocalFiles(root) {
|
|
25
|
+
const out = [];
|
|
26
|
+
async function walk(abs, rel) {
|
|
27
|
+
const st = await fs.stat(abs);
|
|
28
|
+
if (st.isFile()) {
|
|
29
|
+
out.push({ localPath: abs, remotePath: rel, size: st.size });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!st.isDirectory()) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const names = await fs.readdir(abs);
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
await walk(path.join(abs, name), rel ? `${rel}/${name}` : name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await walk(root, "");
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
async function walkRemoteFiles(sftp, root) {
|
|
44
|
+
const out = [];
|
|
45
|
+
async function walk(remoteAbs, rel) {
|
|
46
|
+
const list = await sftp.list(remoteAbs);
|
|
47
|
+
for (const item of list) {
|
|
48
|
+
const childRel = rel ? `${rel}/${item.name}` : item.name;
|
|
49
|
+
const childAbs = posixJoin(remoteAbs, item.name);
|
|
50
|
+
if (item.type === "d") {
|
|
51
|
+
await walk(childAbs, childRel);
|
|
52
|
+
}
|
|
53
|
+
else if (item.type === "-") {
|
|
54
|
+
out.push({ localPath: childRel, remotePath: childAbs, size: item.size });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
await walk(root, "");
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
async function connectSftp(config, connectionTimeoutMs) {
|
|
62
|
+
const auth = resolveSshAuthOptions(config);
|
|
63
|
+
const identity = resolveAccessSshIdentity(config);
|
|
64
|
+
const sftp = new SftpClient();
|
|
65
|
+
const connectOpts = {
|
|
66
|
+
host: identity.host,
|
|
67
|
+
port: identity.port,
|
|
68
|
+
username: identity.username,
|
|
69
|
+
readyTimeout: connectionTimeoutMs,
|
|
70
|
+
};
|
|
71
|
+
if (auth.privateKey) {
|
|
72
|
+
connectOpts.privateKey = auth.privateKey;
|
|
73
|
+
}
|
|
74
|
+
if (auth.password) {
|
|
75
|
+
connectOpts.password = auth.password;
|
|
76
|
+
}
|
|
77
|
+
if (auth.agent) {
|
|
78
|
+
connectOpts.agent = auth.agent;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await sftp.connect(connectOpts);
|
|
82
|
+
return sftp;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const unavailable = toAccessUnavailableError(error, config.accessHost, config.accessPort, "connect");
|
|
86
|
+
throw unavailable ?? error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function safeEnd(sftp) {
|
|
90
|
+
if (!sftp) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await sftp.end();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// ignore
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function remoteFileSize(sftp, remotePath) {
|
|
101
|
+
try {
|
|
102
|
+
const st = await sftp.stat(remotePath);
|
|
103
|
+
return st.size;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function remoteIsDirectory(sftp, remotePath) {
|
|
110
|
+
try {
|
|
111
|
+
await sftp.list(remotePath);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function verifyFile(sftp, localPath, remotePath, expectedSize, verify, execSha256) {
|
|
119
|
+
if (verify === "none") {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const localStat = await fs.stat(localPath);
|
|
123
|
+
const remoteSize = await remoteFileSize(sftp, remotePath);
|
|
124
|
+
if (localStat.size !== expectedSize) {
|
|
125
|
+
throw new Error(`Local size mismatch: local=${localStat.size} expected=${expectedSize}`);
|
|
126
|
+
}
|
|
127
|
+
if (remoteSize !== expectedSize) {
|
|
128
|
+
throw new Error(`Remote size mismatch: remote=${remoteSize} expected=${expectedSize}`);
|
|
129
|
+
}
|
|
130
|
+
if (verify === "size+sha256") {
|
|
131
|
+
const [localHash, remoteHash] = await Promise.all([
|
|
132
|
+
sha256File(localPath),
|
|
133
|
+
execSha256(remotePath),
|
|
134
|
+
]);
|
|
135
|
+
if (localHash !== remoteHash) {
|
|
136
|
+
throw new Error(`sha256 mismatch: local=${localHash} remote=${remoteHash}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Transfer a local file or directory to/from bastion over an independent SFTP connection.
|
|
142
|
+
*/
|
|
143
|
+
export async function transferLocalBastion(params, execSha256) {
|
|
144
|
+
const { direction, localPath, remotePath, config } = params;
|
|
145
|
+
const opts = params.options ?? {};
|
|
146
|
+
const transferId = opts.transferId ?? createTransferId();
|
|
147
|
+
const skipIfComplete = opts.skipIfComplete === true;
|
|
148
|
+
const verify = resolveEffectiveVerify(skipIfComplete, opts.verify);
|
|
149
|
+
const totalTimeoutMs = opts.totalTimeoutMs ?? config.defaultTimeoutMs;
|
|
150
|
+
const connectionTimeoutMs = opts.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS;
|
|
151
|
+
const progressIntervalMs = opts.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
|
|
152
|
+
const signal = opts.signal;
|
|
153
|
+
const sha = (p) => execSha256(p, totalTimeoutMs);
|
|
154
|
+
throwIfAborted(signal);
|
|
155
|
+
let sftp = null;
|
|
156
|
+
let abortHandler;
|
|
157
|
+
const run = async () => {
|
|
158
|
+
sftp = await connectSftp(config, connectionTimeoutMs);
|
|
159
|
+
throwIfAborted(signal);
|
|
160
|
+
abortHandler = () => {
|
|
161
|
+
void safeEnd(sftp);
|
|
162
|
+
};
|
|
163
|
+
if (signal) {
|
|
164
|
+
if (signal.aborted) {
|
|
165
|
+
throw new Error("Copy aborted");
|
|
166
|
+
}
|
|
167
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
168
|
+
}
|
|
169
|
+
const localStat = await fs.stat(localPath).catch(() => undefined);
|
|
170
|
+
if (direction === "upload") {
|
|
171
|
+
if (!localStat) {
|
|
172
|
+
throw new Error(`Local path not found: ${localPath}`);
|
|
173
|
+
}
|
|
174
|
+
if (localStat.isFile()) {
|
|
175
|
+
return uploadOneFile(sftp, localPath, remotePath, localStat.size);
|
|
176
|
+
}
|
|
177
|
+
if (localStat.isDirectory()) {
|
|
178
|
+
return uploadDirectory(sftp, localPath, remotePath);
|
|
179
|
+
}
|
|
180
|
+
throw new Error(`Unsupported local path type: ${localPath}`);
|
|
181
|
+
}
|
|
182
|
+
const isDir = await remoteIsDirectory(sftp, remotePath).catch(() => false);
|
|
183
|
+
if (isDir) {
|
|
184
|
+
return downloadDirectory(sftp, remotePath, localPath);
|
|
185
|
+
}
|
|
186
|
+
const remoteSize = await remoteFileSize(sftp, remotePath);
|
|
187
|
+
if (remoteSize === undefined) {
|
|
188
|
+
throw new Error(`Remote path not found: ${remotePath}`);
|
|
189
|
+
}
|
|
190
|
+
return downloadOneFile(sftp, remotePath, localPath, remoteSize);
|
|
191
|
+
};
|
|
192
|
+
function resultBase(bytes, totalBytes, skippedBytes, usedFastPath) {
|
|
193
|
+
return {
|
|
194
|
+
bytes,
|
|
195
|
+
totalBytes,
|
|
196
|
+
...transferSkipFields(skippedBytes, verify),
|
|
197
|
+
transferId,
|
|
198
|
+
verified: verify !== "none",
|
|
199
|
+
usedFastPath,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async function uploadOneFile(client, local, remote, size) {
|
|
203
|
+
const remoteDir = path.posix.dirname(remote.replace(/\\/g, "/"));
|
|
204
|
+
if (remoteDir && remoteDir !== ".") {
|
|
205
|
+
await client.mkdir(remoteDir, true);
|
|
206
|
+
}
|
|
207
|
+
if (skipIfComplete) {
|
|
208
|
+
try {
|
|
209
|
+
const existing = await remoteFileSize(client, remote);
|
|
210
|
+
if (existing === size) {
|
|
211
|
+
await verifyFile(client, local, remote, size, verify, sha);
|
|
212
|
+
opts.onProgress?.({
|
|
213
|
+
transferred: size,
|
|
214
|
+
total: size,
|
|
215
|
+
percent: 100,
|
|
216
|
+
skippedBytes: size,
|
|
217
|
+
});
|
|
218
|
+
return resultBase(size, size, size, false);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (isAbortError(error, signal)) {
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const reporter = createProgressReporter(progressIntervalMs, opts.onProgress, 0);
|
|
228
|
+
await client.fastPut(local, remote, {
|
|
229
|
+
step: (total_transferred, _chunk, total) => {
|
|
230
|
+
reporter.report(total_transferred, total);
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
reporter.flush(size, size);
|
|
234
|
+
await verifyFile(client, local, remote, size, verify, sha);
|
|
235
|
+
return resultBase(size, size, 0, true);
|
|
236
|
+
}
|
|
237
|
+
async function downloadOneFile(client, remote, local, size) {
|
|
238
|
+
await fs.mkdir(path.dirname(local), { recursive: true });
|
|
239
|
+
if (skipIfComplete) {
|
|
240
|
+
try {
|
|
241
|
+
const st = await fs.stat(local);
|
|
242
|
+
if (st.isFile() && st.size === size) {
|
|
243
|
+
await verifyFile(client, local, remote, size, verify, sha);
|
|
244
|
+
opts.onProgress?.({
|
|
245
|
+
transferred: size,
|
|
246
|
+
total: size,
|
|
247
|
+
percent: 100,
|
|
248
|
+
skippedBytes: size,
|
|
249
|
+
});
|
|
250
|
+
return resultBase(size, size, size, false);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (isAbortError(error, signal)) {
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (size === 0) {
|
|
260
|
+
await fs.writeFile(local, "");
|
|
261
|
+
return resultBase(0, 0, 0, false);
|
|
262
|
+
}
|
|
263
|
+
const reporter = createProgressReporter(progressIntervalMs, opts.onProgress, 0);
|
|
264
|
+
await client.fastGet(remote, local, {
|
|
265
|
+
step: (total_transferred, _chunk, total) => {
|
|
266
|
+
reporter.report(total_transferred, total);
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
reporter.flush(size, size);
|
|
270
|
+
await verifyFile(client, local, remote, size, verify, sha);
|
|
271
|
+
return resultBase(size, size, 0, true);
|
|
272
|
+
}
|
|
273
|
+
async function uploadDirectory(client, localRoot, remoteRoot) {
|
|
274
|
+
const files = await walkLocalFiles(localRoot);
|
|
275
|
+
const totalBytes = files.reduce((s, f) => s + f.size, 0);
|
|
276
|
+
await client.mkdir(remoteRoot.replace(/\\/g, "/"), true);
|
|
277
|
+
let transferred = 0;
|
|
278
|
+
let skippedBytes = 0;
|
|
279
|
+
const reporter = createProgressReporter(progressIntervalMs, opts.onProgress, 0);
|
|
280
|
+
for (const file of files) {
|
|
281
|
+
throwIfAborted(signal);
|
|
282
|
+
const remoteFile = posixJoin(remoteRoot, file.remotePath);
|
|
283
|
+
const remoteDir = path.posix.dirname(remoteFile);
|
|
284
|
+
await client.mkdir(remoteDir, true);
|
|
285
|
+
let skipped = false;
|
|
286
|
+
if (skipIfComplete) {
|
|
287
|
+
try {
|
|
288
|
+
const existing = await remoteFileSize(client, remoteFile);
|
|
289
|
+
if (existing === file.size) {
|
|
290
|
+
await verifyFile(client, file.localPath, remoteFile, file.size, verify, sha);
|
|
291
|
+
skipped = true;
|
|
292
|
+
skippedBytes += file.size;
|
|
293
|
+
transferred += file.size;
|
|
294
|
+
reporter.report(transferred, totalBytes);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (isAbortError(error, signal)) {
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!skipped) {
|
|
304
|
+
await client.fastPut(file.localPath, remoteFile, {
|
|
305
|
+
step: (n, _c, fileTotal) => {
|
|
306
|
+
reporter.report(transferred + n, totalBytes);
|
|
307
|
+
void fileTotal;
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
transferred += file.size;
|
|
311
|
+
reporter.report(transferred, totalBytes);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
reporter.flush(transferred, totalBytes);
|
|
315
|
+
const remoteFiles = await walkRemoteFiles(client, remoteRoot.replace(/\\/g, "/"));
|
|
316
|
+
await verifyDirectoryTree(client, files, remoteFiles, "upload", verify, sha);
|
|
317
|
+
return resultBase(transferred, totalBytes, skippedBytes, skippedBytes < totalBytes);
|
|
318
|
+
}
|
|
319
|
+
async function downloadDirectory(client, remoteRoot, localRoot) {
|
|
320
|
+
const remoteFiles = await walkRemoteFiles(client, remoteRoot.replace(/\\/g, "/"));
|
|
321
|
+
const totalBytes = remoteFiles.reduce((s, f) => s + f.size, 0);
|
|
322
|
+
await fs.mkdir(localRoot, { recursive: true });
|
|
323
|
+
let transferred = 0;
|
|
324
|
+
let skippedBytes = 0;
|
|
325
|
+
const reporter = createProgressReporter(progressIntervalMs, opts.onProgress, 0);
|
|
326
|
+
const localFiles = [];
|
|
327
|
+
for (const file of remoteFiles) {
|
|
328
|
+
throwIfAborted(signal);
|
|
329
|
+
const rel = file.localPath.replace(/\\/g, "/");
|
|
330
|
+
const localFile = path.join(localRoot, rel.replace(/\//g, path.sep));
|
|
331
|
+
await fs.mkdir(path.dirname(localFile), { recursive: true });
|
|
332
|
+
let skipped = false;
|
|
333
|
+
if (skipIfComplete) {
|
|
334
|
+
try {
|
|
335
|
+
const st = await fs.stat(localFile);
|
|
336
|
+
if (st.isFile() && st.size === file.size) {
|
|
337
|
+
await verifyFile(client, localFile, file.remotePath, file.size, verify, sha);
|
|
338
|
+
skipped = true;
|
|
339
|
+
skippedBytes += file.size;
|
|
340
|
+
transferred += file.size;
|
|
341
|
+
localFiles.push({
|
|
342
|
+
localPath: localFile,
|
|
343
|
+
remotePath: rel,
|
|
344
|
+
size: file.size,
|
|
345
|
+
});
|
|
346
|
+
reporter.report(transferred, totalBytes);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
if (isAbortError(error, signal)) {
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!skipped) {
|
|
356
|
+
await client.fastGet(file.remotePath, localFile, {
|
|
357
|
+
step: (n) => {
|
|
358
|
+
reporter.report(transferred + n, totalBytes);
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
transferred += file.size;
|
|
362
|
+
localFiles.push({
|
|
363
|
+
localPath: localFile,
|
|
364
|
+
remotePath: rel,
|
|
365
|
+
size: file.size,
|
|
366
|
+
});
|
|
367
|
+
reporter.report(transferred, totalBytes);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
reporter.flush(transferred, totalBytes);
|
|
371
|
+
await verifyDirectoryTree(client, localFiles, remoteFiles, "download", verify, sha);
|
|
372
|
+
return resultBase(transferred, totalBytes, skippedBytes, skippedBytes < totalBytes);
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const result = await Promise.race([
|
|
376
|
+
run(),
|
|
377
|
+
new Promise((_, reject) => {
|
|
378
|
+
const t = setTimeout(() => {
|
|
379
|
+
reject(new Error(`Hop1 total timeout after ${totalTimeoutMs}ms`));
|
|
380
|
+
}, totalTimeoutMs);
|
|
381
|
+
t.unref?.();
|
|
382
|
+
}),
|
|
383
|
+
]);
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
388
|
+
if (/total timeout/i.test(msg)) {
|
|
389
|
+
const timeoutErr = new Error(msg);
|
|
390
|
+
timeoutErr.code = "TIMEOUT";
|
|
391
|
+
throw timeoutErr;
|
|
392
|
+
}
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
if (signal && abortHandler) {
|
|
397
|
+
signal.removeEventListener("abort", abortHandler);
|
|
398
|
+
}
|
|
399
|
+
await safeEnd(sftp);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Path-aligned directory verify.
|
|
404
|
+
* upload localFiles: remotePath=rel, localPath=abs
|
|
405
|
+
* download localFiles: remotePath=rel, localPath=abs
|
|
406
|
+
* remoteFiles: localPath=rel, remotePath=abs
|
|
407
|
+
*/
|
|
408
|
+
async function verifyDirectoryTree(sftp, localFiles, remoteFiles, _mode, verify, execSha256) {
|
|
409
|
+
if (verify === "none") {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const remoteByRel = new Map(remoteFiles.map((f) => [f.localPath.replace(/\\/g, "/"), f]));
|
|
413
|
+
const localBytes = localFiles.reduce((s, f) => s + f.size, 0);
|
|
414
|
+
const remoteBytes = remoteFiles.reduce((s, f) => s + f.size, 0);
|
|
415
|
+
if (localFiles.length !== remoteFiles.length || localBytes !== remoteBytes) {
|
|
416
|
+
throw new Error(`Directory verify failed: local files=${localFiles.length} bytes=${localBytes} ` +
|
|
417
|
+
`remote files=${remoteFiles.length} bytes=${remoteBytes}`);
|
|
418
|
+
}
|
|
419
|
+
for (const lf of localFiles) {
|
|
420
|
+
const rel = lf.remotePath.replace(/\\/g, "/");
|
|
421
|
+
const rf = remoteByRel.get(rel);
|
|
422
|
+
if (!rf || rf.size !== lf.size) {
|
|
423
|
+
throw new Error(`Directory verify path/size mismatch for ${rel}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (verify === "size+sha256") {
|
|
427
|
+
for (const lf of localFiles) {
|
|
428
|
+
const rel = lf.remotePath.replace(/\\/g, "/");
|
|
429
|
+
const rf = remoteByRel.get(rel);
|
|
430
|
+
if (!rf) {
|
|
431
|
+
throw new Error(`Directory verify missing remote for ${rel}`);
|
|
432
|
+
}
|
|
433
|
+
await verifyFile(sftp, lf.localPath, rf.remotePath, lf.size, "size+sha256", execSha256);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
//# sourceMappingURL=hop1-sftp.js.map
|