huaweicloud-devkit 1.1.10 → 1.1.11
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 +1 -1
- package/README.zh-CN.md +1 -1
- 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-obs/SKILL.md +17 -16
- package/plugins/huaweicloud-core/skills/huawei-obs/references/common-pitfalls.md +168 -1
- package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +75 -13
- package/plugins/huaweicloud-core/skills/huawei-sandbox/references/fullstack-postgrest.md +149 -0
- package/plugins/huaweicloud-core/skills/huawei-sandbox/references/tunnel-persistence.md +121 -0
- package/plugins/huaweicloud-core/skills/huawei-voucher/SKILL.md +13 -9
- package/plugins/huaweicloud-core/src/safety-policy.mjs +6 -2
- package/plugins/huaweicloud-core/src/sandbox/hwlink-api.mjs +75 -12
- package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +396 -9
- package/plugins/huaweicloud-core/src/tools.mjs +258 -5
|
@@ -1301,8 +1301,18 @@ export async function closeSession(workspaceId, username) {
|
|
|
1301
1301
|
// DB_ATTEMPT_* lines are non-sensitive diagnostics for support escalation.
|
|
1302
1302
|
export function buildDevbridgeAuthProbe() {
|
|
1303
1303
|
return [
|
|
1304
|
-
|
|
1304
|
+
// #999: /workspace/.local/bin FIRST — /workspace is the data disk and
|
|
1305
|
+
// survives sandbox reboots; ~/.huawei/bin lives on the system disk and is
|
|
1306
|
+
// wiped back to the image state on reboot. Prepended so a stale image
|
|
1307
|
+
// binary in /usr/local/bin never wins.
|
|
1308
|
+
`export PATH="/workspace/.local/bin:$HOME/.huawei/bin:$PATH"`,
|
|
1305
1309
|
`export HOME="\${HOME:-/root}"`,
|
|
1310
|
+
// #998: probe the version in the SAME exec as the auth waterfall (no extra
|
|
1311
|
+
// round trip). 0.1.x binaries' AK/SK tier still logs in, so the auth probe
|
|
1312
|
+
// alone would green-light an expose that can never work — the version is
|
|
1313
|
+
// what names the real cause.
|
|
1314
|
+
`DB_VERSION=$(devbridge version 2>/dev/null | head -1)`,
|
|
1315
|
+
`echo "DB_VERSION=\${DB_VERSION}"`,
|
|
1306
1316
|
`DB_OK=0`,
|
|
1307
1317
|
`db_login_ok() { devbridge auth status 2>/dev/null | grep -q "Logged in"; }`,
|
|
1308
1318
|
`db_diag() { echo "DB_ATTEMPT_$1=$2"; }`,
|
|
@@ -1391,9 +1401,10 @@ export function buildDevbridgeAuthProbe() {
|
|
|
1391
1401
|
].join('\n');
|
|
1392
1402
|
}
|
|
1393
1403
|
|
|
1394
|
-
// Parse the raw stdout of buildDevbridgeAuthProbe into { authMode, attempts }.
|
|
1404
|
+
// Parse the raw stdout of buildDevbridgeAuthProbe into { authMode, attempts, version }.
|
|
1395
1405
|
// authMode is the LAST DB_AUTH_MODE line; attempts collects the non-sensitive
|
|
1396
|
-
// DB_ATTEMPT_* diagnostics emitted by tiers that failed and fell through
|
|
1406
|
+
// DB_ATTEMPT_* diagnostics emitted by tiers that failed and fell through;
|
|
1407
|
+
// version is the devbridge binary version probed in the same exec (#998).
|
|
1397
1408
|
export function parseDevbridgeAuthOutput(stdout) {
|
|
1398
1409
|
// #946: OSC 133 markers precede every line on integration-enabled shells; the
|
|
1399
1410
|
// anchored ^DB_ATTEMPT_ regexes are blind to them without stripping first.
|
|
@@ -1404,7 +1415,35 @@ export function parseDevbridgeAuthOutput(stdout) {
|
|
|
1404
1415
|
for (const m of text.matchAll(/^DB_ATTEMPT_([A-Z_]+)=(\S+)$/gm)) {
|
|
1405
1416
|
attempts[m[1]] = m[2];
|
|
1406
1417
|
}
|
|
1407
|
-
|
|
1418
|
+
const versionMatch = text.match(/^DB_VERSION=(.*)$/m);
|
|
1419
|
+
const version = versionMatch ? versionMatch[1].trim() : '';
|
|
1420
|
+
return { authMode, attempts, version };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// #998: 0.1.x binaries cannot serve tunnels — their hardcoded pre-migration
|
|
1424
|
+
// gateway was retired (Sep 2026) and now serves a「服务已迁移」placeholder,
|
|
1425
|
+
// while their AK/SK auth tier still logs in successfully. Without a version check the auth green-light leads to an
|
|
1426
|
+
// expose that fails after retries with no version context, sending the caller
|
|
1427
|
+
// chasing credentials/network/port instead of the real cause. Block 0.1.x
|
|
1428
|
+
// BEFORE exposing and point at the Step 0 install path. An empty version
|
|
1429
|
+
// (binary absent) does not block — the auth waterfall handles that case.
|
|
1430
|
+
export function evaluateDevbridgeVersion(version) {
|
|
1431
|
+
const v = String(version || '').trim();
|
|
1432
|
+
if (!v) return { blocked: false };
|
|
1433
|
+
if (/^0\.1($|[.-])/.test(v)) {
|
|
1434
|
+
return {
|
|
1435
|
+
blocked: true,
|
|
1436
|
+
error:
|
|
1437
|
+
`devbridge_too_old: ${v} cannot serve tunnels — its hardcoded gateway was migrated and now serves a 「服务已迁移」 placeholder. ` +
|
|
1438
|
+
'An upgrade to 0.2.x is required before exposing; no credential change will fix this.',
|
|
1439
|
+
remediation:
|
|
1440
|
+
'Upgrade in place per the huawei-sandbox skill Step 0: install 0.2.x via the GitCode mirror with GitHub fallback ' +
|
|
1441
|
+
'(`install.sh -s -u <gitcode URL> -v <target>`, `-s` is required in the sandbox), then re-run huaweicloud_sandbox_expose_tunnel. ' +
|
|
1442
|
+
'Install to /workspace/.local/bin (survives sandbox reboots) — never ~/.huawei/bin (wiped on system-disk reset). ' +
|
|
1443
|
+
'Old 0.1.x tunnels do not survive the upgrade: the expose flow pre-cleans them.',
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
return { blocked: false };
|
|
1408
1447
|
}
|
|
1409
1448
|
|
|
1410
1449
|
// Tunnel lifetime for expose-created hosts, in hours. The report that motivated
|
|
@@ -1444,7 +1483,10 @@ export function buildDevbridgeExposeScript(port, expiryHours = DEVBRIDGE_HOST_EX
|
|
|
1444
1483
|
`HTTP_CODE="000"`,
|
|
1445
1484
|
`if [ -n "$TUNNEL_URL" ]; then`,
|
|
1446
1485
|
` for i in 1 2 3 4; do`,
|
|
1447
|
-
|
|
1486
|
+
// #1000: --noproxy — the sandbox injects an HTTP proxy that does NOT route
|
|
1487
|
+
// the tunnel domain; without it the probe returns 502/404 fakes and a
|
|
1488
|
+
// working tunnel is judged dead.
|
|
1489
|
+
` HTTP_CODE=$(curl -s --noproxy '*' -o /tmp/.db_probe_body -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
|
|
1448
1490
|
` echo "DB_PROBE_$i=$HTTP_CODE"`,
|
|
1449
1491
|
` if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "304" ]; then break; fi`,
|
|
1450
1492
|
` if [ "$i" != "4" ]; then sleep 5; fi`,
|
|
@@ -1480,7 +1522,9 @@ export function buildDevbridgeVerifyScript(tunnelUrl) {
|
|
|
1480
1522
|
}
|
|
1481
1523
|
const bodyFile = '/tmp/devbridge_verify_body';
|
|
1482
1524
|
return [
|
|
1483
|
-
|
|
1525
|
+
// #1000: --noproxy — the injected proxy does not route the tunnel domain;
|
|
1526
|
+
// a proxied verify returns 502/404 fakes (working tunnel judged dead).
|
|
1527
|
+
`VERIFY_CODE=$(curl -s --noproxy '*' -o ${bodyFile} -w "%{http_code}" --max-time 10 "${url}" 2>/dev/null || echo "000")`,
|
|
1484
1528
|
`VERIFY_NOT_FOUND=$(grep -ci 'tunnel not found' ${bodyFile} 2>/dev/null || true)`,
|
|
1485
1529
|
`if [ -z "$VERIFY_NOT_FOUND" ]; then VERIFY_NOT_FOUND=0; fi`,
|
|
1486
1530
|
`rm -f ${bodyFile} 2>/dev/null || true`,
|
|
@@ -1572,6 +1616,254 @@ export function validateTunnelPort(port) {
|
|
|
1572
1616
|
return n;
|
|
1573
1617
|
}
|
|
1574
1618
|
|
|
1619
|
+
// Upper bound of devbridge's -e expiry, in hours (measured 2026-10: `-e 720` is
|
|
1620
|
+
// accepted and sets expiration = now + 2592001s; larger values are rejected).
|
|
1621
|
+
export const DEVBRIDGE_MAX_EXPIRY_HOURS = 720;
|
|
1622
|
+
|
|
1623
|
+
// Default renewal horizon for renewTunnel / the keepalive watchdog: 720h gives a
|
|
1624
|
+
// 29-day window of tolerance if renewal ever stops firing.
|
|
1625
|
+
export const DEVBRIDGE_RENEW_HOURS = 720;
|
|
1626
|
+
|
|
1627
|
+
// Validate and clamp a -e expiry value destined for shell interpolation in
|
|
1628
|
+
// `devbridge host -p <port> -e <hours>` / `devbridge update <id> -e <hours>`.
|
|
1629
|
+
// devbridge accepts 1..720 (30 days); anything else is rejected or clipped by
|
|
1630
|
+
// the gateway, so fail fast here instead of surfacing a misleading expiry.
|
|
1631
|
+
export function validateExpiryHours(hours) {
|
|
1632
|
+
const n = Number(hours);
|
|
1633
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > DEVBRIDGE_MAX_EXPIRY_HOURS) {
|
|
1634
|
+
throw new Error(
|
|
1635
|
+
`sandbox tunnel expiry: invalid expiry_hours ${JSON.stringify(hours)}. Expected an integer between 1 and ${DEVBRIDGE_MAX_EXPIRY_HOURS} (720 = the devbridge 30-day maximum).`,
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
return n;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// Validate a DevBridge tunnel ID destined for shell interpolation in
|
|
1642
|
+
// `devbridge update <id>` / `devbridge host <id>` / pgrep patterns. IDs are
|
|
1643
|
+
// short lowercase base36 tokens (e.g. nv2dvv6w, kag7rozs); the strict charset
|
|
1644
|
+
// keeps shell metacharacters out of the splice.
|
|
1645
|
+
export function validateTunnelId(tunnelId) {
|
|
1646
|
+
const id = String(tunnelId || '');
|
|
1647
|
+
if (!/^[A-Za-z0-9_-]{4,32}$/.test(id)) {
|
|
1648
|
+
throw new Error(
|
|
1649
|
+
`sandbox tunnel: invalid tunnel_id ${JSON.stringify(id.slice(0, 40))}. Expected the short ID from huaweicloud_sandbox_expose_tunnel's "tunnelId" field (URL-safe characters only).`,
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
return id;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// Build the script that renews a tunnel's lifetime: `devbridge update <id> -e
|
|
1656
|
+
// <hours>`. The expiry is a ROLLING WINDOW (measured 2026-10: renewal sets
|
|
1657
|
+
// expiration = now + hours regardless of remaining time, so renewing before
|
|
1658
|
+
// expiry keeps the same URL alive indefinitely). The absolute expiration
|
|
1659
|
+
// (tunnelExpiration, Unix seconds) is surfaced via show -v so callers get a
|
|
1660
|
+
// verifiable timestamp instead of a bare exit code.
|
|
1661
|
+
export function buildDevbridgeRenewScript(tunnelId, expiryHours = DEVBRIDGE_RENEW_HOURS) {
|
|
1662
|
+
const id = validateTunnelId(tunnelId);
|
|
1663
|
+
const hours = validateExpiryHours(expiryHours);
|
|
1664
|
+
return [
|
|
1665
|
+
`export PATH="$HOME/.huawei/bin:$PATH"`,
|
|
1666
|
+
`devbridge update ${id} -e ${hours} > /tmp/db-renew-update.log 2>&1`,
|
|
1667
|
+
`DB_RENEW_EXIT=$?`,
|
|
1668
|
+
`DB_RENEW_ERROR=$(head -1 /tmp/db-renew-update.log 2>/dev/null)`,
|
|
1669
|
+
`rm -f /tmp/db-renew-update.log`,
|
|
1670
|
+
`DB_EXPIRATION=$(devbridge show ${id} -v 2>&1 | grep -oE 'tunnelExpiration[^0-9]+[0-9]+' | grep -oE '[0-9]+' | head -1)`,
|
|
1671
|
+
`echo "DB_RENEW_EXIT=$DB_RENEW_EXIT"`,
|
|
1672
|
+
`echo "DB_RENEW_ID=${id}"`,
|
|
1673
|
+
`echo "DB_RENEW_HOURS=${hours}"`,
|
|
1674
|
+
`echo "DB_EXPIRATION=$DB_EXPIRATION"`,
|
|
1675
|
+
`echo "DB_RENEW_ERROR=$DB_RENEW_ERROR"`,
|
|
1676
|
+
].join('\n');
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Parse the raw stdout of buildDevbridgeRenewScript into structured fields.
|
|
1680
|
+
// A renewal is ok only when the update exit code is 0 AND show -v reports the
|
|
1681
|
+
// absolute tunnelExpiration — the management plane can lose a tunnel's record
|
|
1682
|
+
// (10002 "tunnel not found") while the data plane keeps serving, which a bare
|
|
1683
|
+
// exit-code check would misread as success on the old process alone.
|
|
1684
|
+
export function parseDevbridgeRenewOutput(stdout) {
|
|
1685
|
+
const text = stripTerminalEscapes(stdout);
|
|
1686
|
+
const getField = (key) => {
|
|
1687
|
+
const m = text.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
1688
|
+
return m ? m[1].trim() : '';
|
|
1689
|
+
};
|
|
1690
|
+
const renewExit = getField('DB_RENEW_EXIT');
|
|
1691
|
+
const expirationUnix = Number(getField('DB_EXPIRATION')) || undefined;
|
|
1692
|
+
const ok = renewExit === '0' && expirationUnix !== undefined;
|
|
1693
|
+
const expiration = expirationUnix !== undefined ? new Date(expirationUnix * 1000).toISOString() : undefined;
|
|
1694
|
+
return {
|
|
1695
|
+
ok,
|
|
1696
|
+
tunnelId: getField('DB_RENEW_ID'),
|
|
1697
|
+
renewedForHours: Number(getField('DB_RENEW_HOURS')) || undefined,
|
|
1698
|
+
expirationUnix,
|
|
1699
|
+
expiresAt: expiration,
|
|
1700
|
+
error: ok ? undefined : getField('DB_RENEW_ERROR') || `renewal failed (exit ${renewExit || 'unknown'})`,
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Renew a tunnel's lifetime via `devbridge update <id> -e <hours>`. Returns ok,
|
|
1705
|
+
// tunnelId, expiresAt (absolute ISO timestamp), renewedForHours. The URL does
|
|
1706
|
+
// not change — renewal only extends the lifetime of the existing tunnel ID.
|
|
1707
|
+
export async function renewTunnel(workspaceId, { tunnelId, expiryHours }, username = 'root', timeoutMs = 60000) {
|
|
1708
|
+
if (!workspaceId) {
|
|
1709
|
+
throw new Error('sandbox renew tunnel: workspace_id is required.');
|
|
1710
|
+
}
|
|
1711
|
+
const script = buildDevbridgeRenewScript(tunnelId, expiryHours);
|
|
1712
|
+
const result = await execOneShot(workspaceId, script, username, timeoutMs);
|
|
1713
|
+
const parsed = parseDevbridgeRenewOutput(result.stdout);
|
|
1714
|
+
return parsed;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Build the keepalive watchdog script for a set of known tunnels (the
|
|
1718
|
+
// three-layer pattern proven in the 2026-10 tunnel-persistence test):
|
|
1719
|
+
// 1. host watchdog — re-attach with `devbridge host <id>` when the host
|
|
1720
|
+
// process dies; the URL is bound to the tunnel ID, so it never changes
|
|
1721
|
+
// 2. daily renewal — `devbridge update <id> -e 720` (29-day failure tolerance)
|
|
1722
|
+
// 3. health check — curl each URL every 5 minutes, log on non-200/304
|
|
1723
|
+
// URLs are HARDCODED per tunnel (tunnel IDs never change), NOT derived from
|
|
1724
|
+
// `devbridge port list` — that table's lines are wrapped in OSC escapes on
|
|
1725
|
+
// integration-enabled shells, so anchored greps silently fail and the
|
|
1726
|
+
// constructed health URL 404s (measured 2026-10).
|
|
1727
|
+
export function buildDevbridgeKeepaliveScript(tunnels) {
|
|
1728
|
+
if (!Array.isArray(tunnels) || tunnels.length === 0) {
|
|
1729
|
+
throw new Error('sandbox keepalive: tunnels must be a non-empty array of {tunnelId, url}.');
|
|
1730
|
+
}
|
|
1731
|
+
const entries = tunnels
|
|
1732
|
+
.map((t) => `${validateTunnelId(t?.tunnelId)}:${String(t?.url || '')}`)
|
|
1733
|
+
.map((entry) => {
|
|
1734
|
+
const url = entry.slice(entry.indexOf(':') + 1);
|
|
1735
|
+
if (!/^https:\/\/[A-Za-z0-9:._/-]+$/.test(url)) {
|
|
1736
|
+
throw new Error(
|
|
1737
|
+
`sandbox keepalive: invalid tunnel URL ${JSON.stringify(url.slice(0, 80))} for the keepalive health check. Expected an https:// URL with URL-safe characters (from huaweicloud_sandbox_expose_tunnel's publicUrl).`,
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
return entry;
|
|
1741
|
+
})
|
|
1742
|
+
.join(' ');
|
|
1743
|
+
// Heredoc is single-quoted ('DB_KEEPALIVE_EOF') so the writer shell expands
|
|
1744
|
+
// nothing; all ${...} expansion happens inside the watchdog at runtime.
|
|
1745
|
+
const body = [
|
|
1746
|
+
`#!/bin/bash`,
|
|
1747
|
+
`export PATH="$HOME/.huawei/bin:$PATH"`,
|
|
1748
|
+
`# entries: tunnelId:url (URLs are stable — tunnel IDs never change)`,
|
|
1749
|
+
`TUNNELS="${entries}"`,
|
|
1750
|
+
`declare -A LAST_RENEW`,
|
|
1751
|
+
`while true; do`,
|
|
1752
|
+
` NOW=$(date -u +%s)`,
|
|
1753
|
+
` TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)`,
|
|
1754
|
+
` for entry in $TUNNELS; do`,
|
|
1755
|
+
` ID="\${entry%%:*}"; URL="\${entry#*:}"`,
|
|
1756
|
+
` if ! pgrep -f "devbridge host $ID" > /dev/null 2>&1; then`,
|
|
1757
|
+
` if command -v setsid > /dev/null 2>&1; then setsid nohup devbridge host "$ID" > "/tmp/host-$ID.log" 2>&1 < /dev/null & else nohup devbridge host "$ID" > "/tmp/host-$ID.log" 2>&1 < /dev/null & fi`,
|
|
1758
|
+
` echo "$TS host $ID restarted (same tunnel, URL unchanged)" >> /tmp/tunnel-keepalive.log`,
|
|
1759
|
+
` sleep 8`,
|
|
1760
|
+
` fi`,
|
|
1761
|
+
` if [ $((\${LAST_RENEW[$ID]:-0})) -eq 0 ] || [ $((NOW - \${LAST_RENEW[$ID]})) -ge 86400 ]; then`,
|
|
1762
|
+
` if devbridge update "$ID" -e ${DEVBRIDGE_RENEW_HOURS} > /tmp/keepalive-last-update.log 2>&1; then`,
|
|
1763
|
+
` echo "$TS renew $ID ok (${DEVBRIDGE_RENEW_HOURS}h)" >> /tmp/tunnel-keepalive.log`,
|
|
1764
|
+
` else`,
|
|
1765
|
+
` echo "$TS renew $ID FAILED: $(head -1 /tmp/keepalive-last-update.log)" >> /tmp/tunnel-keepalive.log`,
|
|
1766
|
+
` fi`,
|
|
1767
|
+
` LAST_RENEW[$ID]=$NOW`,
|
|
1768
|
+
` fi`,
|
|
1769
|
+
` HC=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$URL/" 2>/dev/null || echo "000")`,
|
|
1770
|
+
` if [ "$HC" != "200" ] && [ "$HC" != "304" ]; then`,
|
|
1771
|
+
` echo "$TS health $ID WARN http=$HC" >> /tmp/tunnel-keepalive.log`,
|
|
1772
|
+
` fi`,
|
|
1773
|
+
` done`,
|
|
1774
|
+
` sleep 300`,
|
|
1775
|
+
`done`,
|
|
1776
|
+
].join('\n');
|
|
1777
|
+
return [
|
|
1778
|
+
`export PATH="$HOME/.huawei/bin:$PATH"`,
|
|
1779
|
+
`pkill -f "tunnel-keepalive.sh" 2>/dev/null || true`,
|
|
1780
|
+
`sleep 1`,
|
|
1781
|
+
`cat > /tmp/tunnel-keepalive.sh << 'DB_KEEPALIVE_EOF'`,
|
|
1782
|
+
body,
|
|
1783
|
+
`DB_KEEPALIVE_EOF`,
|
|
1784
|
+
`chmod +x /tmp/tunnel-keepalive.sh`,
|
|
1785
|
+
`bash -n /tmp/tunnel-keepalive.sh > /dev/null 2>&1`,
|
|
1786
|
+
`echo "DB_KEEPALIVE_SYNTAX=$?"`,
|
|
1787
|
+
`if command -v setsid > /dev/null 2>&1; then setsid nohup /tmp/tunnel-keepalive.sh > /dev/null 2>&1 < /dev/null & else nohup /tmp/tunnel-keepalive.sh > /dev/null 2>&1 < /dev/null & fi`,
|
|
1788
|
+
`disown 2>/dev/null || true`,
|
|
1789
|
+
`sleep 3`,
|
|
1790
|
+
`echo "DB_KEEPALIVE_PID=$(pgrep -f 'tunnel-keepalive.sh' | head -1)"`,
|
|
1791
|
+
`echo "DB_KEEPALIVE_LOG=/tmp/tunnel-keepalive.log"`,
|
|
1792
|
+
].join('\n');
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// Read the tunnel list of the INSTALLED watchdog (if any). The TUNNELS line in
|
|
1796
|
+
// /tmp/tunnel-keepalive.sh is the single source of truth for what is currently
|
|
1797
|
+
// protected; expose_tunnel reads it before installing so the new tunnel is
|
|
1798
|
+
// MERGED into the existing protection instead of replacing it.
|
|
1799
|
+
export function buildKeepaliveTunnelsReadScript() {
|
|
1800
|
+
return [
|
|
1801
|
+
`LINE=$(grep -m1 '^TUNNELS=' /tmp/tunnel-keepalive.sh 2>/dev/null | sed 's/^TUNNELS=//; s/^"//; s/"$//')`,
|
|
1802
|
+
`echo "DB_KEEPALIVE_TUNNELS=$LINE"`,
|
|
1803
|
+
].join('\n');
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// Parse the raw stdout of buildKeepaliveTunnelsReadScript. Empty output (no
|
|
1807
|
+
// watchdog installed yet) yields [].
|
|
1808
|
+
export function parseKeepaliveTunnelsOutput(stdout) {
|
|
1809
|
+
const text = stripTerminalEscapes(stdout);
|
|
1810
|
+
const m = text.match(/^DB_KEEPALIVE_TUNNELS=(.*)$/m);
|
|
1811
|
+
const line = m ? m[1].trim() : '';
|
|
1812
|
+
if (!line) return [];
|
|
1813
|
+
return line
|
|
1814
|
+
.split(/\s+/)
|
|
1815
|
+
.map((entry) => {
|
|
1816
|
+
const i = entry.indexOf(':');
|
|
1817
|
+
return i > 0 ? { tunnelId: entry.slice(0, i), url: entry.slice(i + 1) } : null;
|
|
1818
|
+
})
|
|
1819
|
+
.filter(Boolean);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// Merge the current watchdog tunnels with the incoming ones: dedupe by
|
|
1823
|
+
// tunnelId, incoming wins (same tunnel re-exposed refreshes its URL), existing
|
|
1824
|
+
// order preserved, new tunnels appended.
|
|
1825
|
+
export function mergeKeepaliveTunnels(existing, incoming) {
|
|
1826
|
+
const byId = new Map();
|
|
1827
|
+
for (const t of existing || []) {
|
|
1828
|
+
if (t?.tunnelId && t?.url) byId.set(t.tunnelId, { tunnelId: t.tunnelId, url: t.url });
|
|
1829
|
+
}
|
|
1830
|
+
for (const t of incoming || []) {
|
|
1831
|
+
if (t?.tunnelId && t?.url) byId.set(t.tunnelId, { tunnelId: t.tunnelId, url: t.url });
|
|
1832
|
+
}
|
|
1833
|
+
return [...byId.values()];
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// Install the keepalive watchdog in the sandbox. Validates every tunnel ID and
|
|
1837
|
+
// URL before splicing, aborts on a bash syntax error (DB_KEEPALIVE_SYNTAX != 0),
|
|
1838
|
+
// and reports the watchdog PID. Replacing the watchdog (re-running the tool)
|
|
1839
|
+
// kills the old loop first so two loops never double-renew.
|
|
1840
|
+
export async function installKeepaliveWatchdog(workspaceId, { tunnels }, username = 'root', timeoutMs = 60000) {
|
|
1841
|
+
if (!workspaceId) {
|
|
1842
|
+
throw new Error('sandbox keepalive: workspace_id is required.');
|
|
1843
|
+
}
|
|
1844
|
+
const script = buildDevbridgeKeepaliveScript(tunnels);
|
|
1845
|
+
const result = await execOneShot(workspaceId, script, username, timeoutMs);
|
|
1846
|
+
const text = stripTerminalEscapes(result.stdout);
|
|
1847
|
+
const getField = (key) => {
|
|
1848
|
+
const m = text.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
1849
|
+
return m ? m[1].trim() : '';
|
|
1850
|
+
};
|
|
1851
|
+
const syntax = getField('DB_KEEPALIVE_SYNTAX');
|
|
1852
|
+
if (syntax !== '0') {
|
|
1853
|
+
return {
|
|
1854
|
+
ok: false,
|
|
1855
|
+
error: `keepalive watchdog script failed the bash -n syntax check (exit ${syntax || 'unknown'}).`,
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
return {
|
|
1859
|
+
ok: true,
|
|
1860
|
+
scriptPath: '/tmp/tunnel-keepalive.sh',
|
|
1861
|
+
logPath: getField('DB_KEEPALIVE_LOG') || '/tmp/tunnel-keepalive.log',
|
|
1862
|
+
watchdogPid: getField('DB_KEEPALIVE_PID') || undefined,
|
|
1863
|
+
tunnels: (tunnels || []).map((t) => ({ tunnelId: t.tunnelId, url: t.url })),
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1575
1867
|
// Expose a sandbox port to a public DevBridge tunnel URL. `port` MUST be the
|
|
1576
1868
|
// ACTUAL port reported by deployNginx (deploy_nginx auto-increments on conflict),
|
|
1577
1869
|
// otherwise the tunnel is bound to a dead port. Returns ok, publicUrl, port,
|
|
@@ -1579,15 +1871,38 @@ export function validateTunnelPort(port) {
|
|
|
1579
1871
|
// tunnel is re-verified from a second independent exec session (#919) and the
|
|
1580
1872
|
// result carries verifyHttpCode + persistent so callers can tell a
|
|
1581
1873
|
// session-surviving URL from one that dies with the deploying session.
|
|
1582
|
-
export async function exposeTunnel(
|
|
1874
|
+
export async function exposeTunnel(
|
|
1875
|
+
workspaceId,
|
|
1876
|
+
{ port, expiryHours, keepalive },
|
|
1877
|
+
username = 'root',
|
|
1878
|
+
timeoutMs = 90000,
|
|
1879
|
+
) {
|
|
1583
1880
|
if (!workspaceId) {
|
|
1584
1881
|
throw new Error('sandbox expose tunnel: workspace_id is required.');
|
|
1585
1882
|
}
|
|
1586
1883
|
const validatedPort = validateTunnelPort(port);
|
|
1884
|
+
const validatedExpiryHours = validateExpiryHours(expiryHours ?? DEVBRIDGE_HOST_EXPIRY_HOURS);
|
|
1587
1885
|
|
|
1588
1886
|
const authScript = buildDevbridgeAuthProbe();
|
|
1589
1887
|
const authResult = await execOneShot(workspaceId, authScript, username, 30000);
|
|
1590
|
-
const { authMode, attempts } = parseDevbridgeAuthOutput(authResult.stdout);
|
|
1888
|
+
const { authMode, attempts, version: authVersion } = parseDevbridgeAuthOutput(authResult.stdout);
|
|
1889
|
+
|
|
1890
|
+
// #998: version gate BEFORE the expose loop. 0.1.x binaries' AK/SK tier still
|
|
1891
|
+
// logs in, so authMode alone would green-light an expose that can never work.
|
|
1892
|
+
// This must precede the authMode check — the structured too_old error names
|
|
1893
|
+
// the real cause where the auth-failure path would point at credentials.
|
|
1894
|
+
const versionCheck = evaluateDevbridgeVersion(authVersion);
|
|
1895
|
+
if (versionCheck.blocked) {
|
|
1896
|
+
return {
|
|
1897
|
+
ok: false,
|
|
1898
|
+
error: versionCheck.error,
|
|
1899
|
+
error_code: 'devbridge_too_old',
|
|
1900
|
+
version: authVersion,
|
|
1901
|
+
authMode,
|
|
1902
|
+
attempts,
|
|
1903
|
+
remediation: versionCheck.remediation,
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1591
1906
|
|
|
1592
1907
|
if (authMode !== 'API_KEY_KEYRING' && authMode !== 'AKSK_SUPPORTED' && authMode !== 'API_KEY_FILE') {
|
|
1593
1908
|
const loginOutput = String(authResult.stdout || '') + String(authResult.stderr || '');
|
|
@@ -1614,7 +1929,7 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
|
|
|
1614
1929
|
const maxAttempts = 2;
|
|
1615
1930
|
while (attempt < maxAttempts) {
|
|
1616
1931
|
attempt += 1;
|
|
1617
|
-
const exposeScript = buildDevbridgeExposeScript(validatedPort);
|
|
1932
|
+
const exposeScript = buildDevbridgeExposeScript(validatedPort, validatedExpiryHours);
|
|
1618
1933
|
result = await execOneShot(workspaceId, exposeScript, username, timeoutMs);
|
|
1619
1934
|
const parsed = parseDevbridgeExposeOutput(result.stdout);
|
|
1620
1935
|
|
|
@@ -1662,6 +1977,42 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
|
|
|
1662
1977
|
if (parsed.quotaError)
|
|
1663
1978
|
warnings.push('DevBridge tunnel quota (10006) hit — stale tunnels were cleaned up and the host was retried.');
|
|
1664
1979
|
|
|
1980
|
+
// #998: a host that never produced a Tunnel URL must NOT surface as ok:true
|
|
1981
|
+
// with an undefined publicUrl — that reads as success and sends the caller
|
|
1982
|
+
// chasing credentials/network/port while the real cause is the host/版本.
|
|
1983
|
+
// Read the version back into the return body: in this incident a single
|
|
1984
|
+
// version field would have saved the whole diagnosis.
|
|
1985
|
+
if (!parsed.tunnelUrl) {
|
|
1986
|
+
let noUrlVersion = authVersion;
|
|
1987
|
+
try {
|
|
1988
|
+
const versionResult = await execOneShot(
|
|
1989
|
+
workspaceId,
|
|
1990
|
+
`export PATH="/workspace/.local/bin:$HOME/.huawei/bin:$PATH"; devbridge version 2>/dev/null | head -1`,
|
|
1991
|
+
username,
|
|
1992
|
+
15000,
|
|
1993
|
+
);
|
|
1994
|
+
noUrlVersion = String(versionResult.stdout || '').trim() || authVersion;
|
|
1995
|
+
} catch {}
|
|
1996
|
+
const noUrlCheck = evaluateDevbridgeVersion(noUrlVersion);
|
|
1997
|
+
if (noUrlCheck.blocked) {
|
|
1998
|
+
warnings.push(
|
|
1999
|
+
`devbridge version is ${noUrlVersion} — too old to serve tunnels; upgrade per Step 0 before retrying.`,
|
|
2000
|
+
);
|
|
2001
|
+
}
|
|
2002
|
+
return {
|
|
2003
|
+
ok: false,
|
|
2004
|
+
error: noUrlCheck.blocked
|
|
2005
|
+
? noUrlCheck.error
|
|
2006
|
+
: `Failed to expose a working DevBridge tunnel: the host log contains no Tunnel URL (HTTP probe code ${parsed.httpCode || '000'}) — the host did not start. Check the hostLog below for the real cause.`,
|
|
2007
|
+
error_code: noUrlCheck.blocked ? 'devbridge_too_old' : 'no_tunnel_url',
|
|
2008
|
+
version: noUrlVersion || undefined,
|
|
2009
|
+
httpCode: parsed.httpCode,
|
|
2010
|
+
authMode,
|
|
2011
|
+
...(noUrlCheck.blocked ? { remediation: noUrlCheck.remediation } : {}),
|
|
2012
|
+
hostLog: String(result.stdout || '').slice(0, 400),
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
|
|
1665
2016
|
// #919: re-verify with a real GET from a SECOND, independent exec session.
|
|
1666
2017
|
// The in-session health check above cannot prove the host survived session
|
|
1667
2018
|
// teardown (setsid/nohup/disown make that likely, not certain). When the
|
|
@@ -1717,6 +2068,33 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
|
|
|
1717
2068
|
parsed.expiryHours !== undefined
|
|
1718
2069
|
? new Date(Date.now() + parsed.expiryHours * 3_600_000).toISOString()
|
|
1719
2070
|
: undefined;
|
|
2071
|
+
|
|
2072
|
+
// AUTO-ENROLL: every tunnel exposed through this tool is enrolled into the
|
|
2073
|
+
// keepalive watchdog by default (user requirement: all devbridge tunnels
|
|
2074
|
+
// deployed to the sandbox must be keepalive-protected — unattended renewal
|
|
2075
|
+
// + host re-attach + health check). The new tunnel is MERGED into the
|
|
2076
|
+
// existing watchdog tunnel list, never replacing other tunnels' coverage.
|
|
2077
|
+
// keepalive:false opts out. Non-fatal: an enrollment failure is surfaced
|
|
2078
|
+
// as a warning, not a failed expose — the tunnel itself is working.
|
|
2079
|
+
let keepaliveEnrollment = undefined;
|
|
2080
|
+
if (keepalive !== false && parsed.tunnelUrl && parsed.tunnelId) {
|
|
2081
|
+
try {
|
|
2082
|
+
const readResult = await execOneShot(workspaceId, buildKeepaliveTunnelsReadScript(), username, 20000);
|
|
2083
|
+
const existing = parseKeepaliveTunnelsOutput(readResult.stdout);
|
|
2084
|
+
const merged = mergeKeepaliveTunnels(existing, [{ tunnelId: parsed.tunnelId, url: parsed.tunnelUrl }]);
|
|
2085
|
+
keepaliveEnrollment = await installKeepaliveWatchdog(workspaceId, { tunnels: merged }, username, 30000);
|
|
2086
|
+
if (!keepaliveEnrollment.ok) {
|
|
2087
|
+
warnings.push(
|
|
2088
|
+
`Keepalive watchdog enrollment failed: ${keepaliveEnrollment.error || 'unknown error'} — the tunnel works but will NOT be auto-renewed. Retry with huaweicloud_sandbox_keepalive_watchdog.`,
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
warnings.push(
|
|
2093
|
+
`Keepalive watchdog enrollment failed (${error?.message || 'unknown error'}) — the tunnel works but will NOT be auto-renewed. Retry with huaweicloud_sandbox_keepalive_watchdog.`,
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
1720
2098
|
return {
|
|
1721
2099
|
ok: true,
|
|
1722
2100
|
publicUrl: parsed.tunnelUrl || undefined,
|
|
@@ -1729,6 +2107,15 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
|
|
|
1729
2107
|
persistent,
|
|
1730
2108
|
expiresAt,
|
|
1731
2109
|
expiresInHours: parsed.expiryHours,
|
|
2110
|
+
keepalive: keepaliveEnrollment
|
|
2111
|
+
? {
|
|
2112
|
+
enrolled: keepaliveEnrollment.ok === true,
|
|
2113
|
+
tunnelCount: keepaliveEnrollment.ok ? (keepaliveEnrollment.tunnels || []).length : undefined,
|
|
2114
|
+
watchdogPid: keepaliveEnrollment.watchdogPid,
|
|
2115
|
+
logPath: keepaliveEnrollment.logPath,
|
|
2116
|
+
error: keepaliveEnrollment.ok ? undefined : keepaliveEnrollment.error,
|
|
2117
|
+
}
|
|
2118
|
+
: undefined,
|
|
1732
2119
|
warnings: warnings.length > 0 ? warnings.join(' ') : undefined,
|
|
1733
2120
|
hostLog: String(result.stdout || '').slice(0, 400),
|
|
1734
2121
|
};
|