huaweicloud-devkit 1.1.13-next.5 → 1.1.13-next.6

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,7 +1,7 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
3
  "mcpName": "io.github.huaweicloud/huaweicloud-devkit",
4
- "version": "1.1.13-next.5",
4
+ "version": "1.1.13-next.6",
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.13-next.5",
20
+ "version": "1.1.13-next.6",
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.13-next.5",
23
+ "version": "1.1.13-next.6",
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.13-next.5",
20
+ "version": "1.1.13-next.6",
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.13-next.5",
3
+ "version": "1.1.13-next.6",
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.13-next.5",
3
+ "version": "1.1.13-next.6",
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.13-next.5",
5
+ "version": "1.1.13-next.6",
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.",
@@ -187,8 +187,10 @@ Setup is a **plugin-side preflight** — the developer should be asked a questio
187
187
  sleep 1
188
188
  # Remove stale nginx configs from previous deployments
189
189
  sudo rm -f /etc/nginx/conf.d/*.conf /etc/nginx/conf.d/*.conf.bak 2>/dev/null
190
- # Remove stale DevBridge tunnels
191
- devbridge delete-all 2>/dev/null || true
190
+ # Remove stale DevBridge tunnels only (keep other ports/sessions):
191
+ # list and delete orphan IDs; the automated connect cleanup does this too.
192
+ devbridge list
193
+ # devbridge delete <orphan-id>
192
194
  # Reload nginx to apply
193
195
  sudo nginx -s reload 2>/dev/null || true
194
196
  ```
@@ -358,7 +360,7 @@ fi
358
360
 
359
361
  - `-s` (silent) is **required** in the sandbox — without it the installer blocks reading `/dev/tty`.
360
362
  - **Old tunnels do not survive the upgrade**: 0.1.x-created tunnels are not registered on the s2 gateway — always rebuild the tunnel after upgrading (the expose flow below does this via pre-cleanup).
361
- - If `10006: quota exceeded` appears after upgrading, stale pre-upgrade tunnels still count against the quota — `devbridge delete-all` and retry.
363
+ - If `10006: quota exceeded` appears after upgrading, stale pre-upgrade tunnels still count against the quota — the expose tool now removes orphans first, then the oldest non-keepalive tunnel one at a time. Manually: `devbridge list` and `devbridge delete <id>` for stale entries; reserve `devbridge delete-all` for an explicit full reset.
362
364
 
363
365
  ### Sandbox filesystem persistence: what survives a reboot (#999)
364
366
 
@@ -423,10 +425,10 @@ devbridge auth status
423
425
  Run the web server and the tunnel in the background, then read the URL from the log; the app lives in the workspace mount, e.g. `/workspace/<repo-name>`:
424
426
 
425
427
  ```bash
426
- # 0. Pre-cleanup: kill old processes and stale tunnels
427
- pkill -f "devbridge host" 2>/dev/null || true
428
- sleep 2
429
- devbridge delete-all 2>/dev/null || true
428
+ # 0. Pre-cleanup: replace ONLY the tunnel bound to <port>; keep other ports/sessions.
429
+ # (huaweicloud_sandbox_expose_tunnel does this automatically with cleanup_mode=stale.)
430
+ OLD_ID=$(devbridge list 2>/dev/null | awk '$2 ~ /^tunnel-<port>-/ {print $1; exit}')
431
+ [ -n "$OLD_ID" ] && devbridge delete "$OLD_ID" >/dev/null 2>&1 || true
430
432
 
431
433
  # 1. Start tunnel
432
434
  if command -v setsid > /dev/null 2>&1; then setsid nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & else nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & fi
@@ -443,8 +445,9 @@ if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "304" ]; then
443
445
  echo "Tunnel verified: $TUNNEL_URL (HTTP $HTTP_CODE)"
444
446
  else
445
447
  echo "WARN: Tunnel URL unreachable (HTTP $HTTP_CODE). Rebuilding tunnel..."
446
- pkill -f "devbridge host" && sleep 2
447
- devbridge delete-all 2>/dev/null || true
448
+ # Rebuild only this port's tunnel; never kill/delete other ports or sessions.
449
+ OLD_ID=$(devbridge list 2>/dev/null | awk '$2 ~ /^tunnel-<port>-/ {print $1; exit}')
450
+ [ -n "$OLD_ID" ] && devbridge delete "$OLD_ID" >/dev/null 2>&1 || true
448
451
  if command -v setsid > /dev/null 2>&1; then setsid nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & else nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & fi
449
452
  disown 2>/dev/null || true
450
453
  sleep 10
@@ -465,8 +468,9 @@ fi
465
468
  # 0.2.x: `devbridge list` (plain table; data rows start with the 8-char tunnel ID)
466
469
  # 0.1.x (legacy): `devbridge list -j` (JSON)
467
470
  devbridge list
468
- # Step B: Remove all stale tunnels
469
- devbridge delete-all
471
+ # Step B: Remove stale tunnels one by one (the expose tool already does
472
+ # orphans first, then oldest non-keepalive; keep keepalive-protected tunnels)
473
+ # devbridge delete <stale-id>
470
474
  # Step C: Retry tunnel creation
471
475
  if command -v setsid > /dev/null 2>&1; then setsid nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & else nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & fi
472
476
  disown 2>/dev/null || true
@@ -1006,7 +1010,7 @@ Follow the standard [Expose the deployed app](#expose-the-deployed-app-public-ur
1006
1010
 
1007
1011
  Use `exec_with_session` to background DevBridge. For SSR, DevBridge tunnels the nginx public port (not the Node port directly).
1008
1012
 
1009
- **Pre-flight**: always run `devbridge delete-all` before creating a new tunnel to prevent `10006: quota exceeded` from accumulated stale tunnels. If you still get quota error, list tunnels with `devbridge list` (0.2.x plain table; 0.1.x legacy `-j` JSON), delete stale ones, and retry.
1013
+ **Pre-flight**: `huaweicloud_sandbox_expose_tunnel` now performs **selective cleanup** automatically (default `cleanup_mode=stale`): it removes orphan records/hosts and replaces the tunnel bound to the requested port; all other ports, other sessions and keepalive-protected tunnels are preserved (the same-port tunnel is the one documented exception — a port cannot have two tunnels). On `10006: quota exceeded` it removes all orphans first, then the oldest non-keepalive tunnel one at a time; keepalive-protected tunnels are never auto-deleted and a still-full quota returns `devbridge_tunnel_quota_exceeded`. `cleanup_mode=none` skips cleanup entirely (a stale same-port host can then conflict). For manual flows, prefer `devbridge list` + `devbridge delete <id>` for stale entries; use `devbridge delete-all` only for an explicit full reset (shared-sandbox warning below).
1010
1014
 
1011
1015
  > **Concurrency warning**: `devbridge delete-all` and `pkill -f "devbridge host"` are GLOBAL operations scoped to the whole sandbox — if multiple sessions share the same sandbox, pre-cleaning can kill tunnels published by other sessions and invalidate URLs you handed out earlier. For shared sandboxes, prefer `devbridge list` and delete only stale/your-own tunnels instead of blanket `delete-all`, and re-expose only after confirming the target port is still served.
1012
1016
 
@@ -1302,8 +1302,10 @@ export async function closeSession(workspaceId, username) {
1302
1302
  // fallback (devbridge-key.mjs) when keyring injection is unavailable.
1303
1303
  // AK/SK login (--access-key/--secret-key) was REMOVED: devbridge authentication is
1304
1304
  // now fixed to API Key only (定稿决策,2026-11-04).
1305
- // Every tier performs a real `devbridge auth login --api-key` and verifies with
1306
- // `auth status` (must print "Logged in"); on failure the waterfall falls through.
1305
+ // Every tier performs a real `devbridge auth login --api-key` (exit code checked),
1306
+ // then verifies with `auth status` AND a real authenticated API call (`devbridge list`);
1307
+ // on failure the waterfall falls through. The list check is required because a stale
1308
+ // key can leave a "Logged in" local token while server-side calls return 40100.
1307
1309
  // DB_ATTEMPT_* lines are non-sensitive diagnostics for support escalation.
1308
1310
  // The HOST orchestrates an auto-create fallback between probe rounds: when the
1309
1311
  // first probe returns NO_CREDENTIAL, the host calls ensureDevbridgeKey() and
@@ -1323,7 +1325,12 @@ export function buildDevbridgeAuthProbe() {
1323
1325
  `DB_VERSION=$(devbridge version 2>/dev/null | head -1)`,
1324
1326
  `echo "DB_VERSION=\${DB_VERSION}"`,
1325
1327
  `DB_OK=0`,
1326
- `db_login_ok() { devbridge auth status 2>/dev/null | grep -q "Logged in"; }`,
1328
+ `db_login_ok() {`,
1329
+ // 本地 token 可能仍显示 Logged in,但 API key 已在服务端失效(实测 list/renew 返回 40100)。
1330
+ // 因此额外做一次真实鉴权 API 调用,确保探针结果可用(否则 auto-create 自愈不会触发)。
1331
+ ` devbridge auth status 2>/dev/null | grep -q "Logged in" || return 1`,
1332
+ ` devbridge list >/dev/null 2>&1`,
1333
+ `}`,
1327
1334
  `db_diag() { echo "DB_ATTEMPT_$1=$2"; }`,
1328
1335
  ``,
1329
1336
  `# ---- Tier 1: API Key from the sandbox keyring (platform auto-injection) ----`,
@@ -1353,8 +1360,7 @@ export function buildDevbridgeAuthProbe() {
1353
1360
  ` db_diag KEYRING "hwcloud-not-installed"`,
1354
1361
  `fi`,
1355
1362
  `if [ -n "$HW_DB_KEY" ]; then`,
1356
- ` devbridge auth login --api-key "$HW_DB_KEY" >/tmp/db_auth.log 2>&1`,
1357
- ` if db_login_ok; then`,
1363
+ ` if devbridge auth login --api-key "$HW_DB_KEY" >/tmp/db_auth.log 2>&1 && db_login_ok; then`,
1358
1364
  ` echo "DB_AUTH_MODE=API_KEY_KEYRING"`,
1359
1365
  ` DB_OK=1`,
1360
1366
  ` else`,
@@ -1368,8 +1374,7 @@ export function buildDevbridgeAuthProbe() {
1368
1374
  `if [ "$DB_OK" -eq 0 ]; then`,
1369
1375
  ` source /tmp/hw_api_key 2>/dev/null`,
1370
1376
  ` if [ -n "$HW_API_KEY" ]; then`,
1371
- ` devbridge auth login --api-key "$HW_API_KEY" >/tmp/db_auth.log 2>&1`,
1372
- ` if db_login_ok; then`,
1377
+ ` if devbridge auth login --api-key "$HW_API_KEY" >/tmp/db_auth.log 2>&1 && db_login_ok; then`,
1373
1378
  ` echo "DB_AUTH_MODE=API_KEY_FILE"`,
1374
1379
  ` DB_OK=1`,
1375
1380
  ` else`,
@@ -1450,9 +1455,8 @@ export const DEVBRIDGE_HOST_EXPIRY_HOURS = 72;
1450
1455
  export function buildDevbridgeExposeScript(port, expiryHours = DEVBRIDGE_HOST_EXPIRY_HOURS) {
1451
1456
  const hostLog = '/tmp/host.log';
1452
1457
  return [
1453
- `pkill -f "devbridge host" 2>/dev/null || true`,
1454
- `sleep 2`,
1455
- `devbridge delete-all > /dev/null 2>&1 || true`,
1458
+ // Pre-clean is now selective (cleanupDevbridgeTunnels) and runs before this
1459
+ // script, so other ports/sessions are preserved.
1456
1460
  // #922 (S8): setsid is unavailable on some sandbox images — fall back to a
1457
1461
  // bare nohup launch so the host still starts; persistence is then judged by
1458
1462
  // the independent re-verify below, which emits the 请勿分享 warning in the
@@ -1735,6 +1739,7 @@ export function buildDevbridgeKeepaliveScript(tunnels) {
1735
1739
  const body = [
1736
1740
  `#!/bin/bash`,
1737
1741
  `export PATH="$HOME/.huawei/bin:$PATH"`,
1742
+ `export DBUS_SESSION_BUS_ADDRESS="\${DBUS_SESSION_BUS_ADDRESS:-unix:path=/run/dbus-session}"`,
1738
1743
  `# entries: tunnelId:url (URLs are stable — tunnel IDs never change)`,
1739
1744
  `TUNNELS="${entries}"`,
1740
1745
  `declare -A LAST_RENEW`,
@@ -1753,6 +1758,26 @@ export function buildDevbridgeKeepaliveScript(tunnels) {
1753
1758
  ` echo "$TS renew $ID ok (${DEVBRIDGE_RENEW_HOURS}h)" >> /tmp/tunnel-keepalive.log`,
1754
1759
  ` else`,
1755
1760
  ` echo "$TS renew $ID FAILED: $(head -1 /tmp/keepalive-last-update.log)" >> /tmp/tunnel-keepalive.log`,
1761
+ ` # Self-heal: CLI token 过期但 key 仍有效时,重新登录后立即重试一次`,
1762
+ ` DB_KEY=""`,
1763
+ ` if command -v hwcloud >/dev/null 2>&1; then`,
1764
+ ` DB_KEY=$(hwcloud keyring get HW_DEVBRIDGE_API_KEY 2>&1 || true)`,
1765
+ ` case "$DB_KEY" in "devbridge_"*) : ;; *) DB_KEY="";; esac`,
1766
+ ` fi`,
1767
+ ` if [ -z "$DB_KEY" ] && [ -s /tmp/hw_api_key ]; then`,
1768
+ ` source /tmp/hw_api_key 2>/dev/null || true`,
1769
+ ` DB_KEY="\${HW_API_KEY:-}"`,
1770
+ ` fi`,
1771
+ ` if [ -n "$DB_KEY" ] && devbridge auth login --api-key "$DB_KEY" >/tmp/keepalive-auth.log 2>&1; then`,
1772
+ ` if devbridge update "$ID" -e ${DEVBRIDGE_RENEW_HOURS} > /tmp/keepalive-last-update.log 2>&1; then`,
1773
+ ` echo "$TS renew $ID ok after re-login (${DEVBRIDGE_RENEW_HOURS}h)" >> /tmp/tunnel-keepalive.log`,
1774
+ ` else`,
1775
+ ` echo "$TS renew $ID FAILED after re-login: $(head -1 /tmp/keepalive-last-update.log)" >> /tmp/tunnel-keepalive.log`,
1776
+ ` fi`,
1777
+ ` else`,
1778
+ ` echo "$TS renew $ID AUTH_FAILED: no usable key; next expose_tunnel will auto-create" >> /tmp/tunnel-keepalive.log`,
1779
+ ` fi`,
1780
+ ` unset DB_KEY HW_API_KEY 2>/dev/null`,
1756
1781
  ` fi`,
1757
1782
  ` LAST_RENEW[$ID]=$NOW`,
1758
1783
  ` fi`,
@@ -1854,6 +1879,337 @@ export async function installKeepaliveWatchdog(workspaceId, { tunnels }, usernam
1854
1879
  };
1855
1880
  }
1856
1881
 
1882
+ // ── DevBridge selective cleanup ─────────────────────────────────────────────
1883
+ // delete-all is intentionally NOT used: it kills other sessions/ports on a shared
1884
+ // sandbox. Cleanup removes only orphan tunnels/hosts and (for expose) tunnels bound
1885
+ // to the same port, preserving keepalive-protected and live-unknown tunnels.
1886
+ // Same-port is the one documented exception: a keepalive-protected tunnel on the
1887
+ // target port is replaced (you cannot bind two tunnels to one port).
1888
+
1889
+ // Same charset as validateTunnelId: real IDs may contain 0/1/8/9, so parsing must
1890
+ // not assume the base32 subset.
1891
+ const DEVBRIDGE_TUNNEL_ID_RE = /^[A-Za-z0-9_-]{4,32}$/;
1892
+ const DEVBRIDGE_PATH_EXPORT = 'export PATH="/workspace/.local/bin:$HOME/.huawei/bin:$PATH"';
1893
+ const DEVBRIDGE_LOCK_PATH = '/tmp/devbridge-cleanup.lock';
1894
+ // Worst-case hold: state 60x2 + apply 60x2 + sync ~150 + release 60 ≈ 450s;
1895
+ // keep the stale threshold above the worst case so a second cleanup cannot
1896
+ // break a lock that is still legitimately held.
1897
+ const DEVBRIDGE_LOCK_STALE_MINUTES = 10;
1898
+ const DEVBRIDGE_CLEANUP_TIMEOUT_MS = 60000;
1899
+
1900
+ export function normalizeCleanupMode(raw) {
1901
+ return ['stale', 'all', 'none'].includes(raw) ? raw : 'stale';
1902
+ }
1903
+
1904
+ export function parseDevbridgeTunnelList(stdout) {
1905
+ const text = stripTerminalEscapes(String(stdout || ''));
1906
+ const tunnels = [];
1907
+ for (const line of text.split(/\r?\n/)) {
1908
+ if (/^\s*Tunnel\s+ID\b/i.test(line)) continue;
1909
+ const m = line.match(/^\s*([A-Za-z0-9_-]{4,32})\s+(.+)$/);
1910
+ if (!m || !DEVBRIDGE_TUNNEL_ID_RE.test(m[1])) continue;
1911
+ const id = m[1];
1912
+ const rest = m[2];
1913
+ const name = rest.trim().split(/\s{2,}|\t+/)[0] || '';
1914
+ const portMatch = rest.match(/tunnel-(\d+)-/);
1915
+ const expMatch = rest.match(/(\d+)\s*(day|days|hour|hours|minute|minutes)/i);
1916
+ const unit = expMatch ? expMatch[2].toLowerCase() : '';
1917
+ const multiplier = { day: 86400, days: 86400, hour: 3600, hours: 3600, minute: 60, minutes: 60 }[unit];
1918
+ tunnels.push({
1919
+ id,
1920
+ name,
1921
+ port: portMatch ? Number(portMatch[1]) : null,
1922
+ expiryText: expMatch ? expMatch[0] : '',
1923
+ expirySeconds: expMatch && multiplier ? Number(expMatch[1]) * multiplier : null,
1924
+ });
1925
+ }
1926
+ return tunnels;
1927
+ }
1928
+
1929
+ export function parseDevbridgeHostProcesses(stdout) {
1930
+ const text = stripTerminalEscapes(String(stdout || ''));
1931
+ const hosts = [];
1932
+ for (const line of text.split(/\r?\n/)) {
1933
+ const m = line.match(/^\s*(\d+)\s+(.*)$/);
1934
+ if (!m) continue;
1935
+ // Anchor the executable so a shell wrapper (`bash -c "...devbridge host..."`)
1936
+ // is never mistaken for a real host process.
1937
+ const execMatch = m[2].match(/^(?:\S*\/)?devbridge\s+host\b(.*)$/);
1938
+ if (!execMatch) continue;
1939
+ const rest = execMatch[1].trim();
1940
+ const tokens = rest ? rest.split(/\s+/) : [];
1941
+ let id = null;
1942
+ let port = null;
1943
+ for (let i = 0; i < tokens.length; i += 1) {
1944
+ const token = tokens[i];
1945
+ if (token === '-p' && /^\d+$/.test(tokens[i + 1] || '')) {
1946
+ port = Number(tokens[i + 1]);
1947
+ i += 1;
1948
+ } else if (token === '-e') {
1949
+ i += 1;
1950
+ } else if (token.startsWith('-')) {
1951
+ // unknown flag: skip it
1952
+ } else if (!id && DEVBRIDGE_TUNNEL_ID_RE.test(token) && !/^\d+$/.test(token)) {
1953
+ id = token;
1954
+ }
1955
+ }
1956
+ if (!id && port == null) continue; // unparseable host: ignore, never kill blindly
1957
+ hosts.push({ pid: Number(m[1]), id, port });
1958
+ }
1959
+ return hosts;
1960
+ }
1961
+
1962
+ /**
1963
+ * Decide which tunnels/hosts to remove.
1964
+ * - protected: keepalive TUNNELS entries (never removed unless mode=all or samePort)
1965
+ * - orphan: tunnel record with no live host -> removed in stale/quota
1966
+ * - orphan host: host process with no matching tunnel record -> killed
1967
+ * - quota: also removes the oldest non-protected live tunnels (maxExtra per call)
1968
+ */
1969
+ export function planDevbridgeCleanup({
1970
+ tunnels = [],
1971
+ hosts = [],
1972
+ keepalive = [],
1973
+ mode = 'stale',
1974
+ samePort = null,
1975
+ maxExtra = 1,
1976
+ } = {}) {
1977
+ if (mode === 'none') return { deleteIds: [], killPids: [] };
1978
+
1979
+ const tunnelIds = new Set(tunnels.map((t) => t.id));
1980
+ const protectedIds = new Set(keepalive.map((k) => k.tunnelId).filter(Boolean));
1981
+ const portById = new Map();
1982
+ for (const t of tunnels) if (t.port != null) portById.set(t.id, t.port);
1983
+ for (const k of keepalive) {
1984
+ const m = String(k.url || '').match(/-(\d+)\.devbridge-s2\./);
1985
+ if (m && tunnelIds.has(k.tunnelId)) portById.set(k.tunnelId, Number(m[1]));
1986
+ }
1987
+
1988
+ if (mode === 'all') {
1989
+ return { deleteIds: [...tunnelIds], killPids: hosts.map((h) => h.pid) };
1990
+ }
1991
+
1992
+ const liveIds = new Set();
1993
+ const mappedPids = new Set();
1994
+ for (const h of hosts) {
1995
+ if (!h.id && h.port == null) continue; // unattributable host: never kill blindly
1996
+ if (h.id && tunnelIds.has(h.id)) {
1997
+ liveIds.add(h.id);
1998
+ mappedPids.add(h.pid);
1999
+ } else if (h.port != null) {
2000
+ for (const [id, port] of portById) {
2001
+ if (port === h.port) {
2002
+ liveIds.add(id);
2003
+ mappedPids.add(h.pid);
2004
+ }
2005
+ }
2006
+ }
2007
+ }
2008
+
2009
+ const killPids = new Set(hosts.filter((h) => (h.id || h.port != null) && !mappedPids.has(h.pid)).map((h) => h.pid));
2010
+ const samePortIds = samePort == null ? [] : tunnels.filter((t) => portById.get(t.id) === samePort).map((t) => t.id);
2011
+ const orphanIds = tunnels.filter((t) => !protectedIds.has(t.id) && !liveIds.has(t.id)).map((t) => t.id);
2012
+ const deleteSet = new Set([...orphanIds, ...samePortIds]);
2013
+
2014
+ if (mode === 'quota') {
2015
+ const candidates = tunnels
2016
+ .filter((t) => !protectedIds.has(t.id) && !deleteSet.has(t.id) && liveIds.has(t.id))
2017
+ .sort((a, b) => (a.expirySeconds ?? Number.MAX_SAFE_INTEGER) - (b.expirySeconds ?? Number.MAX_SAFE_INTEGER));
2018
+ for (let i = 0; i < Math.max(0, maxExtra) && i < candidates.length; i += 1) {
2019
+ deleteSet.add(candidates[i].id);
2020
+ }
2021
+ }
2022
+
2023
+ for (const h of hosts) {
2024
+ if (h.id && deleteSet.has(h.id)) killPids.add(h.pid);
2025
+ else if (h.port != null && [...deleteSet].some((id) => portById.get(id) === h.port)) killPids.add(h.pid);
2026
+ }
2027
+
2028
+ return { deleteIds: [...deleteSet], killPids: [...killPids] };
2029
+ }
2030
+
2031
+ export function buildDevbridgeCleanupApplyScript({ deleteIds = [], killPids = [] } = {}) {
2032
+ for (const id of deleteIds) {
2033
+ if (!DEVBRIDGE_TUNNEL_ID_RE.test(String(id))) {
2034
+ throw new Error(`devbridge cleanup: invalid tunnel id ${JSON.stringify(id)}`);
2035
+ }
2036
+ }
2037
+ for (const pid of killPids) {
2038
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
2039
+ throw new Error(`devbridge cleanup: invalid pid ${JSON.stringify(pid)}`);
2040
+ }
2041
+ }
2042
+ return [
2043
+ DEVBRIDGE_PATH_EXPORT,
2044
+ // Re-check the process identity before killing: state→apply can take minutes,
2045
+ // so a recycled PID must not hit an unrelated process.
2046
+ ...killPids.map(
2047
+ (pid) =>
2048
+ `if ps -p ${pid} -o args= 2>/dev/null | grep -q "devbridge host"; then kill ${pid} >/dev/null 2>&1 || true; fi`,
2049
+ ),
2050
+ `sleep 1`,
2051
+ ...deleteIds.map((id) => `devbridge delete ${id} >/dev/null 2>&1 || true`),
2052
+ `echo "DB_CLEANUP_DELETED=${deleteIds.join(',')}"`,
2053
+ `echo "DB_CLEANUP_KILLED=${killPids.join(',')}"`,
2054
+ ].join('\n');
2055
+ }
2056
+
2057
+ export function buildDevbridgeCleanupStateScript() {
2058
+ return [
2059
+ DEVBRIDGE_PATH_EXPORT,
2060
+ // Refresh the CLI token from an existing key. A valid API key can leave an
2061
+ // expired CLI token; without this, devbridge list returns 40100 and cleanup
2062
+ // is skipped even though the key is usable. Never creates a new key here.
2063
+ `DB_KEY=""`,
2064
+ `if command -v hwcloud >/dev/null 2>&1; then`,
2065
+ ` DB_KEY=$(hwcloud keyring get HW_DEVBRIDGE_API_KEY 2>&1 || true)`,
2066
+ ` case "$DB_KEY" in "devbridge_"*) : ;; *) DB_KEY="";; esac`,
2067
+ `fi`,
2068
+ `if [ -z "$DB_KEY" ] && [ -s /tmp/hw_api_key ]; then`,
2069
+ ` source /tmp/hw_api_key 2>/dev/null || true`,
2070
+ ` DB_KEY="\${HW_API_KEY:-}"`,
2071
+ `fi`,
2072
+ `if [ -n "$DB_KEY" ]; then devbridge auth login --api-key "$DB_KEY" >/dev/null 2>&1 || true; fi`,
2073
+ `unset DB_KEY HW_API_KEY 2>/dev/null`,
2074
+ `echo "DB_CLEANUP_TUNNELS_BEGIN"`,
2075
+ `devbridge list 2>&1 || true`,
2076
+ `echo "DB_CLEANUP_TUNNELS_END"`,
2077
+ `echo "DB_CLEANUP_HOSTS_BEGIN"`,
2078
+ `pgrep -af "devbridge host" 2>&1 || true`,
2079
+ `echo "DB_CLEANUP_HOSTS_END"`,
2080
+ buildKeepaliveTunnelsReadScript(),
2081
+ ].join('\n');
2082
+ }
2083
+
2084
+ export function buildDevbridgeCleanupLockScript() {
2085
+ return [
2086
+ `LOCK=${DEVBRIDGE_LOCK_PATH}`,
2087
+ `if mkdir "$LOCK" 2>/dev/null; then echo LOCK_OK; else`,
2088
+ ` if find "$LOCK" -maxdepth 0 -mmin +${DEVBRIDGE_LOCK_STALE_MINUTES} 2>/dev/null | grep -q .; then rmdir "$LOCK" 2>/dev/null || true; fi`,
2089
+ ` if mkdir "$LOCK" 2>/dev/null; then echo LOCK_OK; else echo LOCK_BUSY; fi`,
2090
+ `fi`,
2091
+ ].join('\n');
2092
+ }
2093
+
2094
+ function cleanupSection(text, name) {
2095
+ const m = String(text || '').match(new RegExp(`DB_CLEANUP_${name}_BEGIN\\r?\\n([\\s\\S]*?)DB_CLEANUP_${name}_END`));
2096
+ return m ? m[1] : '';
2097
+ }
2098
+
2099
+ async function execCleanupWithRetry(workspaceId, script, username, timeoutMs = 60000) {
2100
+ let lastError;
2101
+ for (let i = 1; i <= 2; i += 1) {
2102
+ try {
2103
+ return await execOneShot(workspaceId, script, username, timeoutMs);
2104
+ } catch (error) {
2105
+ lastError = error;
2106
+ if (i === 2 || !/timeout|websocket/i.test(String(error?.message || ''))) throw error;
2107
+ await new Promise((r) => setTimeout(r, 1000));
2108
+ }
2109
+ }
2110
+ throw lastError;
2111
+ }
2112
+
2113
+ async function syncKeepaliveAfterCleanup(workspaceId, { deletedIds, liveTunnelIds, pruneMissing, username }) {
2114
+ let current;
2115
+ try {
2116
+ const read = await execCleanupWithRetry(workspaceId, buildKeepaliveTunnelsReadScript(), username, 30000);
2117
+ current = parseKeepaliveTunnelsOutput(read.stdout);
2118
+ } catch {
2119
+ return;
2120
+ }
2121
+ if (!current.length) return;
2122
+ const deleted = new Set(deletedIds || []);
2123
+ // Prune deleted IDs AND entries that no longer exist in the management plane
2124
+ // (ghost tunnels from earlier delete-all calls would otherwise be re-hosted
2125
+ // and renewed forever).
2126
+ // Only prune "absent" entries when we have at least one confirmed live tunnel
2127
+ // (or an explicit all-mode reset). A transient empty list must not wipe the
2128
+ // watchdog and silently stop renewals.
2129
+ const remaining = current.filter((t) => !deleted.has(t.tunnelId) && (!pruneMissing || liveTunnelIds.has(t.tunnelId)));
2130
+ if (remaining.length === current.length) return;
2131
+ if (remaining.length === 0) {
2132
+ try {
2133
+ await execCleanupWithRetry(
2134
+ workspaceId,
2135
+ `pkill -f "tunnel-keepalive.sh" 2>/dev/null || true; rm -f /tmp/tunnel-keepalive.sh`,
2136
+ username,
2137
+ 30000,
2138
+ );
2139
+ } catch {}
2140
+ return;
2141
+ }
2142
+ try {
2143
+ await installKeepaliveWatchdog(workspaceId, { tunnels: remaining }, username, 30000);
2144
+ } catch {}
2145
+ }
2146
+
2147
+ export async function cleanupDevbridgeTunnels(
2148
+ workspaceId,
2149
+ { mode = 'stale', samePort = null, maxExtra = 1, username = 'root' } = {},
2150
+ ) {
2151
+ if (!workspaceId) throw new Error('devbridge cleanup: workspace_id is required.');
2152
+ if (mode === 'none') return { ok: true, skipped: true, deleteIds: [], killPids: [] };
2153
+
2154
+ let lockResult;
2155
+ try {
2156
+ lockResult = await execOneShot(workspaceId, buildDevbridgeCleanupLockScript(), username, 30000);
2157
+ } catch {
2158
+ return { ok: false, locked: true, deleteIds: [], killPids: [] };
2159
+ }
2160
+ if (!stripTerminalEscapes(lockResult.stdout).includes('LOCK_OK')) {
2161
+ return { ok: false, locked: true, deleteIds: [], killPids: [] };
2162
+ }
2163
+
2164
+ try {
2165
+ const stateResult = await execCleanupWithRetry(workspaceId, buildDevbridgeCleanupStateScript(), username, 60000);
2166
+ const text = stripTerminalEscapes(stateResult.stdout);
2167
+ const tunnelSection = cleanupSection(text, 'TUNNELS');
2168
+ const listOk = /Tunnel ID|No tunnels found/i.test(tunnelSection);
2169
+ if (!listOk) {
2170
+ // list/auth/network failed — do not delete or kill anything on a guess.
2171
+ return { ok: false, listFailed: true, deleteIds: [], killPids: [] };
2172
+ }
2173
+ const tunnels = parseDevbridgeTunnelList(tunnelSection);
2174
+ const hosts = parseDevbridgeHostProcesses(cleanupSection(text, 'HOSTS'));
2175
+ // Output-format drift guard: header matched but rows didn't parse, while host
2176
+ // processes exist. Never mass-kill hosts on a parse we don't trust.
2177
+ if (tunnels.length === 0 && hosts.length > 0 && !/No tunnels found/i.test(tunnelSection)) {
2178
+ return { ok: false, listFailed: true, deleteIds: [], killPids: [] };
2179
+ }
2180
+ const keepalive = parseKeepaliveTunnelsOutput(text);
2181
+ const liveTunnelIds = new Set(tunnels.map((t) => t.id));
2182
+ const pruneMissing = mode === 'all' || liveTunnelIds.size > 0;
2183
+ const plan = planDevbridgeCleanup({ tunnels, hosts, keepalive, mode, samePort, maxExtra });
2184
+ if (plan.deleteIds.length === 0 && plan.killPids.length === 0) {
2185
+ await syncKeepaliveAfterCleanup(workspaceId, {
2186
+ deletedIds: [],
2187
+ liveTunnelIds,
2188
+ pruneMissing,
2189
+ username,
2190
+ });
2191
+ return { ok: true, deleteIds: [], killPids: [], tunnelCount: tunnels.length };
2192
+ }
2193
+ await execCleanupWithRetry(
2194
+ workspaceId,
2195
+ buildDevbridgeCleanupApplyScript(plan),
2196
+ username,
2197
+ DEVBRIDGE_CLEANUP_TIMEOUT_MS,
2198
+ );
2199
+ await syncKeepaliveAfterCleanup(workspaceId, {
2200
+ deletedIds: plan.deleteIds,
2201
+ liveTunnelIds,
2202
+ pruneMissing,
2203
+ username,
2204
+ });
2205
+ return { ok: true, ...plan, tunnelCount: tunnels.length };
2206
+ } finally {
2207
+ try {
2208
+ await execCleanupWithRetry(workspaceId, `rmdir ${DEVBRIDGE_LOCK_PATH} 2>/dev/null || true`, username, 30000);
2209
+ } catch {}
2210
+ }
2211
+ }
2212
+
1857
2213
  // Expose a sandbox port to a public DevBridge tunnel URL. `port` MUST be the
