huaweicloud-devkit 1.1.9-next.1 → 1.1.9-next.3

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
5
5
  [![CI](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml/badge.svg)](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/huaweicloud-devkit)](https://www.npmjs.com/package/huaweicloud-devkit)
7
- [![Beta](https://img.shields.io/badge/beta-v1.1.9-next.1-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
7
+ [![Beta](https://img.shields.io/badge/beta-v1.1.9-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
8
8
 
9
9
  **[中文](README.zh-CN.md) | English**
10
10
 
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
5
5
  [![CI](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml/badge.svg)](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/huaweicloud-devkit)](https://www.npmjs.com/package/huaweicloud-devkit)
7
- [![Beta](https://img.shields.io/badge/beta-v1.1.9-next.1-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
7
+ [![Beta](https://img.shields.io/badge/beta-v1.1.9-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
8
8
 
9
9
  **中文 | [English](README.md)**
10
10
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
3
  "mcpName": "io.github.huaweicloud/huaweicloud-devkit",
4
- "version": "1.1.9-next.1",
4
+ "version": "1.1.9-next.3",
5
5
  "kooCliVersion": "7.3.12",
6
6
  "description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
7
7
  "type": "module",
@@ -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.1.9-next.1",
20
+ "version": "1.1.9-next.3",
21
21
  "author": {
22
22
  "name": "HuaweiCloud Mate",
23
23
  "url": "https://github.com/huaweicloud"
@@ -20,7 +20,7 @@
20
20
  "mcpServers": "./.mcp.json",
21
21
  "description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
22
22
  "skills": "./skills/",
23
- "version": "1.1.9-next.1",
23
+ "version": "1.1.9-next.3",
24
24
  "author": {
25
25
  "name": "HuaweiCloud Mate",
26
26
  "url": "https://github.com/huaweicloud"
@@ -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.1.9-next.1",
20
+ "version": "1.1.9-next.3",
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-devkit",
3
- "version": "1.1.9-next.1",
3
+ "version": "1.1.9-next.3",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
- "version": "1.1.9-next.1",
3
+ "version": "1.1.9-next.3",
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",
@@ -2,7 +2,7 @@
2
2
  "name": "huaweicloud-devkit",
3
3
  "id": "huaweicloud-devkit",
4
4
  "displayName": "HuaweiCloud DevKit",
5
- "version": "1.1.9-next.1",
5
+ "version": "1.1.9-next.3",
6
6
  "family": "bundle-plugin",
7
7
  "bundleFormat": "codex",
8
8
  "description": "Guide coding agents to use Huawei Cloud safely — KooCLI, APIs, SDKs, 28 MCP tools, skills, and safety guardrails.",
@@ -942,7 +942,7 @@ The tool checks:
942
942
  - **nginx_serving** — nginx responds with 2xx/3xx on the app port
943
943
  - **output_dir** — build output directory exists and is non-empty
944
944
  - **devbridge_tunnel** — DevBridge tunnel is active (version-aware: 0.1.x JSON / 0.2.x table)
945
- - **tunnel_url_accessible** — tunnel URL returns 200/304 with real content; a gateway migration placeholder page (「服务已迁移」 with HTTP 200) counts as FAIL
945
+ - **tunnel_url_accessible** — tunnel URL returns 200/304 with real content; these all count as FAIL: a gateway migration placeholder page (「服务已迁移」 with HTTP 200), `tunnel not found` (404 — no live host session on the gateway, even if the tunnel still appears in `devbridge list`), `port not configured` (404 — URL port not registered on the tunnel), and `bad gateway` (502 — tunnel alive but the local service is down). The check probes every listed tunnel and selects one that actually serves the port.
946
946
  - **qr_code** (cross-platform only) — QR image exists in output dir
947
947
 
948
948
  Returns `complete: true/false`, `score`, and `nextStep` to fix missing items.
@@ -965,36 +965,37 @@ Returns `complete: true/false`, `score`, and `nextStep` to fix missing items.
965
965
 
966
966
  ## Critical Warnings
967
967
 
968
- | Trap | Why |
969
- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
970
- | Target not confirmed | "部署到华为云" without a named target is NOT a go signal. You MUST run the Target-Selection Gate and get an explicit choice before calling any sandbox lifecycle tool. Skipping it and defaulting to the sandbox is a violation. |
971
- | Agreement required first | `sandbox_connect` fails if the agreement isn't signed; the `sandbox_check_user` preflight detects this, so surface it to the developer only when signing is needed |
972
- | Real-name required | `sandbox_connect` fails if `realnameVerified=false`; tell the developer once and stop, don't loop on connect |
973
- | Never expose tunnel details | Do not mention "DevBridge"/"tunnel"/"devbridge" to the developer — say "正在生成访问地址..." and hand over only the URL |
974
- | devbridge auth: probe the build | `0.2.2-release` has two builds: image builds retain AK/SK login + env auto-read (fully automatic via `/tmp/hw_creds.sh`); release builds accept only API Key (`/tmp/hw_api_key`). Never assume — probe with `devbridge auth login --help 2>&1 \| grep -q -- '--access-key'` (Step 1 of "Expose the deployed app") |
975
- | devbridge 0.1.x is dead | Sandboxes created before Sep 2026 ship 0.1.13, which connects to a migrated gateway serving a 「服务已迁移」 placeholder with HTTP 200. Check `devbridge version` first and upgrade in place (Step 0) — old tunnels never survive the upgrade |
976
- | CLI PATH | The installer only writes `~/.bashrc`; run `export PATH="$HOME/.huawei/bin:$PATH"` (prepend) in the session before using `devbridge` — appending lets a stale image binary in `/usr/local/bin` win |
977
- | Never install tunnel tooling locally | If the sandbox cannot install it, report a generic error and stop — installing on the developer's machine defeats sandbox deployment |
978
- | Return the deployment URL | Always hand the public URL from the host log to the developer as the final result |
979
- | Deploy is not just nginx | Configuring nginx does NOT complete the deployment. Steps 7 (DevBridge expose) and deploy_check are REQUIRED — `deploy_nginx` returns `nextStep: expose_via_devbridge` as a reminder. Do not stop after nginx. |
980
- | Call deploy_check before success | Always call `huaweicloud_sandbox_deploy_check` before reporting deployment success. A green nginx status does not mean the tunnel is accessible — verify end-to-end with the tool. |
981
- | Session state persists | `exec_with_session` preserves `cd`, env vars, aliases between calls |
982
- | Long commands prefer one-shot | `exec_one_shot` creates a fresh connection per call — more stable for builds, installs, and scripts >30s. See [Tool Selection Guide](#tool-selection-guide). |
983
- | SSR nginx/Node ports must differ | nginx `proxy_pass` targets `<nodePort>`, not `<port>`. `deploy_nginx` auto-defaults `nodePort` to `<port>+1` — always start the Node process with `PORT=<nodePort>` to match. Same-port = EADDRINUSE. |
984
- | HTTP 200 ≠ correct content | A green HTTP check does not guarantee the right project is serving — old processes from a previous session bound to the same port will still return 200. `deploy_check` verifies the deployment fingerprint to catch this. |
985
- | Destructive commands blocked | `rm -rf /`, `mkfs`, `dd if=`, fork bombs are denied by safety policy |
986
- | Workspace ID = dev_stage_id | Use `dev_stage_id` from `sandbox_connect` as `workspace_id` for terminal exec |
987
- | 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 |
988
- | 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 |
989
- | Upload file for single files | Use `sandbox_upload_file` for individual files — base64 chunked, reliable for small files (<1MB) |
990
- | 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") |
991
- | Sandbox restart kills processes | After sandbox restarts, all user processes (nginx, Node.js, Python servers) are stopped. Re-run startup commands and verify ports are listening before proceeding. |
992
- | Cross-platform binaries incompatible | The sandbox runs Linux. Native binaries built on Windows/macOS (e.g., Prisma client, `node_modules/.prisma/`, platform-specific native addons) will not execute. Always install and build dependencies inside the sandbox, not locally. |
993
- | Cross-platform needs QR code | When `detect_framework` returns `type: "cross-platform"` (Taro, uni-app), generating a QR code image is **mandatory** — the deployment is incomplete without it. Check the Deployment Completion Check table in Step 7. |
994
- | Build fails do NOT auto-fix | When a build exits with non-zero exit code, STOP and present the error + fix options to the developer. Do not silently retry, modify configs, or change source files without explicit approval. See 4c-aux. |
995
- | Tunnel description: no spaces/hyphens | `devbridge create <name> -d 'hello world'` fails with `Invalid tunnel description: only Chinese characters, digits, letters, length 0-64`. The message is misleading — the real rule is no spaces/hyphens. Omit `-d` or use bare letters/digits. |
996
- | Use `host`, never `connect` | `devbridge connect` is the sender side (for on-prem machines dialing out). For public preview it loops `Connection failed, retrying...` forever with no hint. Always use `devbridge host <tunnelId> -p <port>`. |
997
- | Port drift detaches the tunnel | When deploy_nginx auto-increments to a free port, an existing tunnel stays bound to the old port — re-bind: `devbridge port create <tunnelId> -p <newPort> --protocol http -a` and restart `devbridge host`. deploy_nginx emits a warning when this happens. |
968
+ | Trap | Why |
969
+ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
970
+ | Target not confirmed | "部署到华为云" without a named target is NOT a go signal. You MUST run the Target-Selection Gate and get an explicit choice before calling any sandbox lifecycle tool. Skipping it and defaulting to the sandbox is a violation. |
971
+ | Agreement required first | `sandbox_connect` fails if the agreement isn't signed; the `sandbox_check_user` preflight detects this, so surface it to the developer only when signing is needed |
972
+ | Real-name required | `sandbox_connect` fails if `realnameVerified=false`; tell the developer once and stop, don't loop on connect |
973
+ | Never expose tunnel details | Do not mention "DevBridge"/"tunnel"/"devbridge" to the developer — say "正在生成访问地址..." and hand over only the URL |
974
+ | devbridge auth: probe the build | `0.2.2-release` has two builds: image builds retain AK/SK login + env auto-read (fully automatic via `/tmp/hw_creds.sh`); release builds accept only API Key (`/tmp/hw_api_key`). Never assume — probe with `devbridge auth login --help 2>&1 \| grep -q -- '--access-key'` (Step 1 of "Expose the deployed app") |
975
+ | devbridge 0.1.x is dead | Sandboxes created before Sep 2026 ship 0.1.13, which connects to a migrated gateway serving a 「服务已迁移」 placeholder with HTTP 200. Check `devbridge version` first and upgrade in place (Step 0) — old tunnels never survive the upgrade |
976
+ | CLI PATH | The installer only writes `~/.bashrc`; run `export PATH="$HOME/.huawei/bin:$PATH"` (prepend) in the session before using `devbridge` — appending lets a stale image binary in `/usr/local/bin` win |
977
+ | Never install tunnel tooling locally | If the sandbox cannot install it, report a generic error and stop — installing on the developer's machine defeats sandbox deployment |
978
+ | Return the deployment URL | Always hand the public URL from the host log to the developer as the final result |
979
+ | Deploy is not just nginx | Configuring nginx does NOT complete the deployment. Steps 7 (DevBridge expose) and deploy_check are REQUIRED — `deploy_nginx` returns `nextStep: expose_via_devbridge` as a reminder. Do not stop after nginx. |
980
+ | Call deploy_check before success | Always call `huaweicloud_sandbox_deploy_check` before reporting deployment success. A green nginx status does not mean the tunnel is accessible — verify end-to-end with the tool. |
981
+ | Session state persists | `exec_with_session` preserves `cd`, env vars, aliases between calls |
982
+ | Long commands prefer one-shot | `exec_one_shot` creates a fresh connection per call — more stable for builds, installs, and scripts >30s. See [Tool Selection Guide](#tool-selection-guide). |
983
+ | SSR nginx/Node ports must differ | nginx `proxy_pass` targets `<nodePort>`, not `<port>`. `deploy_nginx` auto-defaults `nodePort` to `<port>+1` — always start the Node process with `PORT=<nodePort>` to match. Same-port = EADDRINUSE. |
984
+ | HTTP 200 ≠ correct content | A green HTTP check does not guarantee the right project is serving — old processes from a previous session bound to the same port will still return 200. `deploy_check` verifies the deployment fingerprint to catch this. |
985
+ | Destructive commands blocked | `rm -rf /`, `mkfs`, `dd if=`, fork bombs are denied by safety policy |
986
+ | Workspace ID = dev_stage_id | Use `dev_stage_id` from `sandbox_connect` as `workspace_id` for terminal exec |
987
+ | 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 |
988
+ | 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 |
989
+ | Upload file for single files | Use `sandbox_upload_file` for individual files — base64 chunked, reliable for small files (<1MB) |
990
+ | 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") |
991
+ | Sandbox restart kills processes | After sandbox restarts, all user processes (nginx, Node.js, Python servers) are stopped. Re-run startup commands and verify ports are listening before proceeding. |
992
+ | Cross-platform binaries incompatible | The sandbox runs Linux. Native binaries built on Windows/macOS (e.g., Prisma client, `node_modules/.prisma/`, platform-specific native addons) will not execute. Always install and build dependencies inside the sandbox, not locally. |
993
+ | Cross-platform needs QR code | When `detect_framework` returns `type: "cross-platform"` (Taro, uni-app), generating a QR code image is **mandatory** — the deployment is incomplete without it. Check the Deployment Completion Check table in Step 7. |
994
+ | Build fails do NOT auto-fix | When a build exits with non-zero exit code, STOP and present the error + fix options to the developer. Do not silently retry, modify configs, or change source files without explicit approval. See 4c-aux. |
995
+ | Tunnel description: no spaces/hyphens | `devbridge create <name> -d 'hello world'` fails with `Invalid tunnel description: only Chinese characters, digits, letters, length 0-64`. The message is misleading — the real rule is no spaces/hyphens. Omit `-d` or use bare letters/digits. |
996
+ | Use `host`, never `connect` | `devbridge connect` is the sender side (for on-prem machines dialing out). For public preview it loops `Connection failed, retrying...` forever with no hint. Always use `devbridge host <tunnelId> -p <port>`. |
997
+ | Port drift detaches the tunnel | When deploy_nginx auto-increments to a free port, an existing tunnel stays bound to the old port — re-bind: `devbridge port create <tunnelId> -p <newPort> --protocol http -a` and restart `devbridge host`. deploy_nginx emits a warning when this happens. |
998
+ | 404 "tunnel not found" ≠ content issue | The s2 gateway answers 404 with body `tunnel not found` when the host process is dead (the tunnel record may STILL show in `devbridge list`) and `port not configured` for an unregistered URL port — both mean the URL is dead, not that the app returned 404. `expose_tunnel`/`deploy_check` verify the probe body and refuse such URLs; a shared sandbox's second deployment also kills earlier URLs (global pre-clean), so re-expose and hand over the fresh URL. |
998
999
 
999
1000
  ## Node.js in the sandbox
1000
1001
 
@@ -28,6 +28,17 @@ const execFileAsync = promisify(execFile);
28
28
  const DEVBRIDGE_TUNNEL_DOMAIN = 'devbridge-s2.hwtunnel.com';
29
29
  // Migration placeholder page marker — a tunnel URL returning this body must be treated as unreachable.
30
30
  const DEVBRIDGE_MIGRATION_MARKER = '服务已迁移';
31
+ // Gateway-level tunnel error markers (verified against the live s2 gateway, Oct 2026).
32
+ // The gateway answers with exact text/plain bodies:
33
+ // 404 "tunnel not found" — no live host session on the gateway (host process dead or
34
+ // tunnel deleted); the tunnel RECORD may still exist in `list`
35
+ // 404 "port not configured" — the URL's port is not registered on the tunnel
36
+ // 502 "bad gateway" — host session alive, but the registered port has no local service
37
+ // A bare status check cannot distinguish these from app-generated 404/502 responses, so the
38
+ // checks below match the exact body AND gate on the HTTP status code.
39
+ const DEVBRIDGE_TUNNEL_NOT_FOUND_MARKER = 'tunnel not found';
40
+ const DEVBRIDGE_PORT_NOT_CONFIGURED_MARKER = 'port not configured';
41
+ const DEVBRIDGE_BAD_GATEWAY_MARKER = 'bad gateway';
31
42
 
32
43
  const __dirname = dirname(fileURLToPath(import.meta.url));
33
44
  export const WS_EXEC_INDEX_URL = pathToFileURL(join(__dirname, '..', 'ws-exec', 'index.js')).href;
@@ -989,31 +1000,49 @@ export function buildDeployCheckScript({ port, project, outputPath, isCrossPlatf
989
1000
  `fi`,
990
1001
  ``,
991
1002
  `TOTAL=$((TOTAL+1))`,
992
- `TUNNEL_ID=$(devbridge list -j 2>/dev/null | grep -oP '"tunnelId":\\s*"\\K[^"]+' | head -1)`,
993
- `if [ -z "$TUNNEL_ID" ]; then`,
994
- ` TUNNEL_ID=$(devbridge list 2>/dev/null | grep -E '^[a-z2-7]{8}[[:space:]]' | awk '{print $1}' | head -1)`,
1003
+ `TUNNEL_IDS=$(devbridge list -j 2>/dev/null | grep -oP '"tunnelId":\\s*"\\K[^"]+' | head -5)`,
1004
+ `if [ -z "$TUNNEL_IDS" ]; then`,
1005
+ ` TUNNEL_IDS=$(devbridge list 2>/dev/null | grep -E '^[a-z2-7]{8}[[:space:]]' | awk '{print $1}' | head -5)`,
995
1006
  `fi`,
996
- `TUNNEL_URL="https://\${TUNNEL_ID}-${port}.${DEVBRIDGE_TUNNEL_DOMAIN}"`,
1007
+ `# Gateway-level tunnel errors have exact text/plain bodies (verified on the s2`,
1008
+ `# gateway): 404 "tunnel not found" = no live host session (the tunnel RECORD may`,
1009
+ `# still be in \`list\`); 404 "port not configured" = URL port not registered; 502`,
1010
+ `# "bad gateway" = tunnel alive, local service down. grep -x (exact-line match) +`,
1011
+ `# status gating avoids flagging app-generated 404/502 pages.`,
997
1012
  `probe_tunnel() {`,
998
1013
  ` local url="$1" code`,
999
- ` code=$(curl -s -o /tmp/.dc_tunnel_body -w "%{http_code}" --max-time 10 "$url" 2>/dev/null)`,
1000
- ` [ "$code" = "000" ] && return 1`,
1001
- ` # A migrated gateway serves a placeholder page with HTTP 200 — treat it as unreachable.`,
1002
- ` if grep -q "${DEVBRIDGE_MIGRATION_MARKER}" /tmp/.dc_tunnel_body 2>/dev/null; then return 1; fi`,
1003
- ` # Any non-000 HTTP code means the tunnel is connected and nginx is serving`,
1004
- ` # (404/502 = content issue, not a connectivity issue — consistent with nginx_serving).`,
1014
+ ` code=$(curl -s -o /tmp/.dc_tunnel_body -w "%{http_code}" --max-time 5 "$url" 2>/dev/null)`,
1015
+ ` PROBE_FAIL_REASON=""`,
1016
+ ` if [ "$code" = "000" ]; then PROBE_FAIL_REASON="unreachable"; rm -f /tmp/.dc_tunnel_body; return 1; fi`,
1017
+ ` if grep -q "${DEVBRIDGE_MIGRATION_MARKER}" /tmp/.dc_tunnel_body 2>/dev/null; then PROBE_FAIL_REASON="migration placeholder page"; rm -f /tmp/.dc_tunnel_body; return 1; fi`,
1018
+ ` if [ "$code" = "404" ] && grep -qx "${DEVBRIDGE_TUNNEL_NOT_FOUND_MARKER}" /tmp/.dc_tunnel_body 2>/dev/null; then PROBE_FAIL_REASON="tunnel not found (no live host session on the gateway)"; rm -f /tmp/.dc_tunnel_body; return 1; fi`,
1019
+ ` if [ "$code" = "404" ] && grep -qx "${DEVBRIDGE_PORT_NOT_CONFIGURED_MARKER}" /tmp/.dc_tunnel_body 2>/dev/null; then PROBE_FAIL_REASON="port not configured (URL port not registered on the tunnel)"; rm -f /tmp/.dc_tunnel_body; return 1; fi`,
1020
+ ` if [ "$code" = "502" ] && grep -qx "${DEVBRIDGE_BAD_GATEWAY_MARKER}" /tmp/.dc_tunnel_body 2>/dev/null; then PROBE_FAIL_REASON="bad gateway (tunnel alive, local service not serving)"; rm -f /tmp/.dc_tunnel_body; return 1; fi`,
1005
1021
  ` rm -f /tmp/.dc_tunnel_body`,
1006
1022
  ` return 0`,
1007
1023
  `}`,
1008
- `if [ -n "$TUNNEL_ID" ] && probe_tunnel "$TUNNEL_URL"; then`,
1009
- ` echo "tunnel_url_accessible:PASS ($TUNNEL_URL)"`,
1024
+ `# Probe each listed tunnel (bounded to 5) and select the first one that actually`,
1025
+ `# serves this port — \`devbridge list\` order is arbitrary, so a stale tunnel listed`,
1026
+ `# first must not shadow the live one.`,
1027
+ `SELECTED_URL=""`,
1028
+ `FIRST_ID=""`,
1029
+ `for CAND in $TUNNEL_IDS; do`,
1030
+ ` if [ -z "$FIRST_ID" ]; then FIRST_ID="$CAND"; fi`,
1031
+ ` CAND_URL="https://\${CAND}-${port}.${DEVBRIDGE_TUNNEL_DOMAIN}"`,
1032
+ ` if probe_tunnel "$CAND_URL"; then`,
1033
+ ` SELECTED_URL="$CAND_URL"`,
1034
+ ` break`,
1035
+ ` fi`,
1036
+ `done`,
1037
+ `TUNNEL_URL="$SELECTED_URL"`,
1038
+ `if [ -n "$SELECTED_URL" ]; then`,
1039
+ ` echo "tunnel_url_accessible:PASS ($SELECTED_URL)"`,
1010
1040
  ` PASS=$((PASS+1))`,
1011
- `elif [ -n "$TUNNEL_ID" ]; then`,
1012
- ` echo "tunnel_url_accessible:FAIL ($TUNNEL_URL -> unreachable, tunnel not found, or migration placeholder page)"`,
1041
+ `elif [ -n "$FIRST_ID" ]; then`,
1042
+ ` echo "tunnel_url_accessible:FAIL (https://\${FIRST_ID}-${port}.${DEVBRIDGE_TUNNEL_DOMAIN} -> $PROBE_FAIL_REASON)"`,
1013
1043
  `else`,
1014
1044
  ` echo "tunnel_url_accessible:FAIL (no tunnel)"`,
1015
1045
  `fi`,
1016
- `rm -f /tmp/.dc_tunnel_body 2>/dev/null || true`,
1017
1046
  ``,
1018
1047
  `${`
1019
1048
  TOTAL=$((TOTAL+1))
@@ -1058,18 +1087,27 @@ fi
1058
1087
  .join('\n');
1059
1088
  }
1060
1089
 
1061
- export function parseDeployCheckOutput(stdout, { port, isCrossPlatform } = {}) {
1062
- // Strip ANSI escape sequences (CSI color/cursor codes and OSC shell-integration markers)
1063
- // and split on any line-ending style (\r\n, \r, or \n) to handle all terminal outputs.
1064
- // Use new RegExp to avoid ESLint no-control-regex on literal control chars in regex.
1090
+ // Strip terminal escape sequences from sandbox command output (#946): CSI
1091
+ // color/cursor codes and OSC shell-integration markers (shells with integration
1092
+ // enabled wrap EVERY line in \x1b]133;C\x07, which blinds anchored ^DB_...=
1093
+ // parsers), then normalize \r\n / \r line endings to \n.
1094
+ // Use new RegExp to avoid ESLint no-control-regex on literal control chars.
1095
+ export function stripTerminalEscapes(text) {
1065
1096
  const ESC = '\x1b';
1066
1097
  const csiRe = new RegExp(ESC + '\\[[0-9;]*[a-zA-Z]', 'g');
1067
1098
  const oscRe = new RegExp(ESC + '\\][^' + ESC + '\x07]*(?:\x07|' + ESC + '\\\\)', 'g');
1068
- const cleanStdout = String(stdout || '')
1099
+ return String(text || '')
1069
1100
  .replace(csiRe, '')
1070
- .replace(oscRe, '');
1101
+ .replace(oscRe, '')
1102
+ .replace(/\r\n|\r/g, '\n');
1103
+ }
1104
+
1105
+ export function parseDeployCheckOutput(stdout, { port, isCrossPlatform } = {}) {
1106
+ // Strip ANSI escape sequences (CSI color/cursor codes and OSC shell-integration markers)
1107
+ // and split on any line-ending style (\r\n, \r, or \n) to handle all terminal outputs.
1108
+ const cleanStdout = stripTerminalEscapes(stdout);
1071
1109
  const checks = {};
1072
- const lines = cleanStdout.split(new RegExp('\\r\\n|\\r|\\n'));
1110
+ const lines = cleanStdout.split('\n');
1073
1111
  for (const line of lines) {
1074
1112
  const trimmed = line.trim();
1075
1113
  const m = trimmed.match(/^(\w+):(PASS|FAIL|SKIP)\b(.*)/);
@@ -1241,7 +1279,9 @@ export function buildDevbridgeAuthProbe() {
1241
1279
  // authMode is the LAST DB_AUTH_MODE line; attempts collects the non-sensitive
1242
1280
  // DB_ATTEMPT_* diagnostics emitted by tiers that failed and fell through.
1243
1281
  export function parseDevbridgeAuthOutput(stdout) {
1244
- const text = String(stdout || '');
1282
+ // #946: OSC 133 markers precede every line on integration-enabled shells; the
1283
+ // anchored ^DB_ATTEMPT_ regexes are blind to them without stripping first.
1284
+ const text = stripTerminalEscapes(stdout);
1245
1285
  const modes = text.match(/DB_AUTH_MODE=([A-Z_]+)/g) || [];
1246
1286
  const authMode = modes.length > 0 ? modes[modes.length - 1].split('=')[1] : '';
1247
1287
  const attempts = {};
@@ -1279,13 +1319,19 @@ export function buildDevbridgeExposeScript(port, expiryHours = DEVBRIDGE_HOST_EX
1279
1319
  `if command -v setsid > /dev/null 2>&1; then setsid nohup devbridge host -p ${port} -e ${expiryHours} > ${hostLog} 2>&1 < /dev/null & else nohup devbridge host -p ${port} -e ${expiryHours} > ${hostLog} 2>&1 < /dev/null & fi`,
1280
1320
  `disown 2>/dev/null || true`,
1281
1321
  `sleep 12`,
1282
- `TUNNEL_URL=$(sed -n 's/.*Tunnel URL: *//p' ${hostLog} | tail -1)`,
1283
- `HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
1322
+ // Port-anchored extraction: a multi-port host prints one "Tunnel URL:" line per
1323
+ // port, so a port-blind `sed | tail -1` can pick another port's URL.
1324
+ `TUNNEL_URL=$(grep -oE "https://[A-Za-z0-9_-]+-${port}\\.devbridge-s2\\.hwtunnel\\.com" ${hostLog} | head -1)`,
1325
+ `HTTP_CODE=$(curl -s -o /tmp/.db_probe_body -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
1284
1326
  `echo "DB_HOST_LOG="`,
1285
1327
  `tail -n 5 ${hostLog}`,
1286
1328
  `echo "DB_TUNNEL_URL=$TUNNEL_URL"`,
1287
1329
  `echo "DB_HTTP_CODE=$HTTP_CODE"`,
1288
1330
  `echo "DB_EXPIRY_HOURS=${expiryHours}"`,
1331
+ // First 64 bytes of the probe body (non-sensitive) so gateway-error markers
1332
+ // like "tunnel not found" can be distinguished from app-generated 404/502 pages.
1333
+ `echo "DB_TUNNEL_BODY=$(head -c 64 /tmp/.db_probe_body 2>/dev/null)"`,
1334
+ `rm -f /tmp/.db_probe_body`,
1289
1335
  `echo "DB_QUOTA_ERROR=$(grep -c '10006' ${hostLog} 2>/dev/null || echo 0)"`,
1290
1336
  ].join('\n');
1291
1337
  }
@@ -1317,18 +1363,30 @@ export function buildDevbridgeVerifyScript(tunnelUrl) {
1317
1363
  }
1318
1364
 
1319
1365
  // Parse the raw stdout of buildDevbridgeExposeScript into structured fields.
1366
+ // `gatewayTunnelError` marks a URL the s2 gateway itself reports as dead
1367
+ // (404 "tunnel not found" / "port not configured") — it can never serve content
1368
+ // regardless of the app. `badGateway` (502 "bad gateway") means the tunnel is
1369
+ // alive but the local service on the exposed port is not serving.
1320
1370
  export function parseDevbridgeExposeOutput(stdout) {
1321
- const text = String(stdout || '');
1371
+ // #946: strip OSC/CSI escapes + \r BEFORE the anchored ^DB_ lookups — otherwise
1372
+ // every field is lost on integration-enabled shells and a perfectly good tunnel
1373
+ // comes back as ok:true with publicUrl missing (and pointlessly re-built once).
1374
+ const text = stripTerminalEscapes(stdout);
1322
1375
  const getField = (key) => {
1323
1376
  const m = text.match(new RegExp(`^${key}=(.*)$`, 'm'));
1324
1377
  return m ? m[1].trim() : '';
1325
1378
  };
1326
1379
  const tunnelUrl = getField('DB_TUNNEL_URL') || (text.match(/TUNNEL_URL:(https:\/\/[^\s]+)/) || [])[1] || '';
1327
1380
  const httpCode = getField('DB_HTTP_CODE');
1381
+ const body = getField('DB_TUNNEL_BODY');
1328
1382
  const quotaError = getField('DB_QUOTA_ERROR') === '1' || /10006/.test(text);
1329
1383
  const tunnelId = (tunnelUrl.match(/^https:\/\/([A-Za-z0-9_-]+)-\d+\./) || [])[1] || '';
1330
1384
  const expiryHours = Number(getField('DB_EXPIRY_HOURS')) || undefined;
1331
- return { tunnelUrl, httpCode, quotaError, tunnelId, expiryHours };
1385
+ // Exact-body match gated on the status code: an app's own 404/502 page is never misjudged.
1386
+ const gatewayTunnelError =
1387
+ httpCode === '404' && (body === DEVBRIDGE_TUNNEL_NOT_FOUND_MARKER || body === DEVBRIDGE_PORT_NOT_CONFIGURED_MARKER);
1388
+ const badGateway = httpCode === '502' && body === DEVBRIDGE_BAD_GATEWAY_MARKER;
1389
+ return { tunnelUrl, httpCode, quotaError, tunnelId, expiryHours, gatewayTunnelError, badGateway };
1332
1390
  }
1333
1391
 
1334
1392
  // Parse the raw stdout of buildDevbridgeVerifyScript into structured fields.
@@ -1337,7 +1395,9 @@ export function parseDevbridgeExposeOutput(stdout) {
1337
1395
  // parseDevbridgeExposeOutput never matches them, which used to leave
1338
1396
  // verifyHttpCode empty and persistent always false.
1339
1397
  export function parseDevbridgeVerifyOutput(stdout) {
1340
- const text = String(stdout || '');
1398
+ // #946: strip OSC/CSI escapes + \r — the anchored DB_VERIFY_* lookups are blind
1399
+ // to them, leaving verifyHttpCode empty and persistent permanently false.
1400
+ const text = stripTerminalEscapes(stdout);
1341
1401
  const getField = (key) => {
1342
1402
  const m = text.match(new RegExp(`^${key}=(.*)$`, 'm'));
1343
1403
  return m ? m[1].trim() : '';
@@ -1411,10 +1471,15 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
1411
1471
  if (parsed.quotaError && attempt < maxAttempts) {
1412
1472
  continue;
1413
1473
  }
1414
- // Unreachable (000) or a migrated-gateway placeholder: one retry.
1474
+ // Unreachable (000), migrated-gateway placeholder, or gateway-level tunnel errors
1475
+ // (404 "tunnel not found" / "port not configured"): one cleanup + rebuild retry.
1476
+ // 502 "bad gateway" (host alive, local service down) also gets one retry to cover
1477
+ // slow app starts.
1415
1478
  const unreachable = parsed.httpCode === '000' || parsed.httpCode === '';
1416
1479
  const migration = /服务已迁移/.test(String(result.stdout || ''));
1417
- if (unreachable || migration) {
1480
+ const gatewayTunnelError = parsed.gatewayTunnelError === true;
1481
+ const backendDown = parsed.badGateway === true;
1482
+ if (unreachable || migration || gatewayTunnelError || backendDown) {
1418
1483
  if (attempt === 1) {
1419
1484
  try {
1420
1485
  await execOneShot(
@@ -1426,6 +1491,20 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
1426
1491
  } catch {}
1427
1492
  continue;
1428
1493
  }
1494
+ // Retry exhausted: a URL the gateway itself reports as dead (or one pointing at
1495
+ // the retired pre-migration gateway) must NOT be handed over as a working
1496
+ // deployment link — previously this returned ok:true with a soft warning.
1497
+ if (gatewayTunnelError || migration) {
1498
+ return {
1499
+ ok: false,
1500
+ error: gatewayTunnelError
1501
+ ? 'DevBridge gateway reports the fresh tunnel URL as dead (404 "tunnel not found" / "port not configured") — the host session is not registered on the gateway. A rebuild was already attempted.'
1502
+ : 'DevBridge gateway serves a migration placeholder page (服务已迁移) — the URL points at the retired pre-migration gateway and cannot work.',
1503
+ authMode,
1504
+ httpCode: parsed.httpCode,
1505
+ hostLog: String(result.stdout || '').slice(0, 400),
1506
+ };
1507
+ }
1429
1508
  }
1430
1509
 
1431
1510
  const warnings = [];
@@ -1474,6 +1553,10 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
1474
1553
  }
1475
1554
  }
1476
1555
 
1556
+ if (parsed.badGateway)
1557
+ warnings.push(
1558
+ 'Tunnel reachable but the gateway reports "bad gateway" — the local service on the exposed port is not serving.',
1559
+ );
1477
1560
  if (parsed.httpCode !== '200' && parsed.httpCode !== '304') {
1478
1561
  warnings.push(
1479
1562
  `Tunnel reachable check returned HTTP ${parsed.httpCode} (expected 200/304). The URL may not be serving yet.`,
@@ -961,21 +961,6 @@ async function installOpenCode() {
961
961
  mkdirSync(opcPlugins, { recursive: true });
962
962
  copyFileSync(pluginSrc, join(opcPlugins, 'skill-tracker.js'));
963
963
  console.log(` Plugin -> ${opcPlugins}`);
964
- // OpenCode native plugin (#905 OpenCode performance): bridges all 42 MCP tools
965
- // into OpenCode in-process custom tools (zod schemas + callTool), plus safety
966
- // gate and telemetry. Local plugins are auto-loaded from this directory.
967
- const nativePluginSrc = join(PACKAGE_ROOT, 'integrations', 'opencode', 'plugins', 'huaweicloud-devkit.js');
968
- if (existsSync(nativePluginSrc)) {
969
- copyFileSync(nativePluginSrc, join(opcPlugins, 'huaweicloud-devkit.js'));
970
- // Deps for the native plugin (zod) live next to the OpenCode config so the
971
- // plugin can require them at load time.
972
- const nativePkgSrc = join(PACKAGE_ROOT, 'integrations', 'opencode', 'package.json');
973
- const nativePkgDst = join(configRoot('opencode'), 'package.json');
974
- if (existsSync(nativePkgSrc) && !existsSync(nativePkgDst)) {
975
- copyFileSync(nativePkgSrc, nativePkgDst);
976
- }
977
- console.log(` Native plugin -> ${join(opcPlugins, 'huaweicloud-devkit.js')}`);
978
- }
979
964
  updateOpenCodeConfig(pluginDest);
980
965
  installRuntimeDeps(pluginDest);
981
966
  }
@@ -1009,11 +994,6 @@ function uninstallOpenCode() {
1009
994
  removeIfExists(pluginFile);
1010
995
  console.log(' Removed plugin');
1011
996
  }
1012
- const nativePluginFile = join(configRoot('opencode'), 'plugins', 'huaweicloud-devkit.js');
1013
- if (existsSync(nativePluginFile)) {
1014
- removeIfExists(nativePluginFile);
1015
- console.log(' Removed native plugin');
1016
- }
1017
997
 
1018
998
  if (removeIfExists(opencodePluginsDir())) {
1019
999
  console.log(' Removed MCP server and safety policy');
@@ -1061,17 +1041,6 @@ async function updateOpenCode() {
1061
1041
  mkdirSync(opcPlugins, { recursive: true });
1062
1042
  copyFileSync(pluginSrc, join(opcPlugins, 'skill-tracker.js'));
1063
1043
  console.log(` Plugin updated -> ${opcPlugins}`);
1064
- // Native plugin refresh (#905 OpenCode performance) — overwrite in place.
1065
- const nativePluginSrc = join(PACKAGE_ROOT, 'integrations', 'opencode', 'plugins', 'huaweicloud-devkit.js');
1066
- if (existsSync(nativePluginSrc)) {
1067
- copyFileSync(nativePluginSrc, join(opcPlugins, 'huaweicloud-devkit.js'));
1068
- const nativePkgSrc = join(PACKAGE_ROOT, 'integrations', 'opencode', 'package.json');
1069
- const nativePkgDst = join(configRoot('opencode'), 'package.json');
1070
- if (existsSync(nativePkgSrc) && !existsSync(nativePkgDst)) {
1071
- copyFileSync(nativePkgSrc, nativePkgDst);
1072
- }
1073
- console.log(` Native plugin updated -> ${join(opcPlugins, 'huaweicloud-devkit.js')}`);
1074
- }
1075
1044
  updateOpenCodeConfig(pluginDest);
1076
1045
  mkdirSync(pluginDest, { recursive: true });
1077
1046
  writeFileSync(join(pluginDest, '.installed'), new Date().toISOString());
@@ -1,24 +0,0 @@
1
- {
2
- "name": "@huaweicloud-devkit/opencode-integration",
3
- "version": "0.0.0",
4
- "lockfileVersion": 3,
5
- "requires": true,
6
- "packages": {
7
- "": {
8
- "name": "@huaweicloud-devkit/opencode-integration",
9
- "version": "0.0.0",
10
- "dependencies": {
11
- "zod": "^3.11.0"
12
- }
13
- },
14
- "node_modules/zod": {
15
- "version": "3.25.76",
16
- "resolved": "https://repo.huaweicloud.com/repository/npm/zod/-/zod-3.25.76.tgz",
17
- "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
18
- "license": "MIT",
19
- "funding": {
20
- "url": "https://github.com/sponsors/colinhacks"
21
- }
22
- }
23
- }
24
- }
@@ -1,10 +0,0 @@
1
- {
2
- "name": "@huaweicloud-devkit/opencode-integration",
3
- "version": "0.0.0",
4
- "private": true,
5
- "type": "module",
6
- "description": "Local deps for the huaweicloud-devkit OpenCode plugin (zod for tool schema conversion). Installed into ~/.config/opencode by 'npx huaweicloud-devkit install --target opencode'.",
7
- "dependencies": {
8
- "zod": "^3.11.0"
9
- }
10
- }
@@ -1,194 +0,0 @@
1
- // huaweicloud-devkit OpenCode native plugin
2
- //
3
- // Bridges all 42 MCP tools into OpenCode in-process custom tools via the shared
4
- // callTool() business layer, plus:
5
- // - tool.execute.before safety gate (classifyHcloudArgs / classifyTextCommand)
6
- // - skill-tracker telemetry (superset of the legacy skill-tracker.js plugin)
7
- // - STS command-level injection is inherited from callTool -> runHcloud chain
8
- //
9
- // Install: npm i -g huaweicloud-devkit && npx huaweicloud-devkit install --target opencode
10
- // (this file is copied to ~/.config/opencode/plugins/huaweicloud-devkit.js and
11
- // auto-loaded by OpenCode at startup — local plugins need no config entry)
12
- //
13
- // Compatibility contract (unchanged from MCP mode):
14
- // - tool names stay `huaweicloud_*` (skills/hook matchers keep working)
15
- // - approval gate: run_approved_command still requires args+approvalToken+approvedByUser
16
- // - credentials: resolveCredentials reads env (HW_*) then S1 — same priority as MCP
17
-
18
- import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
19
- import { join, dirname } from 'node:path';
20
- import { fileURLToPath } from 'node:url';
21
- import { createRequire } from 'node:module';
22
-
23
- const require = createRequire(import.meta.url);
24
- const z = require('zod');
25
-
26
- // devkit core — resolved from the installed plugin tree next to this file
27
- const selfDir = dirname(fileURLToPath(import.meta.url));
28
- const DEVKIT_SRC = join(selfDir, '..', 'huaweicloud-plugins', 'src');
29
-
30
- let devkit = null;
31
- let loadError = null;
32
- try {
33
- devkit = await import(`file://${join(DEVKIT_SRC, 'tools.mjs')}`);
34
- } catch (error) {
35
- loadError = error;
36
- }
37
-
38
- // ── telemetry (superset of legacy skill-tracker.js) ─────────────────────────
39
-
40
- const TELEMETRY_DIR = join(DEVKIT_SRC, '..', 'telemetry');
41
- function writeEvent(key, value, extra = {}) {
42
- try {
43
- if (!existsSync(TELEMETRY_DIR)) mkdirSync(TELEMETRY_DIR, { recursive: true });
44
- appendFileSync(join(TELEMETRY_DIR, 'hook-events.jsonl'), JSON.stringify({ key, value, ...extra }) + '\n');
45
- } catch {
46
- /* telemetry must never break the host */
47
- }
48
- }
49
-
50
- function isHuaweiCloudSkill(name) {
51
- return typeof name === 'string' && /^huawei/i.test(name);
52
- }
53
-
54
- // ── JSON Schema -> Zod converter (covers the devkit inputSchema subset) ─────
55
-
56
- function jsonSchemaToZod(schema) {
57
- // schema is { type:'object', properties:{...}, required:[...] }
58
- const shape = {};
59
- const required = new Set(schema?.required || []);
60
- for (const [name, prop] of Object.entries(schema?.properties || {})) {
61
- let node;
62
- switch (prop?.type) {
63
- case 'string':
64
- node = z.string();
65
- if (prop.enum) {
66
- const vals = prop.enum.filter((v) => typeof v === 'string');
67
- if (vals.length > 0) node = z.enum(vals);
68
- }
69
- break;
70
- case 'number':
71
- case 'integer':
72
- node = z.number();
73
- break;
74
- case 'boolean':
75
- node = z.boolean();
76
- break;
77
- case 'array':
78
- node = z.array(z.any());
79
- break;
80
- case 'object':
81
- default:
82
- node = z.any(); // nested objects (e.g. git{repo_url}) stay permissive
83
- }
84
- if (prop?.description) node = node.describe(prop.description);
85
- shape[name] = required.has(name) ? node : node.optional();
86
- }
87
- return shape;
88
- }
89
-
90
- // ── safety gate (mirrors the MCP/hook deny semantics) ──────────────────────
91
-
92
- const DENY_TOOLS = new Set([
93
- 'huaweicloud_run_approved_command', // has its own in-tool approval gate (token check)
94
- ]);
95
-
96
- async function safetyGate(toolName, args) {
97
- if (!devkit) return; // devkit failed to load -> tools absent, nothing to gate
98
- // read-only and planning tools pass through: callTool re-runs the full policy
99
- // layer anyway (classifyHcloudArgs inside plan/read paths). The gate below is
100
- // a fast pre-check for the raw hcloud-ish tools only.
101
- if (DENY_TOOLS.has(toolName)) return;
102
- }
103
-
104
- // ── tool bridging ───────────────────────────────────────────────────────────
105
-
106
- function buildBridgedTools() {
107
- if (!devkit) return {};
108
- const tools = {};
109
- const definitions = devkit.TOOL_DEFINITIONS || [];
110
- for (const def of definitions) {
111
- tools[def.name] = {
112
- description: def.description,
113
- args: jsonSchemaToZod(def.inputSchema || { type: 'object', properties: {} }),
114
- async execute(args, context) {
115
- const started = Date.now();
116
- try {
117
- await safetyGate(def.name, args);
118
- const result = await devkit.callTool(def.name, args, { cwd: context?.directory });
119
- writeEvent('tool:invoke', def.name, { durationMs: Date.now() - started });
120
- return typeof result === 'string' ? result : { output: JSON.stringify(result, null, 2) };
121
- } catch (error) {
122
- // crash containment: never take the host down with a tool error
123
- const payload = {
124
- error: error?.message || String(error),
125
- code: error?.code,
126
- action: error?.action || error?.nextStep,
127
- };
128
- writeEvent('tool:error', def.name, { message: payload.error });
129
- return { output: JSON.stringify(payload, null, 2) };
130
- }
131
- },
132
- };
133
- }
134
- return tools;
135
- }
136
-
137
- // ── plugin export (v1 Hooks contract, IDE + CLI dual form) ─────────────────
138
-
139
- function getHooks() {
140
- return {
141
- 'tool.execute.before': function (input, output) {
142
- try {
143
- if (input?.tool === 'skill') {
144
- const name = output?.args?.name;
145
- if (isHuaweiCloudSkill(name)) writeEvent('skill:retrieve', name);
146
- return;
147
- }
148
- if (input?.tool === 'bash') {
149
- const cmd = output?.args?.command || '';
150
- if (!cmd) return;
151
- // CLI read/write classification (legacy skill-tracker behavior)
152
- const isHcloud = /(?:^|[;&|]\s*)hcloud(?:\.exe)?\s+/i.test(cmd);
153
- if (isHcloud) {
154
- const isRead = /\b(List|Show|Get|Describe|NovaList|NovaShow)\w*/i.test(cmd);
155
- writeEvent(isRead ? 'cli:read' : 'cli:write', cmd.slice(0, 120), { capability: 'cli' });
156
- }
157
- }
158
- } catch {
159
- /* hooks must never throw */
160
- }
161
- },
162
- event: function ({ event }) {
163
- try {
164
- if (event?.type === 'message.part.updated') {
165
- const text = event?.properties?.part?.text;
166
- if (typeof text === 'string') {
167
- const m = text.match(/Base directory for this skill:\s*.*?skills[/\\]([a-z0-9-]+)/i);
168
- if (m && isHuaweiCloudSkill(m[1])) writeEvent('skill:retrieve', m[1]);
169
- }
170
- }
171
- } catch {
172
- /* ignore */
173
- }
174
- },
175
- };
176
- }
177
-
178
- function plugin() {
179
- const hooks = getHooks();
180
- if (loadError) {
181
- // Surface a single actionable line instead of breaking startup
182
- console.error(`[huaweicloud-devkit] core failed to load: ${loadError.message}`);
183
- console.error(`[huaweicloud-devkit] expected devkit src at ${DEVKIT_SRC}`);
184
- console.error(`[huaweicloud-devkit] run: npx huaweicloud-devkit install --target opencode`);
185
- return hooks; // telemetry still works; tools absent until install is re-run
186
- }
187
- hooks.tool = buildBridgedTools();
188
- return hooks;
189
- }
190
-
191
- plugin.id = 'huaweicloud-devkit';
192
- plugin.server = async () => plugin();
193
-
194
- export default plugin;