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