1858
2214
  // ACTUAL port reported by deployNginx (deploy_nginx auto-increments on conflict),
1859
2215
  // otherwise the tunnel is bound to a dead port. Returns ok, publicUrl, port,
@@ -1869,6 +2225,7 @@ export async function exposeTunnel(
1869
2225
  keepalive,
1870
2226
  poolType,
1871
2227
  poolContext,
2228
+ cleanupMode = 'stale',
1872
2229
  ensureDevbridgeKey,
1873
2230
  buildKeyringInjectScript,
1874
2231
  listKeysForQuotaHint,
@@ -1979,18 +2336,39 @@ export async function exposeTunnel(
1979
2336
  };
1980
2337
  }
1981
2338
 
2339
+ // Selective pre-clean: remove same-port tunnels + orphan records/hosts only.
2340
+ // Other ports and keepalive-protected tunnels are preserved.
2341
+ const validatedCleanupMode = normalizeCleanupMode(cleanupMode);
2342
+ try {
2343
+ await cleanupDevbridgeTunnels(workspaceId, {
2344
+ mode: validatedCleanupMode,
2345
+ samePort: validatedPort,
2346
+ username,
2347
+ });
2348
+ } catch {}
2349
+
1982
2350
  let result = null;
1983
2351
  let attempt = 0;
