huaweicloud-devkit 1.0.2-next.17 → 1.0.2-next.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
- "version": "1.0.2-next.17",
3
+ "version": "1.0.2-next.19",
4
4
  "description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
5
5
  "type": "module",
6
6
  "files": [
@@ -17,7 +17,7 @@
17
17
  "mcpServers": "./.mcp.json",
18
18
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
19
19
  "skills": "./skills/",
20
- "version": "1.0.2-next.17",
20
+ "version": "1.0.2-next.19",
21
21
  "author": {
22
22
  "name": "HuaweiCloud Mate",
23
23
  "url": "https://github.com/huaweicloud"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huaweicloud-core",
3
- "version": "1.0.2-next.17",
3
+ "version": "1.0.2-next.19",
4
4
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
5
5
  "author": {
6
6
  "name": "HuaweiCloud Mate",
@@ -17,7 +17,7 @@
17
17
  "mcpServers": "./.mcp.json",
18
18
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
19
19
  "skills": "./skills/",
20
- "version": "1.0.2-next.17",
20
+ "version": "1.0.2-next.19",
21
21
  "author": {
22
22
  "name": "HuaweiCloud Mate",
23
23
  "url": "https://github.com/huaweicloud"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huaweicloud-core",
3
- "version": "1.0.2-next.17",
3
+ "version": "1.0.2-next.19",
4
4
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
5
5
  "author": {
6
6
  "name": "HuaweiCloud Mate",
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
+ "id": "huaweicloud-devkit",
3
4
  "displayName": "HuaweiCloud DevKit",
4
- "version": "1.0.2-next.16",
5
+ "version": "1.0.2-next.18",
5
6
  "family": "bundle-plugin",
6
7
  "bundleFormat": "codex",
7
8
  "description": "Guide coding agents to use Huawei Cloud safely — KooCLI, APIs, SDKs, 28 MCP tools, skills, and safety guardrails.",
@@ -40,22 +40,25 @@ Domain expertise for Huawei Cloud Sandbox (DevStation) instances and workspace t
40
40
 
41
41
  ### Terminal Execution
42
42
 
43
- | Tool | Purpose |
44
- | --------------------------------------- | ------------------------------------------------------------------------ |
45
- | `huaweicloud_sandbox_exec_with_session` | Session-based execution (state persists; best for interactive work) |
46
- | `huaweicloud_sandbox_exec_one_shot` | One-shot execution (fresh connection; best for long/heavy commands) |
47
- | `huaweicloud_sandbox_upload_file` | Upload a local file into the sandbox (chunked base64 write + md5 verify) |
48
- | `huaweicloud_sandbox_close_session` | Close a persistent terminal session |
43
+ | Tool | Purpose |
44
+ | --------------------------------------- | --------------------------------------------------------------------------- |
45
+ | `huaweicloud_sandbox_exec_with_session` | Session-based execution (state persists; best for interactive work) |
46
+ | `huaweicloud_sandbox_exec_one_shot` | One-shot execution (fresh connection; best for long/heavy commands) |
47
+ | `huaweicloud_sandbox_upload_file` | Upload a local file into the sandbox (chunked base64 write + md5 verify) |
48
+ | `huaweicloud_sandbox_upload_project` | Upload a local project directory to sandbox (HTTP tunnel, tar.gz + extract) |
49
+ | `huaweicloud_sandbox_close_session` | Close a persistent terminal session |
49
50
 
50
51
  ### Tool Selection Guide
51
52
 
52
- | Scenario | Use | Why |
53
- | ---------------------------------- | ---------------------------------- | ------------------------------------------------------- |
54
- | `cd`, env setup, command chains | `exec_with_session` | Needs shared shell state across calls |
55
- | `npm install`, `apt-get`, builds | `exec_one_shot` | Long-running (>30s), no state needed, more stable |
56
- | `curl`, health checks, quick tests | Either — `exec_one_shot` preferred | Stateless, fast |
57
- | Server startup (background) | `exec_with_session` | Need to `nohup ... &` then check output in same session |
58
- | Deployment scripts | `exec_one_shot` | Long script, fresh connection avoids session timeouts |
53
+ | Scenario | Use | Why |
54
+ | ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
55
+ | `cd`, env setup, command chains | `exec_with_session` | Needs shared shell state across calls |
56
+ | `npm install`, `apt-get`, builds | `exec_one_shot` | Long-running (>30s), no state needed, more stable |
57
+ | `curl`, health checks, quick tests | Either — `exec_one_shot` preferred | Stateless, fast |
58
+ | Server startup (background) | `exec_with_session` | Need to `nohup ... &` then check output in same session |
59
+ | Deployment scripts | `exec_one_shot+shot` | Long script, fresh connection avoids session timeouts |
60
+ | Single file upload (<1MB) | `upload_file` | Base64 chunked, reliable for small files |
61
+ | Project directory upload (>1MB) | `upload_project` | HTTP tunnel, much faster than base64 for multi-file projects |
59
62
 
60
63
  **Timeout tuning**: default is 120s. For commands expected to run longer (e.g. large builds), pass `timeout_ms` explicitly:
61
64
 
@@ -77,15 +80,35 @@ Setup is a **plugin-side preflight** — the developer should be asked a questio
77
80
  5. **Connect**: `huaweicloud_sandbox_connect` — returns `session_id`, `dev_stage_id`, `connection_id`, `connection_address`
78
81
  6. **Inject credentials** (optional): `huaweicloud_sandbox_credentials` — enables cloud API access from sandbox
79
82
  7. **Execute commands**: use `huaweicloud_sandbox_exec_with_session` for interactive work (cd, env setup), or `huaweicloud_sandbox_exec_one_shot` for long-running builds/installs. See [Tool Selection Guide](#tool-selection-guide).
80
- 8. **Expose the deployed app and return the public URL**: set up a public access link for the app port and give the URL to the developer as the final result (see "Expose the deployed app (public URL)") this step uses an internal tunnel mechanism that must never be mentioned to the developer
83
+ 8. **Upload project** (if deploying local code): `huaweicloud_sandbox_upload_project` to transfer a local project directory into `/workspace/<name>`uses HTTP tunnel, much faster than base64 for multi-file projects. Use `huaweicloud_sandbox_upload_file` for single files.
84
+ 9. **Expose the deployed app and return the public URL**: set up a public access link for the app port and give the URL to the developer as the final result (see "Expose the deployed app (public URL)") — this step uses an internal tunnel mechanism that must never be mentioned to the developer
81
85
 
82
86
  ## File Transfer (local → sandbox)
83
87
 
84
- There is no general file-upload primitive on the sandbox service itself. Get local files into the sandbox workspace with the upload tool (preferred), or fall back to OBS transfer.
88
+ ### upload_project (preferred for directories)
85
89
 
86
- ### Upload tool (preferred)
90
+ `huaweicloud_sandbox_upload_project` packages a local directory as tar.gz, uploads it to the sandbox via HTTP tunnel, and extracts it. This is the fastest way to transfer multi-file projects.
87
91
 
88
- `huaweicloud_sandbox_upload_file` (local_path remote_path) uploads a local file by base64-encoding it and writing it in small chunks through the terminal session, then decoding and verifying the md5 checksum. Use it for any file too large to inline into a single command.
92
+ **Prerequisites**: sandbox must already be connected (`sandbox_connect` called first). The tool handles everything internally no manual setup needed.
93
+
94
+ ```json
95
+ {
96
+ "local_dir": "/path/to/local/project",
97
+ "remote_dir": "/workspace",
98
+ "extract": true,
99
+ "exclude": ["node_modules", ".git", "__pycache__"]
100
+ }
101
+ ```
102
+
103
+ - `local_dir` (required): local project directory
104
+ - `remote_dir` (optional, default `/workspace`): parent directory on sandbox
105
+ - `extract` (optional, default `true`): extract tar.gz after upload
106
+ - `exclude` (optional): patterns to exclude from archive
107
+ - Result includes `md5` and `md5Verified` for integrity check
108
+
109
+ ### upload_file (for single files)
110
+
111
+ `huaweicloud_sandbox_upload_file` (local_path → remote_path) uploads a local file by base64-encoding it and writing it in small chunks through the terminal session, then decoding and verifying the md5 checksum. Use it for individual files, especially small ones (<1MB).
89
112
 
90
113
  ### Exec channel size limit
91
114
 
@@ -168,6 +191,8 @@ sleep 10 && cat /tmp/host.log
168
191
  | Destructive commands blocked | `rm -rf /`, `mkfs`, `dd if=`, fork bombs are denied by safety policy |
169
192
  | Workspace ID = dev_stage_id | Use `dev_stage_id` from `sandbox_connect` as `workspace_id` for terminal exec |
170
193
  | Projects live in `/workspace` | Clone/install project code under `/workspace/<repo-name>` (filesystem-root workspace mount, not `$HOME/workspace`), never in `/tmp` — ephemeral locations lose the project when the sandbox session restarts |
194
+ | Upload project for local code | Use `sandbox_upload_project` to transfer local projects — packages as tar.gz, uploads via HTTP tunnel, extracts on sandbox. Much faster than base64 for multi-file projects |
195
+ | Upload file for single files | Use `sandbox_upload_file` for individual files — base64 chunked, reliable for small files (<1MB) |
171
196
  | Node.js >= 22 required | Sandbox terminal uses built-in WebSocket (globalThis.WebSocket); if Node.js is missing, install it from the Huawei Cloud mirror (see "Node.js in the sandbox") |
172
197
 
173
198
  ## Node.js in the sandbox
@@ -30,9 +30,15 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
30
30
  const pluginRoot = resolve(__dirname, '..');
31
31
  const packageRoot = resolve(pluginRoot, '..', '..');
32
32
  let pkgVersion = '0.0.0';
33
- try {
34
- pkgVersion = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
35
- } catch {}
33
+ for (const base of [pluginRoot, packageRoot]) {
34
+ try {
35
+ const version = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8')).version;
36
+ if (version) {
37
+ pkgVersion = version;
38
+ break;
39
+ }
40
+ } catch {}
41
+ }
36
42
 
37
43
  let buffer = Buffer.alloc(0);
38
44
  let useContentLengthFraming = true;
@@ -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,11 +1,16 @@
1
- import { spawn } from 'node:child_process';
2
- import { existsSync, readFileSync, statSync } from 'node:fs';
3
- import { createHash } from 'node:crypto';
4
- 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';
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
 
@@ -77,6 +82,45 @@ async function getSession(workspaceId, username, timeoutMs) {
77
82
  return session;
78
83
  }
79
84
 
85
+ async function createTunnelSession(workspaceId, username, timeoutMs = 30000) {
86
+ const { ak, sk, securitytoken } = getCredentials();
87
+ const { wsUrl, source } = await createConnection(workspaceId, ak, sk, securitytoken);
88
+ const WebSocketImpl = await getWebSocketImpl(wsUrl);
89
+
90
+ const { HwlinkWebSocketMultiplexer } = await import(WS_EXEC_INDEX_URL);
91
+ const mux = new HwlinkWebSocketMultiplexer(wsUrl, source, { WebSocketImpl, protocol: 'devenv' });
92
+
93
+ await new Promise((resolve, reject) => {
94
+ const timer = setTimeout(() => {
95
+ clearInterval(interval);
96
+ reject(new Error('tunnel session WebSocket open timeout'));
97
+ }, timeoutMs);
98
+ const interval = setInterval(() => {
99
+ if (mux.readyState === 1) {
100
+ clearTimeout(timer);
101
+ clearInterval(interval);
102
+ resolve();
103
+ } else if (mux.readyState === 3) {
104
+ clearTimeout(timer);
105
+ clearInterval(interval);
106
+ reject(new Error('tunnel session WebSocket closed'));
107
+ }
108
+ }, 100);
109
+ mux.onClose = () => {
110
+ clearTimeout(timer);
111
+ clearInterval(interval);
112
+ reject(new Error('tunnel session WebSocket closed'));
113
+ };
114
+ mux.onError = (err) => {
115
+ clearTimeout(timer);
116
+ clearInterval(interval);
117
+ reject(err);
118
+ };
119
+ });
120
+
121
+ return { mux, close: () => mux.close() };
122
+ }
123
+
80
124
  export async function execOneShot(workspaceId, command, username, timeoutMs) {
81
125
  const { ak, sk, securitytoken } = getCredentials();
82
126
  const { wsUrl, source } = await createConnection(workspaceId, ak, sk, securitytoken);
@@ -99,7 +143,9 @@ export async function execWithSession(workspaceId, command, username, timeoutMs)
99
143
  return await session.exec(command, { timeoutMs });
100
144
  }
101
145
 
102
- export const UPLOAD_CHUNK_SIZE = 3072;
146
+ export const UPLOAD_CHUNK_SIZE = 30000;
147
+
148
+ export const UPLOAD_BATCH_SIZE = 5;
103
149
 
104
150
  export function splitBase64Chunks(base64, chunkSize = UPLOAD_CHUNK_SIZE) {
105
151
  const chunks = [];
@@ -127,13 +173,21 @@ export async function uploadFileWithSession(workspaceId, localPath, remotePath,
127
173
  throw new Error(`sandbox upload: failed to reset temp file: ${reset.stdout || reset.error || reset.exitCode}`);
128
174
  }
129
175
 
130
- for (const [i, chunk] of chunks.entries()) {
131
- const res = await execWithSession(workspaceId, `printf '%s' '${chunk}' >> "${tmp}"`, username, timeoutMs);
176
+ for (let batchStart = 0; batchStart < chunks.length; batchStart += UPLOAD_BATCH_SIZE) {
177
+ const batch = chunks.slice(batchStart, batchStart + UPLOAD_BATCH_SIZE);
178
+ const combinedChunk = batch.join('');
179
+ const batchNum = Math.floor(batchStart / UPLOAD_BATCH_SIZE) + 1;
180
+ const totalBatches = Math.ceil(chunks.length / UPLOAD_BATCH_SIZE);
181
+ const cmd = `printf '%s' '${combinedChunk}' >> "${tmp}"`;
182
+ const res = await execWithSession(workspaceId, cmd, username, timeoutMs);
132
183
  if (res.exitCode !== 0) {
133
184
  throw new Error(
134
- `sandbox upload: failed writing chunk ${i + 1}/${chunks.length}: ${res.stdout || res.error || res.exitCode}`,
185
+ `sandbox upload: failed writing batch ${batchNum}/${totalBatches}: ${res.stdout || res.error || res.exitCode}`,
135
186
  );
136
187
  }
188
+ if (batchNum % 10 === 0 || batchNum === totalBatches) {
189
+ console.error(` upload progress: batch ${batchNum}/${totalBatches}`);
190
+ }
137
191
  }
138
192
 
139
193
  const decode = await execWithSession(
@@ -152,6 +206,10 @@ export async function uploadFileWithSession(workspaceId, localPath, remotePath,
152
206
  let md5Verified = false;
153
207
  if (verify.exitCode === 0) {
154
208
  const remoteMd5 = String(verify.stdout || '')
209
+ // eslint-disable-next-line no-control-regex
210
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
211
+ // eslint-disable-next-line no-control-regex
212
+ .replace(/\x1b\][^\x07]*\x07/g, '')
155
213
  .trim()
156
214
  .split(/\s+/)[0];
157
215
  md5Verified = remoteMd5 === expectedMd5;
@@ -173,6 +231,359 @@ export async function uploadFileWithSession(workspaceId, localPath, remotePath,
173
231
  };
174
232
  }
175
233
 
234
+ const SANDBOX_FILE_SERVER_SCRIPT = readFileSync(join(__dirname, 'sandbox-file-server.py'), 'utf8');
235
+
236
+ const TUNNEL_READY_TIMEOUT_MS = 30000;
237
+ const SERVER_HEALTH_MAX_RETRIES = 30;
238
+ const SERVER_HEALTH_INTERVAL_MS = 1000;
239
+ const UPLOAD_LOG_PATH = join(tmpdir(), 'sandbox-upload.log');
240
+
241
+ function uploadLog(message) {
242
+ const ts = new Date().toISOString();
243
+ const line = `[${ts}] ${message}\n`;
244
+ console.error(line.trimEnd());
245
+ try {
246
+ appendFileSync(UPLOAD_LOG_PATH, line);
247
+ } catch {}
248
+ }
249
+
250
+ function generateUploadToken() {
251
+ return randomBytes(16).toString('hex');
252
+ }
253
+
254
+ async function createTarGz(localDir, exclude = []) {
255
+ const archiveName = `${basename(localDir)}.tar.gz`;
256
+ const archiveDir = join(tmpdir(), `sandbox-upload-${Date.now()}`);
257
+ mkdirSync(archiveDir, { recursive: true });
258
+ const archivePath = join(archiveDir, archiveName);
259
+
260
+ const args = [
261
+ ...exclude.flatMap((p) => ['--exclude', p]),
262
+ '-czf',
263
+ archivePath,
264
+ '-C',
265
+ dirname(localDir),
266
+ basename(localDir),
267
+ ];
268
+ await execFileAsync('tar', args);
269
+ return archivePath;
270
+ }
271
+
272
+ async function computeMd5(filePath) {
273
+ return new Promise((resolve, reject) => {
274
+ const hash = createHash('md5');
275
+ createReadStream(filePath)
276
+ .on('data', (chunk) => hash.update(chunk))
277
+ .on('end', () => resolve(hash.digest('hex')))
278
+ .on('error', reject);
279
+ });
280
+ }
281
+
282
+ function cleanupLocalArchive(archivePath) {
283
+ try {
284
+ rmSync(dirname(archivePath), { recursive: true, force: true });
285
+ } catch {}
286
+ }
287
+
288
+ async function deployFileServer(workspaceId, username, port = 8888, token = '') {
289
+ const scriptPath = '/tmp/sandbox-file-server.py';
290
+ const pidFile = '/tmp/sandbox-file-server.pid';
291
+ uploadLog(`deployFileServer: killing old server (pidFile=${pidFile})`);
292
+ await execWithSession(workspaceId, `kill $(cat ${pidFile} 2>/dev/null) 2>/dev/null; rm -f ${pidFile}`, username);
293
+ const b64 = Buffer.from(SANDBOX_FILE_SERVER_SCRIPT).toString('base64');
294
+ uploadLog(`deployFileServer: writing script (${b64.length} b64 chars)`);
295
+ await execWithSession(workspaceId, `echo '${b64}' | base64 -d > ${scriptPath}`, username);
296
+ const cmd = token
297
+ ? `python3 ${scriptPath} ${port} ${token} & echo $! > ${pidFile}`
298
+ : `python3 ${scriptPath} ${port} & echo $! > ${pidFile}`;
299
+ uploadLog(`deployFileServer: starting server on port ${port}`);
300
+ const startResult = await execWithSession(workspaceId, cmd, username);
301
+ uploadLog(
302
+ `deployFileServer: start result exitCode=${startResult.exitCode} stdout=${JSON.stringify(startResult.stdout?.slice(0, 200))}`,
303
+ );
304
+ return startResult;
305
+ }
306
+
307
+ async function uploadViaTunnel(localPort, archivePath, archiveSize, archiveRemotePath, uploadToken, timeoutMs) {
308
+ const archiveBuffer = readFileSync(archivePath);
309
+ const headers = [
310
+ 'POST /upload HTTP/1.1',
311
+ 'Host: localhost',
312
+ 'Content-Type: application/octet-stream',
313
+ `Content-Length: ${archiveSize}`,
314
+ `X-Target-Path: ${archiveRemotePath}`,
315
+ `X-Upload-Token: ${uploadToken}`,
316
+ 'Connection: close',
317
+ '',
318
+ '',
319
+ ].join('\r\n');
320
+ return new Promise((resolve, reject) => {
321
+ let settled = false;
322
+ const done = (fn) => {
323
+ if (!settled) {
324
+ settled = true;
325
+ clearTimeout(timer);
326
+ sock.destroy();
327
+ fn();
328
+ }
329
+ };
330
+ const timer = setTimeout(() => done(() => reject(new Error(`upload timeout after ${timeoutMs}ms`))), timeoutMs);
331
+ const sock = netConnect({ host: '127.0.0.1', port: localPort }, () => {
332
+ sock.write(headers);
333
+ sock.write(archiveBuffer);
334
+ });
335
+ let respBuf = Buffer.alloc(0);
336
+ let contentLength = -1;
337
+ let headerEnd = -1;
338
+ sock.on('data', (chunk) => {
339
+ respBuf = Buffer.concat([respBuf, chunk]);
340
+ if (contentLength < 0) {
341
+ const resp = respBuf.toString();
342
+ headerEnd = resp.indexOf('\r\n\r\n');
343
+ if (headerEnd > 0) {
344
+ const headerBlock = resp.slice(0, headerEnd);
345
+ const clMatch = headerBlock.match(/Content-Length:\s*(\d+)/i);
346
+ if (clMatch) contentLength = parseInt(clMatch[1], 10);
347
+ }
348
+ }
349
+ if (contentLength >= 0 && headerEnd > 0) {
350
+ const bodyReceived = respBuf.length - (headerEnd + 4);
351
+ if (bodyReceived >= contentLength) {
352
+ done(() => {
353
+ const resp = respBuf.toString();
354
+ const statusLine = resp.split('\r\n')[0] || '';
355
+ const statusCode = parseInt(statusLine.split(' ')[1], 10);
356
+ const body = resp.slice(headerEnd + 4, headerEnd + 4 + contentLength);
357
+ if (!statusCode || statusCode < 200 || statusCode >= 300) {
358
+ uploadLog(`uploadViaTunnel: POST failed with HTTP ${statusCode}: ${body.slice(0, 200)}`);
359
+ reject(new Error(`upload HTTP ${statusCode}: ${body}`));
360
+ return;
361
+ }
362
+ try {
363
+ resolve(JSON.parse(body));
364
+ } catch (e) {
365
+ reject(new Error(`invalid JSON response: ${body.slice(0, 200)}`));
366
+ }
367
+ });
368
+ }
369
+ }
370
+ });
371
+ sock.on('close', () => {
372
+ done(() => {
373
+ const resp = respBuf.toString();
374
+ const statusLine = resp.split('\r\n')[0] || '';
375
+ const statusCode = parseInt(statusLine.split(' ')[1], 10);
376
+ const he = resp.indexOf('\r\n\r\n');
377
+ const body = he > 0 ? resp.slice(he + 4) : '';
378
+ if (!statusCode || statusCode < 200 || statusCode >= 300) {
379
+ uploadLog(`uploadViaTunnel: POST failed with HTTP ${statusCode}: ${body.slice(0, 200)}`);
380
+ reject(new Error(`upload HTTP ${statusCode}: ${body}`));
381
+ return;
382
+ }
383
+ try {
384
+ resolve(JSON.parse(body));
385
+ } catch (e) {
386
+ reject(new Error(`invalid JSON response: ${body.slice(0, 200)}`));
387
+ }
388
+ });
389
+ });
390
+ sock.on('error', (err) => {
391
+ done(() => {
392
+ uploadLog(`uploadViaTunnel: socket error: ${err.message}`);
393
+ reject(err);
394
+ });
395
+ });
396
+ });
397
+ }
398
+
399
+ async function waitForServerReady(localPort) {
400
+ for (let i = 0; i < SERVER_HEALTH_MAX_RETRIES; i++) {
401
+ try {
402
+ uploadLog(`waitForServerReady: attempt ${i + 1}, checking http://localhost:${localPort}/health`);
403
+ const ok = await new Promise((resolve) => {
404
+ let settled = false;
405
+ const done = (val) => {
406
+ if (!settled) {
407
+ settled = true;
408
+ sock.destroy();
409
+ resolve(val);
410
+ }
411
+ };
412
+ const sock = netConnect({ host: '127.0.0.1', port: localPort }, () => {
413
+ sock.write('GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
414
+ });
415
+ let resp = '';
416
+ sock.on('data', (c) => {
417
+ resp += c.toString();
418
+ if (resp.includes('200 OK')) done(true);
419
+ });
420
+ sock.on('close', () => done(resp.includes('200 OK')));
421
+ sock.on('error', () => done(false));
422
+ setTimeout(() => done(false), 3000);
423
+ });
424
+ if (ok) {
425
+ uploadLog(`waitForServerReady: server ready on port ${localPort} after ${i} retries`);
426
+ return;
427
+ }
428
+ uploadLog(`waitForServerReady: health check returned non-200 (retry ${i + 1})`);
429
+ } catch (err) {
430
+ uploadLog(`waitForServerReady: health check failed: ${err.message} (retry ${i + 1})`);
431
+ }
432
+ await new Promise((r) => setTimeout(r, SERVER_HEALTH_INTERVAL_MS));
433
+ }
434
+ throw new Error(
435
+ `sandbox file server not ready after ${SERVER_HEALTH_MAX_RETRIES * SERVER_HEALTH_INTERVAL_MS}ms (log: ${UPLOAD_LOG_PATH})`,
436
+ );
437
+ }
438
+
439
+ async function cleanupFileServer(workspaceId, username) {
440
+ const pidFile = '/tmp/sandbox-file-server.pid';
441
+ const scriptPath = '/tmp/sandbox-file-server.py';
442
+ await execWithSession(workspaceId, `kill $(cat ${pidFile}) 2>/dev/null; rm -f ${pidFile} ${scriptPath}`, username);
443
+ }
444
+
445
+ async function uploadViaHttpTunnel(workspaceId, archivePath, archiveRemotePath, username, timeoutMs, options) {
446
+ const sandboxPort = options.sandboxPort || 8888;
447
+ const uploadToken = generateUploadToken();
448
+ const archiveSize = statSync(archivePath).size;
449
+
450
+ uploadLog(
451
+ `uploadViaHttpTunnel: start (archive=${archivePath}, size=${archiveSize}, remotePath=${archiveRemotePath})`,
452
+ );
453
+
454
+ uploadLog(`uploadViaHttpTunnel: deploying file server on sandbox port ${sandboxPort}`);
455
+ await deployFileServer(workspaceId, username, sandboxPort, uploadToken);
456
+
457
+ uploadLog(`uploadViaHttpTunnel: creating dedicated tunnel session`);
458
+ const tunnelSession = await createTunnelSession(workspaceId, username);
459
+
460
+ uploadLog(`uploadViaHttpTunnel: creating tunnel channel (localPort=0, remotePort=${sandboxPort})`);
461
+ const { HwlinkTunnelChannel } = await import(WS_EXEC_INDEX_URL);
462
+ const tunnel = new HwlinkTunnelChannel({
463
+ localPort: 0,
464
+ remotePort: sandboxPort,
465
+ });
466
+ tunnel.attach(tunnelSession.mux);
467
+
468
+ uploadLog(`uploadViaHttpTunnel: waiting for tunnel ready (timeout=${TUNNEL_READY_TIMEOUT_MS}ms)`);
469
+ try {
470
+ await Promise.race([
471
+ tunnel.ready,
472
+ new Promise((_, reject) =>
473
+ setTimeout(
474
+ () => reject(new Error(`tunnel ready timeout after ${TUNNEL_READY_TIMEOUT_MS}ms`)),
475
+ TUNNEL_READY_TIMEOUT_MS,
476
+ ),
477
+ ),
478
+ ]);
479
+ uploadLog(`uploadViaHttpTunnel: tunnel ready, localPort=${tunnel.localPort}`);
480
+ } catch (tunnelReadyError) {
481
+ uploadLog(`uploadViaHttpTunnel: TUNNEL READY FAILED: ${tunnelReadyError.message}`);
482
+ tunnel.close();
483
+ throw new Error(
484
+ `HTTP tunnel failed to establish: ${tunnelReadyError.message}. ` +
485
+ `This means the WebSocket port-forwarding channel to sandbox port ${sandboxPort} could not be opened. ` +
486
+ `Common causes: (1) Python file server not running on sandbox, (2) sandbox port ${sandboxPort} blocked, ` +
487
+ `(3) hwlink multiplexer channel rejected. ` +
488
+ `Diagnostic log: ${UPLOAD_LOG_PATH}`,
489
+ { cause: tunnelReadyError },
490
+ );
491
+ }
492
+
493
+ try {
494
+ uploadLog(`uploadViaHttpTunnel: waiting for server health on localhost:${tunnel.localPort}`);
495
+ await waitForServerReady(tunnel.localPort);
496
+
497
+ uploadLog(`uploadViaHttpTunnel: sending POST with ${archiveSize} bytes`);
498
+ const result = await uploadViaTunnel(
499
+ tunnel.localPort,
500
+ archivePath,
501
+ archiveSize,
502
+ archiveRemotePath,
503
+ uploadToken,
504
+ timeoutMs,
505
+ );
506
+ uploadLog(`uploadViaHttpTunnel: upload complete (bytes=${result.bytes}, md5=${result.md5})`);
507
+ return result;
508
+ } catch (uploadError) {
509
+ uploadLog(`uploadViaHttpTunnel: UPLOAD FAILED: ${uploadError.message}`);
510
+ throw uploadError;
511
+ } finally {
512
+ tunnel.close();
513
+ tunnelSession.close();
514
+ }
515
+ }
516
+
517
+ export async function uploadProjectWithSession(
518
+ workspaceId,
519
+ localDir,
520
+ remoteDir,
521
+ username = 'root',
522
+ timeoutMs = 120000,
523
+ options = {},
524
+ ) {
525
+ if (!existsSync(localDir)) {
526
+ throw new Error(`sandbox upload project: local directory not found: ${localDir}`);
527
+ }
528
+ if (!statSync(localDir).isDirectory()) {
529
+ throw new Error(`sandbox upload project: path is not a directory: ${localDir}`);
530
+ }
531
+
532
+ const projectName = basename(localDir);
533
+ const targetParentDir = remoteDir || '/workspace';
534
+ const archiveRemotePath = `${targetParentDir}/${projectName}.tar.gz`;
535
+
536
+ const archivePath = await createTarGz(localDir, options.exclude);
537
+ const archiveSize = statSync(archivePath).size;
538
+ const expectedMd5 = await computeMd5(archivePath);
539
+
540
+ uploadLog(`uploadProject: ${localDir} -> ${archiveRemotePath} (archive=${archiveSize} bytes, md5=${expectedMd5})`);
541
+
542
+ let result;
543
+ try {
544
+ result = await uploadViaHttpTunnel(workspaceId, archivePath, archiveRemotePath, username, timeoutMs, options);
545
+ } catch (tunnelError) {
546
+ uploadLog(`uploadProject: HTTP tunnel upload failed: ${tunnelError.message}`);
547
+ uploadLog(`uploadProject: NOT falling back to base64 (removed). Rethrowing with diagnostics.`);
548
+ cleanupLocalArchive(archivePath);
549
+ throw new Error(
550
+ `sandbox upload failed: HTTP tunnel could not transfer the project archive. ` +
551
+ `Archive size: ${(archiveSize / 1024).toFixed(1)}KB. ` +
552
+ `Root cause: ${tunnelError.message}. ` +
553
+ `Diagnostic log: ${UPLOAD_LOG_PATH}`,
554
+ { cause: tunnelError },
555
+ );
556
+ }
557
+
558
+ if (options.verify !== false && result.md5 && result.md5 !== expectedMd5) {
559
+ throw new Error(`md5 mismatch: expected ${expectedMd5}, got ${result.md5}`);
560
+ }
561
+
562
+ if (options.extract !== false) {
563
+ await execWithSession(
564
+ workspaceId,
565
+ `mkdir -p "${targetParentDir}" && tar -xzf "${archiveRemotePath}" -C "${targetParentDir}" && rm -f "${archiveRemotePath}"`,
566
+ username,
567
+ timeoutMs,
568
+ );
569
+ }
570
+
571
+ try {
572
+ await cleanupFileServer(workspaceId, username);
573
+ } catch {}
574
+ cleanupLocalArchive(archivePath);
575
+
576
+ return {
577
+ ok: true,
578
+ localDir,
579
+ remotePath: options.extract !== false ? `${targetParentDir}/${projectName}` : archiveRemotePath,
580
+ bytes: result.bytes || 0,
581
+ md5: result.md5 || expectedMd5,
582
+ md5Verified: result.md5 ? result.md5 === expectedMd5 : true,
583
+ extracted: options.extract !== false,
584
+ };
585
+ }
586
+
176
587
  export async function closeSession(workspaceId, username) {
177
588
  const key = `${workspaceId}:${username}`;
178
589
  const session = sessions.get(key);
@@ -405,7 +405,12 @@ function copyDir(src, dest) {
405
405
  }
406
406
 
407
407
  function installRuntimeDeps(pluginsDir) {
408
- const pkgJson = { type: 'module', dependencies: { undici: '^8.10.0' } };
408
+ const pkgJson = {
409
+ name: 'huaweicloud-plugins',
410
+ version: pkgVersion,
411
+ type: 'module',
412
+ dependencies: { undici: '^8.10.0' },
413
+ };
409
414
  mkdirSync(pluginsDir, { recursive: true });
410
415
  writeFileSync(join(pluginsDir, 'package.json'), JSON.stringify(pkgJson, null, 2));
411
416
  const r = spawnSync('npm', ['install', '--omit=dev'], {
@@ -1427,6 +1432,7 @@ async function installOfficeAce() {
1427
1432
  copyDir(safetyDir, join(pluginDest, 'safety'));
1428
1433
  console.log(` Safety Policy -> ${join(pluginDest, 'safety')}`);
1429
1434
 
1435
+ installRuntimeDeps(pluginDest);
1430
1436
  ensureOfficeaceMcpInSqlite();
1431
1437
  registerOfficeaceSkillEntries();
1432
1438
  }
@@ -1444,6 +1450,7 @@ async function updateOfficeAce() {
1444
1450
  console.log(` MCP Server updated -> ${join(pluginDest, 'src')}`);
1445
1451
  copyDir(safetyDir, join(pluginDest, 'safety'));
1446
1452
  console.log(` Safety Policy updated -> ${join(pluginDest, 'safety')}`);
1453
+ installRuntimeDeps(pluginDest);
1447
1454
  ensureOfficeaceMcpInSqlite();
1448
1455
  registerOfficeaceSkillEntries();
1449
1456
  mkdirSync(pluginDest, { recursive: true });
@@ -12,6 +12,7 @@ import {
12
12
  execOneShot,
13
13
  closeSession,
14
14
  uploadFileWithSession,
15
+ uploadProjectWithSession,
15
16
  DEFAULT_WORKSPACE_ID,
16
17
  } from './sandbox/session-manager.mjs';
17
18
  import { hdkitCheckUser, hdkitSignAgreement, hdkitConnect, hdkitCredentials } from './sandbox/hdkitservice-api.mjs';
@@ -480,6 +481,35 @@ export const TOOL_DEFINITIONS = [
480
481
  },
481
482
  },
482
483
  },
484
+ {
485
+ name: 'huaweicloud_sandbox_upload_project',
486
+ description:
487
+ 'Package a local project directory and upload it to a sandbox workspace via HTTP tunnel. Falls back to base64 chunking if tunnel fails. Creates a tar.gz archive, uploads it, and extracts it on the sandbox by default.',
488
+ inputSchema: {
489
+ type: 'object',
490
+ required: ['local_dir'],
491
+ properties: {
492
+ local_dir: { type: 'string', description: 'Local project directory to upload.' },
493
+ remote_dir: {
494
+ type: 'string',
495
+ description:
496
+ 'Remote parent directory where project will be extracted (default: /workspace). Final layout: <remote_dir>/<dirname>/',
497
+ },
498
+ workspace_id: { type: 'string', description: 'Workspace ID (defaults to HW_WORKSPACE_ID env var)' },
499
+ username: { type: 'string', description: 'Login username (default: root)' },
500
+ exclude: {
501
+ type: 'array',
502
+ items: { type: 'string' },
503
+ description: 'Patterns to exclude from archive (default: .git, node_modules, __pycache__, .venv)',
504
+ },
505
+ extract: {
506
+ type: 'boolean',
507
+ description: 'Extract tar.gz on sandbox after upload (default: true)',
508
+ },
509
+ timeout_ms: { type: 'number', description: 'Execution timeout in milliseconds (default: 120000)' },
510
+ },
511
+ },
512
+ },
483
513
  {
484
514
  name: 'huaweicloud_sandbox_check_user',
485
515
  description:
@@ -634,6 +664,25 @@ export async function callTool(name, args = {}) {
634
664
  sandboxTimeout5,
635
665
  );
636
666
  }
667
+ case 'huaweicloud_sandbox_upload_project': {
668
+ if (!args.local_dir) {
669
+ throw new Error('local_dir is required.');
670
+ }
671
+ const sandboxWsId6 = args.workspace_id || DEFAULT_WORKSPACE_ID;
672
+ const sandboxUser6 = args.username || 'root';
673
+ const sandboxTimeout6 = args.timeout_ms || 120000;
674
+ return await uploadProjectWithSession(
675
+ sandboxWsId6,
676
+ args.local_dir,
677
+ args.remote_dir,
678
+ sandboxUser6,
679
+ sandboxTimeout6,
680
+ {
681
+ exclude: args.exclude,
682
+ extract: args.extract,
683
+ },
684
+ );
685
+ }
637
686
  case 'huaweicloud_sandbox_check_user':
638
687
  return await hdkitCheckUser();
639
688
  case 'huaweicloud_sandbox_sign_agreement':
@@ -383,10 +383,15 @@ class HwlinkTerminalExecSession {
383
383
  const pending = this.pending;
384
384
  if (!pending) return;
385
385
 
386
- const doneMatch = pending.buffer.match(pending.markers.donePattern);
387
- if (!doneMatch || doneMatch.index === undefined) return;
386
+ const doneMarker = pending.markers.doneMarker;
387
+ const markerIndex = pending.buffer.lastIndexOf(doneMarker);
388
+ if (markerIndex === -1) return;
388
389
 
389
- const rawOutput = pending.buffer.slice(0, doneMatch.index);
390
+ const afterMarker = pending.buffer.slice(markerIndex + doneMarker.length);
391
+ const exitMatch = afterMarker.match(/^(\d+)/);
392
+ if (!exitMatch) return;
393
+
394
+ const rawOutput = pending.buffer.slice(0, markerIndex);
390
395
  const stdout = cleanCommandOutput(rawOutput, {
391
396
  inputEchoed: this.inputEchoed,
392
397
  command: pending.command,
@@ -397,7 +402,7 @@ class HwlinkTerminalExecSession {
397
402
  this.pending = null;
398
403
  pending.resolve({
399
404
  stdout,
400
- exitCode: Number(doneMatch[1]),
405
+ exitCode: Number(exitMatch[1]),
401
406
  url: this.url,
402
407
  source: this.source,
403
408
  username: this.username,
@@ -0,0 +1,258 @@
1
+ import { createServer } from 'node:net';
2
+ import {
3
+ OpCode,
4
+ ReserveSource,
5
+ FIXED_HEADER_LEN,
6
+ MAX_SEND_CHUNK_SIZE,
7
+ createPacket,
8
+ isOpTunnelSuccess,
9
+ isOpTcpTunnelData,
10
+ isOpFailed,
11
+ isSubStreamPing,
12
+ nextIdentifier,
13
+ } from './hwlink-packet.js';
14
+
15
+ const MAX_TUNNEL_PAYLOAD_SIZE = MAX_SEND_CHUNK_SIZE - FIXED_HEADER_LEN;
16
+
17
+ class HwlinkTunnelChannel {
18
+ constructor({ localPort = 0, remotePort, onReady, onClose, onError }) {
19
+ this.localPort = localPort;
20
+ this.remotePort = remotePort;
21
+ this.identifier = nextIdentifier();
22
+ this.mux = null;
23
+ this.closed = false;
24
+ this.opened = false;
25
+
26
+ this.subConnections = new Map();
27
+ this.localServer = null;
28
+ this.nextSubId = 1;
29
+
30
+ this.onReadyCb = onReady || null;
31
+ this.onCloseCb = onClose || null;
32
+ this.onErrorCb = onError || null;
33
+
34
+ this._readyResolve = null;
35
+ this._readyReject = null;
36
+ this.ready = new Promise((resolve, reject) => {
37
+ this._readyResolve = resolve;
38
+ this._readyReject = reject;
39
+ });
40
+ }
41
+
42
+ attach(mux) {
43
+ this.mux = mux;
44
+ mux.register(this);
45
+ }
46
+
47
+ onopen() {
48
+ if (this.closed || this.opened) return;
49
+ this.opened = true;
50
+ this.startLocalServer();
51
+ }
52
+
53
+ onmessage(packet) {
54
+ if (this.closed) return;
55
+
56
+ if (isOpFailed(packet.operation)) {
57
+ const subConn = this.subConnections.get(packet.identifier);
58
+ if (subConn && subConn.socket && !subConn.socket.destroyed) {
59
+ try {
60
+ subConn.socket.end();
61
+ } catch {}
62
+ }
63
+ this.subConnections.delete(packet.identifier);
64
+ this._unregisterSubId(packet.identifier);
65
+ return;
66
+ }
67
+
68
+ if (isOpTunnelSuccess(packet.operation)) {
69
+ const subConn = this.subConnections.get(packet.identifier);
70
+ if (subConn && !subConn.ready) {
71
+ subConn.ready = true;
72
+ if (subConn.readyResolve) subConn.readyResolve();
73
+ }
74
+ if (packet.identifier === this.identifier && this._readyResolve) {
75
+ this._readyResolve();
76
+ this._readyResolve = null;
77
+ this._readyReject = null;
78
+ }
79
+ return;
80
+ }
81
+
82
+ if (isOpTcpTunnelData(packet.operation)) {
83
+ const subConn = this.subConnections.get(packet.identifier);
84
+ if (subConn && subConn.socket && !subConn.socket.destroyed) {
85
+ subConn.socket.write(packet.data);
86
+ }
87
+ return;
88
+ }
89
+
90
+ if (isSubStreamPing(packet.operation)) {
91
+ this.sendRaw(
92
+ createPacket({
93
+ operation: OpCode.OpTcpTunnelData | OpCode.OpSubStreamPong,
94
+ reserve: ReserveSource.Web,
95
+ identifier: packet.identifier,
96
+ source: this.mux ? this.mux.source : 0,
97
+ payload: new Uint8Array(0),
98
+ }),
99
+ );
100
+ return;
101
+ }
102
+
103
+ if (packet.operation === OpCode.OpDisconnect) {
104
+ const subConn = this.subConnections.get(packet.identifier);
105
+ if (subConn && subConn.socket && !subConn.socket.destroyed) {
106
+ subConn.socket.end();
107
+ }
108
+ this.subConnections.delete(packet.identifier);
109
+ this._unregisterSubId(packet.identifier);
110
+ return;
111
+ }
112
+ }
113
+
114
+ onerror(error) {
115
+ if (this.onErrorCb) this.onErrorCb(error);
116
+ if (this._readyReject) {
117
+ this._readyReject(error);
118
+ this._readyResolve = null;
119
+ this._readyReject = null;
120
+ }
121
+ }
122
+
123
+ onclose() {
124
+ this.close();
125
+ }
126
+
127
+ startLocalServer() {
128
+ this.localServer = createServer((socket) => {
129
+ this.onIncomingConnection(socket);
130
+ });
131
+
132
+ this.localServer.listen(this.localPort, '127.0.0.1', () => {
133
+ this.localPort = this.localServer.address().port;
134
+ if (this.onReadyCb) this.onReadyCb();
135
+ if (this._readyResolve) {
136
+ this._readyResolve();
137
+ this._readyResolve = null;
138
+ this._readyReject = null;
139
+ }
140
+ });
141
+
142
+ this.localServer.on('error', (err) => {
143
+ if (this.onErrorCb) this.onErrorCb(err);
144
+ if (this._readyReject) {
145
+ this._readyReject(err);
146
+ this._readyResolve = null;
147
+ this._readyReject = null;
148
+ }
149
+ });
150
+ }
151
+
152
+ _registerSubId(subId) {
153
+ if (this.mux && !this.mux.channels.has(subId)) {
154
+ this.mux.channels.set(subId, this);
155
+ this.mux.queue.register({ identifier: subId });
156
+ }
157
+ }
158
+
159
+ _unregisterSubId(subId) {
160
+ if (this.mux) {
161
+ this.mux.channels.delete(subId);
162
+ this.mux.queue.unregister({ identifier: subId });
163
+ }
164
+ }
165
+
166
+ onIncomingConnection(socket) {
167
+ const subId = this.nextSubId;
168
+ this.nextSubId = ((this.nextSubId + 1) & 0xffffffff) >>> 0;
169
+
170
+ let readyResolve;
171
+ const readyPromise = new Promise((resolve) => {
172
+ readyResolve = resolve;
173
+ });
174
+ const subConn = {
175
+ identifier: subId,
176
+ socket,
177
+ ready: false,
178
+ readyResolve,
179
+ readyPromise,
180
+ };
181
+
182
+ this.subConnections.set(subId, subConn);
183
+ this._registerSubId(subId);
184
+
185
+ this.sendRaw(
186
+ createPacket({
187
+ operation: OpCode.OpCreateTcpTunnel,
188
+ reserve: ReserveSource.Web,
189
+ srcPort: this.localPort,
190
+ dstPort: this.remotePort,
191
+ identifier: subId,
192
+ source: this.mux ? this.mux.source : 0,
193
+ payload: new Uint8Array(0),
194
+ }),
195
+ );
196
+
197
+ socket.on('data', (data) => {
198
+ if (this.closed || socket.destroyed) return;
199
+ for (let offset = 0; offset < data.length; offset += MAX_TUNNEL_PAYLOAD_SIZE) {
200
+ const payload = data.subarray(offset, offset + MAX_TUNNEL_PAYLOAD_SIZE);
201
+ this.sendRaw(
202
+ createPacket({
203
+ operation: OpCode.OpTcpTunnelData,
204
+ reserve: ReserveSource.Web,
205
+ srcPort: this.localPort,
206
+ dstPort: this.remotePort,
207
+ identifier: subId,
208
+ source: this.mux ? this.mux.source : 0,
209
+ payload,
210
+ }),
211
+ );
212
+ }
213
+ });
214
+
215
+ socket.on('close', () => {
216
+ this._unregisterSubId(subId);
217
+ this.subConnections.delete(subId);
218
+ });
219
+
220
+ socket.on('error', () => {
221
+ this._unregisterSubId(subId);
222
+ this.subConnections.delete(subId);
223
+ });
224
+ }
225
+
226
+ destroySubConnection(subConn, error) {
227
+ if (subConn.socket && !subConn.socket.destroyed) {
228
+ if (error) subConn.socket.destroy(error);
229
+ else subConn.socket.destroy();
230
+ }
231
+ this._unregisterSubId(subConn.identifier);
232
+ this.subConnections.delete(subConn.identifier);
233
+ }
234
+
235
+ sendRaw(data) {
236
+ if (this.mux) this.mux.sendFairly(this, data);
237
+ }
238
+
239
+ close() {
240
+ if (this.closed) return;
241
+ this.closed = true;
242
+
243
+ for (const subConn of this.subConnections.values()) {
244
+ this.destroySubConnection(subConn);
245
+ }
246
+ this.subConnections.clear();
247
+
248
+ if (this.localServer) {
249
+ this.localServer.close();
250
+ this.localServer = null;
251
+ }
252
+
253
+ if (this.mux) this.mux.unregister(this);
254
+ if (this.onCloseCb) this.onCloseCb();
255
+ }
256
+ }
257
+
258
+ export { HwlinkTunnelChannel };
@@ -3,10 +3,12 @@ import * as hwlinkPacket from './hwlink-packet.js';
3
3
  import * as wsExec from './ws-exec-client.js';
4
4
  import { HwlinkWebSocketMultiplexer } from './hwlink-multiplexer.js';
5
5
  import { HwlinkTerminalChannel } from './hwlink-terminal-channel.js';
6
+ import { HwlinkTunnelChannel } from './hwlink-tunnel-channel.mjs';
6
7
 
7
8
  export * from './ws-exec-client.js';
8
9
  export * from './hwlink-exec-client.js';
9
10
  export { HwlinkTerminalChannel } from './hwlink-terminal-channel.js';
11
+ export { HwlinkTunnelChannel } from './hwlink-tunnel-channel.mjs';
10
12
  export { HwlinkWebSocketMultiplexer } from './hwlink-multiplexer.js';
11
13
  export { hwlinkPacket };
12
14
 
@@ -14,6 +16,7 @@ export default {
14
16
  ...wsExec,
15
17
  ...hwlinkExec,
16
18
  HwlinkTerminalChannel,
19
+ HwlinkTunnelChannel,
17
20
  HwlinkWebSocketMultiplexer,
18
21
  hwlinkPacket,
19
22
  };
@@ -65,7 +65,6 @@ function buildMarkers(nonce = randomBytes(8).toString('hex')) {
65
65
  doneSuffix,
66
66
  readyMarker,
67
67
  doneMarker,
68
- donePattern: new RegExp(`${escapeRegExp(doneMarker)}(\\d+)`),
69
68
  };
70
69
  }
71
70
 
@@ -284,10 +283,15 @@ class WebSocketShellSession {
284
283
  const pending = this.pending;
285
284
  if (!pending) return;
286
285
 
287
- const doneMatch = pending.buffer.match(pending.markers.donePattern);
288
- if (!doneMatch || doneMatch.index === undefined) return;
286
+ const doneMarker = pending.markers.doneMarker;
287
+ const markerIndex = pending.buffer.lastIndexOf(doneMarker);
288
+ if (markerIndex === -1) return;
289
289
 
290
- const rawOutput = pending.buffer.slice(0, doneMatch.index);
290
+ const afterMarker = pending.buffer.slice(markerIndex + doneMarker.length);
291
+ const exitMatch = afterMarker.match(/^(\d+)/);
292
+ if (!exitMatch) return;
293
+
294
+ const rawOutput = pending.buffer.slice(0, markerIndex);
291
295
  const stdout = cleanCommandOutput(rawOutput, {
292
296
  inputEchoed: this.inputEchoed,
293
297
  command: pending.command,
@@ -298,7 +302,7 @@ class WebSocketShellSession {
298
302
  this.pending = null;
299
303
  pending.resolve({
300
304
  stdout,
301
- exitCode: Number(doneMatch[1]),
305
+ exitCode: Number(exitMatch[1]),
302
306
  url: this.url,
303
307
  command: pending.command,
304
308
  });