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 +1 -1
- package/plugins/huaweicloud-core/.claude-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.codex-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
- package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +16 -12
- package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +412 -21
- package/plugins/huaweicloud-core/src/tools.mjs +11 -2
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
"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
|
-
|
|
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`
|
|
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:
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
devbridge delete
|
|
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
|
-
|
|
447
|
-
devbridge
|
|
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
|
|
469
|
-
|
|
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**:
|
|
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`
|
|
1306
|
-
// `auth status`
|
|
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() {
|
|
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
|
-
|
|
1454
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1769
|
+
await cleanupDevbridgeTunnels(devStageId, { mode: 'stale' });
|
|
1761
1770
|
} catch {}
|
|
1762
1771
|
try {
|
|
1763
1772
|
await execWithSession(
|