huaweicloud-devkit 1.0.2-next.8 → 1.0.2
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/.agents/plugins/marketplace.json +1 -1
- package/LICENSE +201 -201
- package/README.md +71 -52
- package/README.zh-CN.md +71 -50
- package/bin/setup.cjs +0 -0
- package/package.json +9 -5
- package/plugins/huaweicloud-core/.claude-plugin/plugin.json +2 -2
- package/plugins/huaweicloud-core/.codex-plugin/plugin.json +2 -2
- package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +2 -2
- package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +30 -0
- package/plugins/huaweicloud-core/.mcp.json +1 -2
- package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +2 -3
- package/plugins/huaweicloud-core/hooks/huaweicloud-safety.py +159 -136
- package/plugins/huaweicloud-core/openclaw.plugin.json +20 -0
- package/plugins/huaweicloud-core/skills/huawei-cloud-find-skills/SKILL.md +1 -1
- package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +67 -20
- package/plugins/huaweicloud-core/skills/huaweicloud-capability-discovery/SKILL.md +11 -0
- package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +16 -0
- package/plugins/huaweicloud-core/skills/huaweicloud-core/SKILL.md +27 -24
- package/plugins/huaweicloud-core/skills/huaweicloud-core/references/select.md +12 -0
- package/plugins/huaweicloud-core/src/auth/agent-registration.mjs +79 -11
- package/plugins/huaweicloud-core/src/auth/credentials.mjs +65 -0
- package/plugins/huaweicloud-core/src/mcp-server.mjs +31 -4
- package/plugins/huaweicloud-core/src/proxy/proxy-agent.mjs +6 -1
- package/plugins/huaweicloud-core/src/sandbox/hdkitservice-api.mjs +7 -2
- package/plugins/huaweicloud-core/src/sandbox/hwlink-api.mjs +14 -4
- package/plugins/huaweicloud-core/src/sandbox/sandbox-file-server.py +45 -0
- package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +432 -10
- package/plugins/huaweicloud-core/src/setup-cli.mjs +581 -87
- package/plugins/huaweicloud-core/src/tools.mjs +225 -33
- package/plugins/huaweicloud-core/src/ws-exec/hwlink-exec-client.js +18 -4
- package/plugins/huaweicloud-core/src/ws-exec/hwlink-fair-queue.js +9 -0
- package/plugins/huaweicloud-core/src/ws-exec/hwlink-multiplexer.js +9 -0
- package/plugins/huaweicloud-core/src/ws-exec/hwlink-packet.js +9 -0
- package/plugins/huaweicloud-core/src/ws-exec/hwlink-terminal-channel.js +9 -0
- package/plugins/huaweicloud-core/src/ws-exec/hwlink-tunnel-channel.mjs +267 -0
- package/plugins/huaweicloud-core/src/ws-exec/index.js +3 -0
- package/plugins/huaweicloud-core/src/ws-exec/ws-exec-client.js +9 -5
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import hashlib, http.server, json, os, sys
|
|
3
|
+
class UploadHandler(http.server.BaseHTTPRequestHandler):
|
|
4
|
+
def do_POST(self):
|
|
5
|
+
if self.path != '/upload':
|
|
6
|
+
self.send_error(404)
|
|
7
|
+
return
|
|
8
|
+
target_path = self.headers.get('X-Target-Path', '/workspace/upload.tar.gz')
|
|
9
|
+
content_length = int(self.headers.get('Content-Length', 0))
|
|
10
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
11
|
+
md5 = hashlib.md5()
|
|
12
|
+
bytes_written = 0
|
|
13
|
+
with open(target_path, 'wb') as f:
|
|
14
|
+
remaining = content_length
|
|
15
|
+
while remaining > 0:
|
|
16
|
+
chunk = self.rfile.read(min(remaining, 65536))
|
|
17
|
+
if not chunk:
|
|
18
|
+
break
|
|
19
|
+
f.write(chunk)
|
|
20
|
+
md5.update(chunk)
|
|
21
|
+
bytes_written += len(chunk)
|
|
22
|
+
remaining -= len(chunk)
|
|
23
|
+
result = json.dumps({'ok': True, 'path': target_path, 'bytes': bytes_written, 'md5': md5.hexdigest()})
|
|
24
|
+
self.send_response(200)
|
|
25
|
+
self.send_header('Content-Type', 'application/json')
|
|
26
|
+
self.send_header('Content-Length', str(len(result)))
|
|
27
|
+
self.end_headers()
|
|
28
|
+
self.wfile.write(result.encode())
|
|
29
|
+
def do_GET(self):
|
|
30
|
+
if self.path == '/health':
|
|
31
|
+
body = b'ok'
|
|
32
|
+
self.send_response(200)
|
|
33
|
+
self.send_header('Content-Length', str(len(body)))
|
|
34
|
+
self.end_headers()
|
|
35
|
+
self.wfile.write(body)
|
|
36
|
+
else:
|
|
37
|
+
self.send_error(404)
|
|
38
|
+
def log_message(self, format, *args):
|
|
39
|
+
pass
|
|
40
|
+
if __name__ == '__main__':
|
|
41
|
+
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8888
|
|
42
|
+
token = sys.argv[2] if len(sys.argv) > 2 else ''
|
|
43
|
+
server = http.server.HTTPServer(('127.0.0.1', port), UploadHandler)
|
|
44
|
+
server.upload_token = token
|
|
45
|
+
server.serve_forever()
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { createConnection as netConnect } from 'node:net';
|
|
4
|
+
import { existsSync, readFileSync, statSync, mkdirSync, rmSync, createReadStream, appendFileSync } from 'node:fs';
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
|
+
import { join, dirname, basename } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
5
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
9
|
import { createConnection, getCredentials } from './hwlink-api.mjs';
|
|
7
10
|
import { getWebSocketImpl } from '../proxy/proxy-agent.mjs';
|
|
8
11
|
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
9
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
15
|
export const WS_EXEC_INDEX_URL = pathToFileURL(join(__dirname, '..', 'ws-exec', 'index.js')).href;
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
let currentWorkspaceId = process.env.HW_WORKSPACE_ID || null;
|
|
18
|
+
|
|
19
|
+
function setWorkspaceId(id) {
|
|
20
|
+
currentWorkspaceId = id;
|
|
21
|
+
process.env.HW_WORKSPACE_ID = id;
|
|
22
|
+
}
|
|
13
23
|
|
|
14
24
|
function resolveEnv() {
|
|
15
25
|
const env = { ...process.env };
|
|
@@ -77,6 +87,45 @@ async function getSession(workspaceId, username, timeoutMs) {
|
|
|
77
87
|
return session;
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
async function createTunnelSession(workspaceId, username, timeoutMs = 30000) {
|
|
91
|
+
const { ak, sk, securitytoken } = getCredentials();
|
|
92
|
+
const { wsUrl, source } = await createConnection(workspaceId, ak, sk, securitytoken);
|
|
93
|
+
const WebSocketImpl = await getWebSocketImpl(wsUrl);
|
|
94
|
+
|
|
95
|
+
const { HwlinkWebSocketMultiplexer } = await import(WS_EXEC_INDEX_URL);
|
|
96
|
+
const mux = new HwlinkWebSocketMultiplexer(wsUrl, source, { WebSocketImpl, protocol: 'devenv' });
|
|
97
|
+
|
|
98
|
+
await new Promise((resolve, reject) => {
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
clearInterval(interval);
|
|
101
|
+
reject(new Error('tunnel session WebSocket open timeout'));
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
const interval = setInterval(() => {
|
|
104
|
+
if (mux.readyState === 1) {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
clearInterval(interval);
|
|
107
|
+
resolve();
|
|
108
|
+
} else if (mux.readyState === 3) {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
clearInterval(interval);
|
|
111
|
+
reject(new Error('tunnel session WebSocket closed'));
|
|
112
|
+
}
|
|
113
|
+
}, 100);
|
|
114
|
+
mux.onClose = () => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
clearInterval(interval);
|
|
117
|
+
reject(new Error('tunnel session WebSocket closed'));
|
|
118
|
+
};
|
|
119
|
+
mux.onError = (err) => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
clearInterval(interval);
|
|
122
|
+
reject(err);
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return { mux, close: () => mux.close() };
|
|
127
|
+
}
|
|
128
|
+
|
|
80
129
|
export async function execOneShot(workspaceId, command, username, timeoutMs) {
|
|
81
130
|
const { ak, sk, securitytoken } = getCredentials();
|
|
82
131
|
const { wsUrl, source } = await createConnection(workspaceId, ak, sk, securitytoken);
|
|
@@ -99,7 +148,9 @@ export async function execWithSession(workspaceId, command, username, timeoutMs)
|
|
|
99
148
|
return await session.exec(command, { timeoutMs });
|
|
100
149
|
}
|
|
101
150
|
|
|
102
|
-
export const UPLOAD_CHUNK_SIZE =
|
|
151
|
+
export const UPLOAD_CHUNK_SIZE = 30000;
|
|
152
|
+
|
|
153
|
+
export const UPLOAD_BATCH_SIZE = 5;
|
|
103
154
|
|
|
104
155
|
export function splitBase64Chunks(base64, chunkSize = UPLOAD_CHUNK_SIZE) {
|
|
105
156
|
const chunks = [];
|
|
@@ -127,13 +178,21 @@ export async function uploadFileWithSession(workspaceId, localPath, remotePath,
|
|
|
127
178
|
throw new Error(`sandbox upload: failed to reset temp file: ${reset.stdout || reset.error || reset.exitCode}`);
|
|
128
179
|
}
|
|
129
180
|
|
|
130
|
-
for (
|
|
131
|
-
const
|
|
181
|
+
for (let batchStart = 0; batchStart < chunks.length; batchStart += UPLOAD_BATCH_SIZE) {
|
|
182
|
+
const batch = chunks.slice(batchStart, batchStart + UPLOAD_BATCH_SIZE);
|
|
183
|
+
const combinedChunk = batch.join('');
|
|
184
|
+
const batchNum = Math.floor(batchStart / UPLOAD_BATCH_SIZE) + 1;
|
|
185
|
+
const totalBatches = Math.ceil(chunks.length / UPLOAD_BATCH_SIZE);
|
|
186
|
+
const cmd = `printf '%s' '${combinedChunk}' >> "${tmp}"`;
|
|
187
|
+
const res = await execWithSession(workspaceId, cmd, username, timeoutMs);
|
|
132
188
|
if (res.exitCode !== 0) {
|
|
133
189
|
throw new Error(
|
|
134
|
-
`sandbox upload: failed writing
|
|
190
|
+
`sandbox upload: failed writing batch ${batchNum}/${totalBatches}: ${res.stdout || res.error || res.exitCode}`,
|
|
135
191
|
);
|
|
136
192
|
}
|
|
193
|
+
if (batchNum % 10 === 0 || batchNum === totalBatches) {
|
|
194
|
+
console.error(` upload progress: batch ${batchNum}/${totalBatches}`);
|
|
195
|
+
}
|
|
137
196
|
}
|
|
138
197
|
|
|
139
198
|
const decode = await execWithSession(
|
|
@@ -152,6 +211,10 @@ export async function uploadFileWithSession(workspaceId, localPath, remotePath,
|
|
|
152
211
|
let md5Verified = false;
|
|
153
212
|
if (verify.exitCode === 0) {
|
|
154
213
|
const remoteMd5 = String(verify.stdout || '')
|
|
214
|
+
// eslint-disable-next-line no-control-regex
|
|
215
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
216
|
+
// eslint-disable-next-line no-control-regex
|
|
217
|
+
.replace(/\x1b\][^\x07]*\x07/g, '')
|
|
155
218
|
.trim()
|
|
156
219
|
.split(/\s+/)[0];
|
|
157
220
|
md5Verified = remoteMd5 === expectedMd5;
|
|
@@ -173,6 +236,365 @@ export async function uploadFileWithSession(workspaceId, localPath, remotePath,
|
|
|
173
236
|
};
|
|
174
237
|
}
|
|
175
238
|
|
|
239
|
+
const SANDBOX_FILE_SERVER_SCRIPT = readFileSync(join(__dirname, 'sandbox-file-server.py'), 'utf8');
|
|
240
|
+
|
|
241
|
+
const TUNNEL_READY_TIMEOUT_MS = 30000;
|
|
242
|
+
const SERVER_HEALTH_MAX_RETRIES = 30;
|
|
243
|
+
const SERVER_HEALTH_INTERVAL_MS = 1000;
|
|
244
|
+
const UPLOAD_LOG_PATH = join(tmpdir(), 'sandbox-upload.log');
|
|
245
|
+
|
|
246
|
+
function uploadLog(message) {
|
|
247
|
+
const ts = new Date().toISOString();
|
|
248
|
+
const line = `[${ts}] ${message}\n`;
|
|
249
|
+
console.error(line.trimEnd());
|
|
250
|
+
try {
|
|
251
|
+
appendFileSync(UPLOAD_LOG_PATH, line);
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function generateUploadToken() {
|
|
256
|
+
return randomBytes(16).toString('hex');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function createTarGz(localDir, exclude = []) {
|
|
260
|
+
const archiveName = `${basename(localDir)}.tar.gz`;
|
|
261
|
+
const archiveDir = join(tmpdir(), `sandbox-upload-${Date.now()}`);
|
|
262
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
263
|
+
const archivePath = join(archiveDir, archiveName);
|
|
264
|
+
|
|
265
|
+
const args = [
|
|
266
|
+
...exclude.flatMap((p) => ['--exclude', p]),
|
|
267
|
+
'-czf',
|
|
268
|
+
archivePath,
|
|
269
|
+
'-C',
|
|
270
|
+
dirname(localDir),
|
|
271
|
+
basename(localDir),
|
|
272
|
+
];
|
|
273
|
+
await execFileAsync('tar', args);
|
|
274
|
+
return archivePath;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function computeMd5(filePath) {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
const hash = createHash('md5');
|
|
280
|
+
createReadStream(filePath)
|
|
281
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
282
|
+
.on('end', () => resolve(hash.digest('hex')))
|
|
283
|
+
.on('error', reject);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function cleanupLocalArchive(archivePath) {
|
|
288
|
+
try {
|
|
289
|
+
rmSync(dirname(archivePath), { recursive: true, force: true });
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function deployFileServer(workspaceId, username, port = 8888, token = '') {
|
|
294
|
+
const scriptPath = '/tmp/sandbox-file-server.py';
|
|
295
|
+
const pidFile = '/tmp/sandbox-file-server.pid';
|
|
296
|
+
uploadLog(`deployFileServer: killing old server (pidFile=${pidFile})`);
|
|
297
|
+
await execWithSession(workspaceId, `kill $(cat ${pidFile} 2>/dev/null) 2>/dev/null; rm -f ${pidFile}`, username);
|
|
298
|
+
const b64 = Buffer.from(SANDBOX_FILE_SERVER_SCRIPT).toString('base64');
|
|
299
|
+
uploadLog(`deployFileServer: writing script (${b64.length} b64 chars)`);
|
|
300
|
+
await execWithSession(workspaceId, `echo '${b64}' | base64 -d > ${scriptPath}`, username);
|
|
301
|
+
const cmd = token
|
|
302
|
+
? `python3 ${scriptPath} ${port} ${token} & echo $! > ${pidFile}`
|
|
303
|
+
: `python3 ${scriptPath} ${port} & echo $! > ${pidFile}`;
|
|
304
|
+
uploadLog(`deployFileServer: starting server on port ${port}`);
|
|
305
|
+
const startResult = await execWithSession(workspaceId, cmd, username);
|
|
306
|
+
uploadLog(
|
|
307
|
+
`deployFileServer: start result exitCode=${startResult.exitCode} stdout=${JSON.stringify(startResult.stdout?.slice(0, 200))}`,
|
|
308
|
+
);
|
|
309
|
+
return startResult;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function uploadViaTunnel(localPort, archivePath, archiveSize, archiveRemotePath, uploadToken, timeoutMs) {
|
|
313
|
+
const archiveBuffer = readFileSync(archivePath);
|
|
314
|
+
const headers = [
|
|
315
|
+
'POST /upload HTTP/1.1',
|
|
316
|
+
'Host: localhost',
|
|
317
|
+
'Content-Type: application/octet-stream',
|
|
318
|
+
`Content-Length: ${archiveSize}`,
|
|
319
|
+
`X-Target-Path: ${archiveRemotePath}`,
|
|
320
|
+
`X-Upload-Token: ${uploadToken}`,
|
|
321
|
+
'Connection: close',
|
|
322
|
+
'',
|
|
323
|
+
'',
|
|
324
|
+
].join('\r\n');
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
let settled = false;
|
|
327
|
+
const done = (fn) => {
|
|
328
|
+
if (!settled) {
|
|
329
|
+
settled = true;
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
sock.destroy();
|
|
332
|
+
fn();
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
const timer = setTimeout(() => done(() => reject(new Error(`upload timeout after ${timeoutMs}ms`))), timeoutMs);
|
|
336
|
+
const sock = netConnect({ host: '127.0.0.1', port: localPort }, () => {
|
|
337
|
+
sock.write(headers);
|
|
338
|
+
sock.write(archiveBuffer);
|
|
339
|
+
});
|
|
340
|
+
let respBuf = Buffer.alloc(0);
|
|
341
|
+
let contentLength = -1;
|
|
342
|
+
let headerEnd = -1;
|
|
343
|
+
sock.on('data', (chunk) => {
|
|
344
|
+
respBuf = Buffer.concat([respBuf, chunk]);
|
|
345
|
+
if (contentLength < 0) {
|
|
346
|
+
const resp = respBuf.toString();
|
|
347
|
+
headerEnd = resp.indexOf('\r\n\r\n');
|
|
348
|
+
if (headerEnd > 0) {
|
|
349
|
+
const headerBlock = resp.slice(0, headerEnd);
|
|
350
|
+
const clMatch = headerBlock.match(/Content-Length:\s*(\d+)/i);
|
|
351
|
+
if (clMatch) contentLength = parseInt(clMatch[1], 10);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (contentLength >= 0 && headerEnd > 0) {
|
|
355
|
+
const bodyReceived = respBuf.length - (headerEnd + 4);
|
|
356
|
+
if (bodyReceived >= contentLength) {
|
|
357
|
+
done(() => {
|
|
358
|
+
const resp = respBuf.toString();
|
|
359
|
+
const statusLine = resp.split('\r\n')[0] || '';
|
|
360
|
+
const statusCode = parseInt(statusLine.split(' ')[1], 10);
|
|
361
|
+
const body = resp.slice(headerEnd + 4, headerEnd + 4 + contentLength);
|
|
362
|
+
if (!statusCode || statusCode < 200 || statusCode >= 300) {
|
|
363
|
+
uploadLog(`uploadViaTunnel: POST failed with HTTP ${statusCode}: ${body.slice(0, 200)}`);
|
|
364
|
+
reject(new Error(`upload HTTP ${statusCode}: ${body}`));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
resolve(JSON.parse(body));
|
|
369
|
+
} catch (e) {
|
|
370
|
+
reject(new Error(`invalid JSON response: ${body.slice(0, 200)}`));
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
sock.on('close', () => {
|
|
377
|
+
done(() => {
|
|
378
|
+
const resp = respBuf.toString();
|
|
379
|
+
const statusLine = resp.split('\r\n')[0] || '';
|
|
380
|
+
const statusCode = parseInt(statusLine.split(' ')[1], 10);
|
|
381
|
+
const he = resp.indexOf('\r\n\r\n');
|
|
382
|
+
const body = he > 0 ? resp.slice(he + 4) : '';
|
|
383
|
+
if (!statusCode || statusCode < 200 || statusCode >= 300) {
|
|
384
|
+
uploadLog(`uploadViaTunnel: POST failed with HTTP ${statusCode}: ${body.slice(0, 200)}`);
|
|
385
|
+
reject(new Error(`upload HTTP ${statusCode}: ${body}`));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
resolve(JSON.parse(body));
|
|
390
|
+
} catch (e) {
|
|
391
|
+
reject(new Error(`invalid JSON response: ${body.slice(0, 200)}`));
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
sock.on('error', (err) => {
|
|
396
|
+
done(() => {
|
|
397
|
+
uploadLog(`uploadViaTunnel: socket error: ${err.message}`);
|
|
398
|
+
reject(err);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function waitForServerReady(localPort) {
|
|
405
|
+
for (let i = 0; i < SERVER_HEALTH_MAX_RETRIES; i++) {
|
|
406
|
+
try {
|
|
407
|
+
uploadLog(`waitForServerReady: attempt ${i + 1}, checking http://localhost:${localPort}/health`);
|
|
408
|
+
const ok = await new Promise((resolve) => {
|
|
409
|
+
let settled = false;
|
|
410
|
+
const done = (val) => {
|
|
411
|
+
if (!settled) {
|
|
412
|
+
settled = true;
|
|
413
|
+
sock.destroy();
|
|
414
|
+
resolve(val);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
const sock = netConnect({ host: '127.0.0.1', port: localPort }, () => {
|
|
418
|
+
sock.write('GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
|
|
419
|
+
});
|
|
420
|
+
let resp = '';
|
|
421
|
+
sock.on('data', (c) => {
|
|
422
|
+
resp += c.toString();
|
|
423
|
+
if (resp.includes('200 OK')) done(true);
|
|
424
|
+
});
|
|
425
|
+
sock.on('close', () => done(resp.includes('200 OK')));
|
|
426
|
+
sock.on('error', () => done(false));
|
|
427
|
+
setTimeout(() => done(false), 3000);
|
|
428
|
+
});
|
|
429
|
+
if (ok) {
|
|
430
|
+
uploadLog(`waitForServerReady: server ready on port ${localPort} after ${i} retries`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
uploadLog(`waitForServerReady: health check returned non-200 (retry ${i + 1})`);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
uploadLog(`waitForServerReady: health check failed: ${err.message} (retry ${i + 1})`);
|
|
436
|
+
}
|
|
437
|
+
await new Promise((r) => setTimeout(r, SERVER_HEALTH_INTERVAL_MS));
|
|
438
|
+
}
|
|
439
|
+
throw new Error(
|
|
440
|
+
`sandbox file server not ready after ${SERVER_HEALTH_MAX_RETRIES * SERVER_HEALTH_INTERVAL_MS}ms (log: ${UPLOAD_LOG_PATH})`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function cleanupFileServer(workspaceId, username) {
|
|
445
|
+
const pidFile = '/tmp/sandbox-file-server.pid';
|
|
446
|
+
const scriptPath = '/tmp/sandbox-file-server.py';
|
|
447
|
+
await execWithSession(workspaceId, `kill $(cat ${pidFile}) 2>/dev/null; rm -f ${pidFile} ${scriptPath}`, username);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function uploadViaHttpTunnel(workspaceId, archivePath, archiveRemotePath, username, timeoutMs, options) {
|
|
451
|
+
const sandboxPort = options.sandboxPort || 8888;
|
|
452
|
+
const uploadToken = generateUploadToken();
|
|
453
|
+
const archiveSize = statSync(archivePath).size;
|
|
454
|
+
|
|
455
|
+
uploadLog(
|
|
456
|
+
`uploadViaHttpTunnel: start (archive=${archivePath}, size=${archiveSize}, remotePath=${archiveRemotePath})`,
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
uploadLog(`uploadViaHttpTunnel: deploying file server on sandbox port ${sandboxPort}`);
|
|
460
|
+
await deployFileServer(workspaceId, username, sandboxPort, uploadToken);
|
|
461
|
+
|
|
462
|
+
uploadLog(`uploadViaHttpTunnel: creating dedicated tunnel session`);
|
|
463
|
+
const tunnelSession = await createTunnelSession(workspaceId, username);
|
|
464
|
+
|
|
465
|
+
uploadLog(`uploadViaHttpTunnel: creating tunnel channel (localPort=0, remotePort=${sandboxPort})`);
|
|
466
|
+
const { HwlinkTunnelChannel } = await import(WS_EXEC_INDEX_URL);
|
|
467
|
+
const tunnel = new HwlinkTunnelChannel({
|
|
468
|
+
localPort: 0,
|
|
469
|
+
remotePort: sandboxPort,
|
|
470
|
+
});
|
|
471
|
+
tunnel.attach(tunnelSession.mux);
|
|
472
|
+
|
|
473
|
+
uploadLog(`uploadViaHttpTunnel: waiting for tunnel ready (timeout=${TUNNEL_READY_TIMEOUT_MS}ms)`);
|
|
474
|
+
try {
|
|
475
|
+
await Promise.race([
|
|
476
|
+
tunnel.ready,
|
|
477
|
+
new Promise((_, reject) =>
|
|
478
|
+
setTimeout(
|
|
479
|
+
() => reject(new Error(`tunnel ready timeout after ${TUNNEL_READY_TIMEOUT_MS}ms`)),
|
|
480
|
+
TUNNEL_READY_TIMEOUT_MS,
|
|
481
|
+
),
|
|
482
|
+
),
|
|
483
|
+
]);
|
|
484
|
+
uploadLog(`uploadViaHttpTunnel: tunnel ready, localPort=${tunnel.localPort}`);
|
|
485
|
+
} catch (tunnelReadyError) {
|
|
486
|
+
uploadLog(`uploadViaHttpTunnel: TUNNEL READY FAILED: ${tunnelReadyError.message}`);
|
|
487
|
+
tunnel.close();
|
|
488
|
+
throw new Error(
|
|
489
|
+
`HTTP tunnel failed to establish: ${tunnelReadyError.message}. ` +
|
|
490
|
+
`This means the WebSocket port-forwarding channel to sandbox port ${sandboxPort} could not be opened. ` +
|
|
491
|
+
`Common causes: (1) Python file server not running on sandbox, (2) sandbox port ${sandboxPort} blocked, ` +
|
|
492
|
+
`(3) hwlink multiplexer channel rejected. ` +
|
|
493
|
+
`Diagnostic log: ${UPLOAD_LOG_PATH}`,
|
|
494
|
+
{ cause: tunnelReadyError },
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
uploadLog(`uploadViaHttpTunnel: waiting for server health on localhost:${tunnel.localPort}`);
|
|
500
|
+
await waitForServerReady(tunnel.localPort);
|
|
501
|
+
|
|
502
|
+
uploadLog(`uploadViaHttpTunnel: sending POST with ${archiveSize} bytes`);
|
|
503
|
+
const result = await uploadViaTunnel(
|
|
504
|
+
tunnel.localPort,
|
|
505
|
+
archivePath,
|
|
506
|
+
archiveSize,
|
|
507
|
+
archiveRemotePath,
|
|
508
|
+
uploadToken,
|
|
509
|
+
timeoutMs,
|
|
510
|
+
);
|
|
511
|
+
uploadLog(`uploadViaHttpTunnel: upload complete (bytes=${result.bytes}, md5=${result.md5})`);
|
|
512
|
+
return result;
|
|
513
|
+
} catch (uploadError) {
|
|
514
|
+
uploadLog(`uploadViaHttpTunnel: UPLOAD FAILED: ${uploadError.message}`);
|
|
515
|
+
throw uploadError;
|
|
516
|
+
} finally {
|
|
517
|
+
tunnel.close();
|
|
518
|
+
tunnelSession.close();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function uploadProjectWithSession(
|
|
523
|
+
workspaceId,
|
|
524
|
+
localDir,
|
|
525
|
+
remoteDir,
|
|
526
|
+
username = 'root',
|
|
527
|
+
timeoutMs = 300000,
|
|
528
|
+
options = {},
|
|
529
|
+
) {
|
|
530
|
+
if (!workspaceId) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
'sandbox upload project: workspace_id is required. ' +
|
|
533
|
+
'Set HW_WORKSPACE_ID env var or ensure huaweicloud_sandbox_connect was called first.',
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (!existsSync(localDir)) {
|
|
537
|
+
throw new Error(`sandbox upload project: local directory not found: ${localDir}`);
|
|
538
|
+
}
|
|
539
|
+
if (!statSync(localDir).isDirectory()) {
|
|
540
|
+
throw new Error(`sandbox upload project: path is not a directory: ${localDir}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const projectName = basename(localDir);
|
|
544
|
+
const targetParentDir = remoteDir || '/workspace';
|
|
545
|
+
const archiveRemotePath = `${targetParentDir}/${projectName}.tar.gz`;
|
|
546
|
+
|
|
547
|
+
const archivePath = await createTarGz(localDir, options.exclude);
|
|
548
|
+
const archiveSize = statSync(archivePath).size;
|
|
549
|
+
const expectedMd5 = await computeMd5(archivePath);
|
|
550
|
+
|
|
551
|
+
uploadLog(`uploadProject: ${localDir} -> ${archiveRemotePath} (archive=${archiveSize} bytes, md5=${expectedMd5})`);
|
|
552
|
+
|
|
553
|
+
let result;
|
|
554
|
+
try {
|
|
555
|
+
result = await uploadViaHttpTunnel(workspaceId, archivePath, archiveRemotePath, username, timeoutMs, options);
|
|
556
|
+
} catch (tunnelError) {
|
|
557
|
+
uploadLog(`uploadProject: HTTP tunnel upload failed: ${tunnelError.message}`);
|
|
558
|
+
uploadLog(`uploadProject: NOT falling back to base64 (removed). Rethrowing with diagnostics.`);
|
|
559
|
+
cleanupLocalArchive(archivePath);
|
|
560
|
+
throw new Error(
|
|
561
|
+
`sandbox upload failed: HTTP tunnel could not transfer the project archive. ` +
|
|
562
|
+
`Archive size: ${(archiveSize / 1024).toFixed(1)}KB. ` +
|
|
563
|
+
`Root cause: ${tunnelError.message}. ` +
|
|
564
|
+
`Diagnostic log: ${UPLOAD_LOG_PATH}`,
|
|
565
|
+
{ cause: tunnelError },
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (options.verify !== false && result.md5 && result.md5 !== expectedMd5) {
|
|
570
|
+
throw new Error(`md5 mismatch: expected ${expectedMd5}, got ${result.md5}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (options.extract !== false) {
|
|
574
|
+
await execWithSession(
|
|
575
|
+
workspaceId,
|
|
576
|
+
`mkdir -p "${targetParentDir}" && tar -xzf "${archiveRemotePath}" -C "${targetParentDir}" && rm -f "${archiveRemotePath}"`,
|
|
577
|
+
username,
|
|
578
|
+
timeoutMs,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
await cleanupFileServer(workspaceId, username);
|
|
584
|
+
} catch {}
|
|
585
|
+
cleanupLocalArchive(archivePath);
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
ok: true,
|
|
589
|
+
localDir,
|
|
590
|
+
remotePath: options.extract !== false ? `${targetParentDir}/${projectName}` : archiveRemotePath,
|
|
591
|
+
bytes: result.bytes || 0,
|
|
592
|
+
md5: result.md5 || expectedMd5,
|
|
593
|
+
md5Verified: result.md5 ? result.md5 === expectedMd5 : true,
|
|
594
|
+
extracted: options.extract !== false,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
176
598
|
export async function closeSession(workspaceId, username) {
|
|
177
599
|
const key = `${workspaceId}:${username}`;
|
|
178
600
|
const session = sessions.get(key);
|
|
@@ -193,4 +615,4 @@ export async function closeAllSessions() {
|
|
|
193
615
|
}
|
|
194
616
|
}
|
|
195
617
|
|
|
196
|
-
export {
|
|
618
|
+
export { currentWorkspaceId, setWorkspaceId, runNodeExec };
|