1984
- const maxAttempts = 2;
2352
+ let lastQuotaCleanup = null;
2353
+ const maxAttempts = 4;
1985
2354
  while (attempt < maxAttempts) {
1986
2355
  attempt += 1;
1987
2356
  const exposeScript = buildDevbridgeExposeScript(validatedPort, validatedExpiryHours);
1988
2357
  result = await execOneShot(workspaceId, exposeScript, username, timeoutMs);
1989
2358
  const parsed = parseDevbridgeExposeOutput(result.stdout);
1990
2359
 
1991
- // 10006 quota exceeded: clean up stale tunnels and retry.
2360
+ // 10006 quota exceeded: remove orphan tunnels first, then the oldest non-protected
2361
+ // live tunnel (one per retry) until the new tunnel fits. Never touches keepalive keys.
1992
2362
  if (parsed.quotaError && attempt < maxAttempts) {
1993
- continue;
2363
+ const quotaCleanup = await cleanupDevbridgeTunnels(workspaceId, {
2364
+ mode: 'quota',
2365
+ samePort: validatedPort,
2366
+ maxExtra: 1,
2367
+ username,
2368
+ }).catch(() => null);
2369
+ lastQuotaCleanup = quotaCleanup || lastQuotaCleanup;
2370
+ // Kill-only progress frees no tunnel quota; only a deleted record can.
2371
+ if (quotaCleanup?.deleteIds?.length) continue;
1994
2372
  }
1995
2373
  // Unreachable (000), migrated-gateway placeholder, or gateway-level tunnel errors
1996
2374
  // (404 "tunnel not found" / "port not configured"): one cleanup + rebuild retry.
@@ -2003,12 +2381,12 @@ export async function exposeTunnel(
2003
2381
  if (unreachable || migration || gatewayTunnelError || backendDown) {
2004
2382
  if (attempt === 1) {
2005
2383
  try {
2006
- await execOneShot(
2007
- workspaceId,
2008
- `pkill -f "devbridge host" 2>/dev/null || true; sleep 2; devbridge delete-all > /dev/null 2>&1 || true`,
2384
+ // Only the same-port tunnel + orphans; other ports/sessions stay untouched.
2385
+ await cleanupDevbridgeTunnels(workspaceId, {
2386
+ mode: 'stale',
2387
+ samePort: validatedPort,
2009
2388
  username,
2010
- 20000,
2011
- );
2389
+ });
2012
2390
  } catch {}
2013
2391
  continue;
2014
2392
  }
@@ -2029,8 +2407,20 @@ export async function exposeTunnel(
2029
2407
  }
2030
2408
 
2031
2409
  const warnings = [];
2032
- if (parsed.quotaError)
2033
- warnings.push('DevBridge tunnel quota (10006) hit — stale tunnels were cleaned up and the host was retried.');
2410
+ if (parsed.quotaError && !parsed.tunnelUrl) {
2411
+ return {
2412
+ ok: false,
2413
+ error:
2414
+ 'DevBridge tunnel quota exceeded (10006) after cleanup. Keepalive-protected tunnels are preserved; ' +
2415
+ 'remove unused tunnels or retry with cleanup_mode=all if a full reset is acceptable.',
2416
+ error_code: 'devbridge_tunnel_quota_exceeded',
2417
+ authMode: effectiveAuthMode,
2418
+ attempts,
2419
+ cleanup: lastQuotaCleanup || undefined,
2420
+ warnings: 'tunnel quota (10006) still full after removing orphan/oldest non-protected tunnels',
2421
+ hostLog: String(result.stdout || '').slice(0, 400),
2422
+ };
2423
+ }
2034
2424
 
2035
2425
  // #998: a host that never produced a Tunnel URL must NOT surface as ok:true
2036
2426
  // with an undefined publicUrl — that reads as success and sends the caller
@@ -2162,7 +2552,8 @@ export async function exposeTunnel(
2162
2552
  ? { sandboxLifetime: lifetimeNotice.sandboxLifetime, urlLifetimeHint: lifetimeNotice.urlLifetimeHint }
2163
2553
  : {}),
2164
2554
  tunnelId: parsed.tunnelId || undefined,
2165
- authMode,
2555
+ // 报告最终生效的认证方式:自动创建兜底成功后应为 API_KEY_AUTO(而非首轮 probe 的 NO_CREDENTIAL)
2556
+ authMode: effectiveAuthMode,
2166
2557
  httpCode: parsed.httpCode,
2167
2558
  verifyHttpCode: verifyHttpCode || undefined,
2168
2559
  verifyNotFound: verifyNotFound || undefined,
@@ -24,6 +24,7 @@ import {
24
24
  exposeTunnel,
25
25
  renewTunnel,
26
26
  installKeepaliveWatchdog,
27
+ cleanupDevbridgeTunnels,
27
28
  getCurrentWorkspaceId,
28
29
  setWorkspaceId,
29
30
  } from './sandbox/session-manager.mjs';
@@ -776,7 +777,7 @@ export const TOOL_DEFINITIONS = [
776
777
  {
777
778
  name: 'huaweicloud_sandbox_expose_tunnel',
778
779
  description:
779
- 'Expose a port on the sandbox to a public DevBridge tunnel URL (devbridge 0.2.x). Authenticates automatically (probing AK/SK vs API-Key builds, using /tmp/hw_creds.sh and /tmp/hw_api_key as injected by huaweicloud_sandbox_credentials), pre-cleans stale tunnels to avoid quota errors, starts the host, extracts the public URL (https://<id>-<port>.devbridge-s2.hwtunnel.com), and health-checks it before returning. AUTO-ENROLLS the tunnel into the keepalive watchdog (host re-attach + daily 720h renewal + health check) by default, merged into existing coverage — pass keepalive:false to opt out. Use the ACTUAL port reported by huaweicloud_sandbox_deploy_nginx (its "port" field auto-increments on conflict) — never the originally requested port. Returns ok, publicUrl, port, tunnelId, keepalive.enrolled, warnings, sandboxLifetime and urlLifetimeHint. The urlLifetimeHint MUST be relayed to the developer together with the URL: the tunnel expiry is NOT the sandbox lifetime — an idle sandbox shuts down (ephemeral also auto-deletes), which kills the URL. If the API Key is missing on a release build it returns ok:false with instructions to create one.',
780
+ 'Expose a port on the sandbox to a public DevBridge tunnel URL (devbridge 0.2.x). Authenticates automatically (keyring → auto-create → /tmp/hw_api_key), selectively cleans only same-port/orphan tunnels (cleanup_mode=stale, default — other ports, other sessions and keepalive-protected tunnels are preserved, except a tunnel bound to THIS port which is replaced), starts the host, extracts the public URL (https://<id>-<port>.devbridge-s2.hwtunnel.com), and health-checks it before returning. On 10006 quota it removes orphans first, then the oldest non-keepalive tunnel one at a time. AUTO-ENROLLS the tunnel into the keepalive watchdog (host re-attach + daily 720h renewal + health check) by default, merged into existing coverage — pass keepalive:false to opt out. Use the ACTUAL port reported by huaweicloud_sandbox_deploy_nginx (its "port" field auto-increments on conflict) — never the originally requested port. Returns ok, publicUrl, port, tunnelId, keepalive.enrolled, warnings, sandboxLifetime and urlLifetimeHint. The urlLifetimeHint MUST be relayed to the developer together with the URL: the tunnel expiry is NOT the sandbox lifetime — an idle sandbox shuts down (ephemeral also auto-deletes), which kills the URL. If the API Key is missing on a release build it returns ok:false with instructions to create one.',
780
781
  inputSchema: {
781
782
  type: 'object',
782
783
  required: ['port'],
@@ -796,6 +797,12 @@ export const TOOL_DEFINITIONS = [
796
797
  description:
797
798
  'Auto-enroll the tunnel into the keepalive watchdog (default true — every tunnel deployed to the sandbox must be keepalive-protected). Pass false only when the caller explicitly manages keepalive itself.',
798
799
  },
800
+ cleanup_mode: {
801
+ type: 'string',
802
+ enum: ['stale', 'all', 'none'],
803
+ description:
804
+ 'Tunnel cleanup before exposing. stale (default): delete same-port tunnels + orphan records/hosts; preserve other ports, other sessions and keepalive-protected tunnels — except a tunnel bound to THIS port, which is always replaced. all: wipe every tunnel/host (manual reset only). none: no cleanup (a stale same-port host can then conflict).',
805
+ },
799
806
  workspace_id: {
800
807
  type: 'string',
801
808
  description:
@@ -1657,6 +1664,7 @@ export async function callTool(name, rawArgs = {}, opts = {}) {
1657
1664
  port: args.port,
1658
1665
  expiryHours: args.expiry_hours,
1659
1666
  keepalive: args.keepalive,
1667
+ cleanupMode: args.cleanup_mode,
1660
1668
  // DevBridge API Key 自动创建兜底(认证固定 apikey,AK/SK 已移除)
1661
1669
  poolType: exposePoolContext?.poolMode || 'ephemeral',
1662
1670
  poolContext: exposePoolContext,
@@ -1756,8 +1764,9 @@ export async function callTool(name, rawArgs = {}, opts = {}) {
1756
1764
  // 池归属 + 生命周期口径缓存(服务端回显优先,缺失时客户端默认兜底)。
1757
1765
  // 双读兜底与 devStageId 同型(防服务端 wire 命名漂移,QA #1082 配对复审教训)
1758
1766
  rememberWorkspacePool(devStageId, connectResult?.pool_type || connectResult?.poolType, connectResult || {});
1767
+ // 只清理孤儿隧道/宿主;保留其他端口、其他会话与 keepalive 名单内的隧道
1759
1768
  try {
1760
- await execOneShot(devStageId, 'devbridge delete-all 2>/dev/null || true', 'root', 15000);
1769
+ await cleanupDevbridgeTunnels(devStageId, { mode: 'stale' });
1761
1770
  } catch {}
1762
1771
  try {
1763
1772
  await execWithSession(