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.
@@ -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
- `export PATH="$HOME/.huawei/bin:$PATH"`,
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
- return { authMode, attempts };
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
- ` HTTP_CODE=$(curl -s -o /tmp/.db_probe_body -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
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
- `VERIFY_CODE=$(curl -s -o ${bodyFile} -w "%{http_code}" --max-time 10 "${url}" 2>/dev/null || echo "000")`,
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(workspaceId, { port }, username = 'root', timeoutMs = 90000) {
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
  };