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.
@@ -0,0 +1,149 @@
1
+ # Full-Stack on the Sandbox: PostgreSQL 16 + PostgREST + Nginx
2
+
3
+ A REST API over a real database, fully inside the sandbox: PostgREST generates
4
+ a RESTful API from the PostgreSQL schema; nginx serves a frontend and
5
+ reverse-proxies `/api` to PostgREST on a single origin (no CORS needed).
6
+ All values below are **measured** on HCE 2.0 (aarch64, no systemd, dnf) in the
7
+ 2026-10 test. Nginx templates: see `nginx-templates.md`. Tunnel exposure:
8
+ see `tunnel-persistence.md`.
9
+
10
+ ## Architecture
11
+
12
+ ```
13
+ browser → tunnel URL → nginx :8080
14
+ ├─ / → static frontend (index.html)
15
+ └─ /api/ → proxy_pass http://127.0.0.1:3000 (PostgREST)
16
+ └─ PostgreSQL 16 :5432 → schema api
17
+ ```
18
+
19
+ ## Step 1: PostgreSQL 16 on HCE 2.0 (the hard part)
20
+
21
+ The OS repo only ships PostgreSQL 13, but PostgREST 16.x **requires PG ≥ 14**
22
+ (it refuses with "Cannot run in this PostgreSQL version, PostgREST needs at
23
+ least 14.0"). PGDG is the source for PG 16 — but HCE is NOT el8-lib-compatible:
24
+
25
+ ```bash
26
+ # PGDG repo RPM fails on HCE ("nothing provides /etc/redhat-release") — write the repo file by hand.
27
+ # The el8 path is rhel-8-aarch64 WITH A HYPHEN: rhel8-aarch64 (no hyphen) 404s.
28
+ cat > /etc/yum.repos.d/pgdg16.repo << 'PGDG_EOF'
29
+ [pgdg16]
30
+ name=PostgreSQL 16 for RHEL8 aarch64
31
+ baseurl=https://download.postgresql.org/pub/repos/yum/16/redhat/rhel-8-aarch64/
32
+ enabled=1
33
+ gpgcheck=0
34
+ PGDG_EOF
35
+ ```
36
+
37
+ The PGDG el8 RPMs need RHEL-8-era libraries HCE does not have. el9 RPMs fail
38
+ differently (they want `libldap-2.5.so.0`). Install the el8 RPMs with
39
+ `--nodeps` and shim the sonames:
40
+
41
+ ```bash
42
+ BASE="https://download.postgresql.org/pub/repos/yum/16/redhat/rhel-8-aarch64"
43
+ # Download with resume (--max-time + -C -): GitHub/pgdn downloads from the CN
44
+ # region stall; a partial RPM fails rpm verification and ABORTS the whole transaction,
45
+ # silently skipping the other RPMs in the same rpm -ivh call. Install one RPM per call.
46
+ curl -fsSL --http1.1 --max-time 240 -C - "$BASE/postgresql16-libs-16.15-1PGDG.rhel8.10.aarch64.rpm" -o /tmp/pg16-libs.rpm
47
+ rpm -ivh --nodeps /tmp/pg16-libs.rpm
48
+ curl -fsSL --http1.1 --max-time 240 -C - "$BASE/postgresql16-16.15-1PGDG.rhel8.10.aarch64.rpm" -o /tmp/pg16.rpm
49
+ rpm -ivh --nodeps /tmp/pg16.rpm
50
+ curl -fsSL --http1.1 --max-time 240 -C - "$BASE/postgresql16-server-16.15-1PGDG.rhel8.10.aarch64.rpm" -o /tmp/pg16-server.rpm
51
+ rpm -ivh --nodeps /tmp/pg16-server.rpm
52
+ ```
53
+
54
+ Soname shims (measured 2026-10):
55
+
56
+ ```bash
57
+ # ICU: versioned symbols (u_strToLower_60) make symlinks IMPOSSIBLE — a
58
+ # libicu.so.60 -> .69 symlink loads but crashes with "undefined symbol".
59
+ # Install the REAL el8 libicu60 (different sonames, coexists with HCE's icu69):
60
+ curl -fsSL --http1.1 --max-time 240 -C - \
61
+ "https://repo.almalinux.org/almalinux/8/BaseOS/aarch64/os/Packages/libicu-60.3-2.el8_1.aarch64.rpm" -o /tmp/libicu60.rpm
62
+ rpm -ivh --nodeps --force /tmp/libicu60.rpm # --force: license file conflicts with icu69
63
+
64
+ # LDAP: postgres only uses it for LDAP auth (not needed here) — symlinks suffice.
65
+ ln -sf /usr/lib64/libldap.so.2.0.200 /usr/lib64/libldap-2.4.so.2
66
+ ln -sf /usr/lib64/libldap.so.2.0.200 /usr/lib64/libldap_r-2.4.so.2 # initdb needs the _r soname
67
+ ln -sf /usr/lib64/liblber.so.2.0.200 /usr/lib64/liblber-2.4.so.2
68
+
69
+ # readline: psql links libreadline.so.7 (el8); HCE has .8 — API-compatible, symlink fine.
70
+ ln -sf /usr/lib64/libreadline.so.8 /usr/lib64/libreadline.so.7
71
+
72
+ ldconfig # registers /usr/pgsql-16/lib (libpq.so.5)
73
+ ```
74
+
75
+ Init and start (PG refuses root; no systemd on HCE):
76
+
77
+ ```bash
78
+ mkdir -p /var/lib/pgsql/16/data && chown postgres:postgres /var/lib/pgsql/16/data
79
+ su - postgres -c "/usr/pgsql-16/bin/initdb -D /var/lib/pgsql/16/data"
80
+ su - postgres -c "/usr/pgsql-16/bin/pg_ctl -D /var/lib/pgsql/16/data -l /var/lib/pgsql/16/logfile start"
81
+ ```
82
+
83
+ ## Step 2: PostgREST (static build) + schema
84
+
85
+ PostgREST 16.4 `linux-static-aarch64` runs on HCE out of the box (musl
86
+ static). **Dynamic Ubuntu builds do NOT work** — HCE glibc 2.34 is older than
87
+ Ubuntu 22.04's 2.35 and the binaries fail on load.
88
+
89
+ ```bash
90
+ # https://github.com/PostgREST/postgrest/releases → postgrest-v16.4.0-linux-static-aarch64.tar.xz
91
+ mkdir -p /opt/postgrest && tar -xJf postgrest-*.tar.xz -C /opt/postgrest
92
+
93
+ cat > /opt/postgrest/pgrst.conf << 'PGRST_EOF'
94
+ db-uri = "postgres://api_user:<db-password>@localhost:5432/postgres"
95
+ db-schemas = "api"
96
+ db-anon-role = "api_user"
97
+ server-host = "127.0.0.1"
98
+ server-port = 3000
99
+ PGRST_EOF
100
+
101
+ # Schema + read-only API role (the anon role gates all writes)
102
+ su - postgres -c "/usr/pgsql-16/bin/psql -c \"CREATE SCHEMA api; CREATE TABLE api.todos (id serial primary key, done boolean not null default false, task text not null); CREATE ROLE api_user LOGIN PASSWORD '<db-password>'; GRANT USAGE ON SCHEMA api TO api_user; GRANT SELECT ON ALL TABLES IN SCHEMA api TO api_user;\""
103
+
104
+ setsid nohup /opt/postgrest/postgrest /opt/postgrest/pgrst.conf > /tmp/pgrst.log 2>&1 < /dev/null &
105
+ disown
106
+ curl -s http://127.0.0.1:3000/todos # [{"id":1,...}]
107
+ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/ # OpenAPI root, 200
108
+ ```
109
+
110
+ PostgREST allows all CORS origins by default (`Access-Control-Allow-Origin: *`).
111
+
112
+ ## Step 3: Nginx single-origin (frontend + /api)
113
+
114
+ ```bash
115
+ cat > /etc/nginx/conf.d/app.conf << 'NGINX_EOF'
116
+ server {
117
+ listen <port>;
118
+ root /var/www/<app>;
119
+ index index.html;
120
+ location /api/ {
121
+ proxy_pass http://127.0.0.1:3000/; # trailing slashes strip the /api prefix
122
+ proxy_set_header Host $host;
123
+ }
124
+ }
125
+ NGINX_EOF
126
+ nginx -t && nginx -s reload 2>/dev/null || nginx
127
+ ```
128
+
129
+ TRAP (measured 2026-10): nginx runs as the `nginx` user but the frontend dir is
130
+ root-owned — `/` returns **403** until the traverse bit is set on the whole
131
+ path chain: `chmod o+x /var/www /var/www/<app>` (and `o+r` on the files).
132
+ This mirrors the deploy_nginx project-path permission fix.
133
+
134
+ Frontend pages fetch backend data same-origin: `fetch('/api/todos?order=id.asc')`
135
+ — no CORS, no absolute URLs, no CDN dependencies (keep pages self-contained;
136
+ CN-region CDNs stall sandbox egress).
137
+
138
+ ## Step 4: Expose + keep alive
139
+
140
+ ```bash
141
+ # Port on the tunnel must be the ACTUAL nginx port
142
+ devbridge host -p <port> -e 720
143
+ # Then install the watchdog (renewal + host re-attach + health check)
144
+ # → huaweicloud_sandbox_keepalive_watchdog / references/tunnel-persistence.md
145
+ ```
146
+
147
+ Verify end-to-end through the public URL: `GET /` (page, 200), `GET /api/todos`
148
+ (data, 200), `POST /api/todos` (401 — the anon role has SELECT only; if a write
149
+ API is wanted, grant INSERT/UPDATE on the specific table, never to anon broadly).
@@ -0,0 +1,121 @@
1
+ # Tunnel Persistence: Renewal, Re-attach, and the Keepalive Watchdog
2
+
3
+ DevBridge tunnel URLs are bound to the tunnel ID, not to the host process. The
4
+ URL `https://<tunnel-id>-<port>.devbridge-s2.hwtunnel.com` keeps working as long
5
+ as (a) the tunnel has lifetime left and (b) a `devbridge host` process is
6
+ attached. Persistence is therefore about **preventing expiry, not recovering
7
+ URLs** — a tunnel whose record is lost cannot be recovered with the same URL.
8
+
9
+ All values below are **measured** (2026-10 tunnel-persistence test), not
10
+ inferred. Plugin automation: `huaweicloud_sandbox_renew_tunnel` and
11
+ `huaweicloud_sandbox_keepalive_watchdog`.
12
+
13
+ ## Rolling-window expiry (measured)
14
+
15
+ The `-e <hours>` expiry is a **rolling window**: renewal sets
16
+ `expiration = now + hours` regardless of remaining time.
17
+
18
+ ```bash
19
+ # 14:15Z: create with a 1-hour window
20
+ devbridge host -p 8080 -e 1 # Tunnel URL: https://nv2dvv6w-8080...
21
+ # 14:25Z: renew 10 minutes later
22
+ devbridge update nv2dvv6w -e 1
23
+ # expiration = 15:25Z = renewal time + 1h (NOT original expiry + 1h)
24
+ ```
25
+
26
+ Consequences:
27
+
28
+ - Renewing **before** expiry keeps the same URL alive indefinitely — the
29
+ "permanent link" pattern is daily renewal, never URL recovery.
30
+ - `-e` accepts **1..720** hours (720 = 30 days, exact `+2592001s`). Larger
31
+ values are rejected.
32
+
33
+ ## Reading the absolute expiration
34
+
35
+ Non-verbose `show` prints a relative value only; `-v` surfaces the Unix
36
+ timestamp:
37
+
38
+ ```bash
39
+ devbridge show nv2dvv6w -v | grep -oE 'tunnelExpiration[^0-9]+[0-9]+'
40
+ # tunnelExpiration":1795022391 (UTC seconds; ~30 days after a 720h renew)
41
+ ```
42
+
43
+ ## Host re-attach: same tunnel, same URL
44
+
45
+ If the host process dies (sandbox restart, session teardown), re-attach with the
46
+ tunnel ID — the URL does **not** change:
47
+
48
+ ```bash
49
+ devbridge host <tunnel-id>
50
+ ```
51
+
52
+ Always launch in the session-detached form so the host survives the exec
53
+ session: `setsid nohup devbridge host <id> ... < /dev/null &` + `disown`
54
+ (see the expose flow for why all three matter).
55
+
56
+ ## Three-layer keepalive watchdog (the proven pattern)
57
+
58
+ Layer 1 keeps the host process alive, layer 2 keeps the lifetime topped up
59
+ daily (29 days of tolerance if renewal ever stops firing), layer 3 detects a
60
+ dead URL within 5 minutes. Install via
61
+ `huaweicloud_sandbox_keepalive_watchdog`, or by hand:
62
+
63
+ ```bash
64
+ cat > /tmp/tunnel-keepalive.sh << 'KEEPALIVE_EOF'
65
+ #!/bin/bash
66
+ # /workspace/.local/bin FIRST — it survives sandbox reboots; ~/.huawei/bin is
67
+ # on the system disk and is wiped back to the image state on reboot (#999).
68
+ export PATH="/workspace/.local/bin:$HOME/.huawei/bin:$PATH"
69
+ # entries: tunnelId:url — URLs HARDCODED, never derived from port list (see trap 2)
70
+ TUNNELS="<tunnel-id>:https://<tunnel-id>-<port>.devbridge-s2.hwtunnel.com"
71
+ declare -A LAST_RENEW
72
+ while true; do
73
+ NOW=$(date -u +%s); TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
74
+ for entry in $TUNNELS; do
75
+ ID="${entry%%:*}"; URL="${entry#*:}"
76
+ # Layer 1: host watchdog — re-attach keeps the URL (bound to tunnel ID)
77
+ if ! pgrep -f "devbridge host $ID" > /dev/null 2>&1; then
78
+ setsid nohup devbridge host "$ID" > "/tmp/host-$ID.log" 2>&1 < /dev/null &
79
+ echo "$TS host $ID restarted" >> /tmp/tunnel-keepalive.log; sleep 8
80
+ fi
81
+ # Layer 2: daily renewal (86400s) — rolling window keeps the URL alive
82
+ if [ $((${LAST_RENEW[$ID]:-0})) -eq 0 ] || [ $((NOW - ${LAST_RENEW[$ID]})) -ge 86400 ]; then
83
+ devbridge update "$ID" -e 720 >> /tmp/tunnel-keepalive.log 2>&1 \
84
+ && echo "$TS renew $ID ok (720h)" >> /tmp/tunnel-keepalive.log \
85
+ || echo "$TS renew $ID FAILED" >> /tmp/tunnel-keepalive.log
86
+ LAST_RENEW[$ID]=$NOW
87
+ fi
88
+ # Layer 3: health check every 5 min (loop sleep), log on non-200/304
89
+ HC=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$URL/" 2>/dev/null || echo "000")
90
+ [ "$HC" != "200" ] && [ "$HC" != "304" ] \
91
+ && echo "$TS health $ID WARN http=$HC" >> /tmp/tunnel-keepalive.log
92
+ done
93
+ sleep 300
94
+ done
95
+ KEEPALIVE_EOF
96
+ chmod +x /tmp/tunnel-keepalive.sh
97
+ setsid nohup /tmp/tunnel-keepalive.sh > /dev/null 2>&1 < /dev/null &
98
+ disown
99
+ ```
100
+
101
+ The pgrep pattern MUST be tunnel-specific (`devbridge host $ID`) — a bare
102
+ `pgrep -f "devbridge host"` matches _any_ tunnel's host and silently skips
103
+ re-attaching the dead one (measured 2026-10).
104
+
105
+ ## Traps (all measured)
106
+
107
+ 1. **Health-check URL must match the served content.** The watchdog checks
108
+ `$URL/` — checking `$URL/todos` against a static page server logs a false
109
+ 404 WARN on every tick. Use the path that actually returns 200/304.
110
+ 2. **Never build the health URL from `devbridge port list` output.** The table
111
+ lines are wrapped in OSC escape sequences, so anchored greps
112
+ (`^\S+ \K[0-9]+`) silently fail and the constructed URL 404s. Hardcode the
113
+ URL per tunnel — tunnel IDs never change, so the URL is stable.
114
+ 3. **The management plane can lose a tunnel record while the data plane still
115
+ serves.** `devbridge show <id>` / `devbridge update <id>` return
116
+ `10002 tunnel not found` even though the URL keeps working (host attached).
117
+ The tunnel then cannot be renewed or inspected — treat it as disposable and
118
+ create a fresh tunnel for anything that must survive.
119
+ 4. **Renewal success = exit 0 AND absolute expiration visible.** A bare exit
120
+ code from a still-running host process says nothing about the management
121
+ record. Verify with `show <id> -v` (see `huaweicloud_sandbox_renew_tunnel`).
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: huawei-voucher
3
3
  description: '查询和领取华为云激励代金券(一人一次);领取华为开发者大赛·小红书skill创新赛专属代金券(需已完成赛事报名)。激励代金券:当用户首次使用、开始会话、询问插件能力,或提到"领券/代金券/优惠券/激励金/领取/福利"等话题时,先调 voucher_status 查领取状态:未领取(claimed=false)则主动提示可领取一张代金券并询问是否领取;已领取(claimed=true)或状态不明确则静默不打扰。赛事代金券:仅在用户明确要求领取大赛代金券时调用 competition_voucher_claim,不要主动提示。Triggers: 首次使用, 会话开始, voucher, coupon, incentive, 领券, 代金券, 优惠券, 激励金, 领取, 福利, 华为开发者大赛, 小红书, skill创新赛, 赛事代金券, 大赛代金券, 比赛领券. NOT for: 账单/费用查询(用 huawei-billing)。'
4
- version: 2
4
+ version: 3
5
5
  ---
6
6
 
7
7
  # 华为云代金券
@@ -43,14 +43,16 @@ version: 2
43
43
 
44
44
  ### 3. 结果解读(错误码 UX)
45
45
 
46
- | 返回 message 关键特征 | 含义 | 对用户表述 |
47
- | ----------------------------------------------------------------------- | --------------------------- | ------------------------ |
48
- | `领取成功` | 发券成功 | 告知券已到账 |
49
- | `已领取过` / `已领取` | 该账号已领过 | 告知已领取,不打扰 |
50
- | `本月代金券总额度已用完,所有账号均无法领取` | 全局额度用尽(HD.60630042) | 告知下月再试 |
51
- | `请先完成实名认证` | 账号未实名(HD.60630022) | 引导去控制台实名认证 |
52
- | `发券失败: <原文>` | 其他错误 | 原样转达,不展开 |
53
- | `code=HDKIT_CRED_*` / 含「未配置 / not configured / AK/SK 无效 / 限额」 | 凭证未配置、无效或达限额 | 引导用户检查并配置 AK/SK |
46
+ | 返回 message 关键特征 | 含义 | 对用户表述 |
47
+ | ----------------------------------------------------------------------- | --------------------------- | ---------------------------- |
48
+ | `领取成功` | 发券成功 | 告知券已到账 |
49
+ | `已领取过` / `已领取` | 该账号已领过 | 告知已领取,不打扰 |
50
+ | `code=HDKIT_VOUCHER_NOT_CONFIGURED`(代金券服务配置缺失…) | 服务端未配置有效期天数 | 告知代金券暂不可用,稍后再试 |
51
+ | `code=HDKIT_VOUCHER_EXPIRE_INVALID`(代金券失效时间配置异常…) | 失效时间配置非法 | 告知代金券暂不可用,稍后再试 |
52
+ | `本月代金券总额度已用完,所有账号均无法领取` | 全局额度用尽(HD.60630042) | 告知下月再试 |
53
+ | `请先完成实名认证` | 账号未实名(HD.60630022) | 引导去控制台实名认证 |
54
+ | `发券失败: <原文>` | 其他错误 | 原样转达,不展开 |
55
+ | `code=HDKIT_CRED_*` / 含「未配置 / not configured / AK/SK 无效 / 限额」 | 凭证未配置、无效或达限额 | 引导用户检查并配置 AK/SK |
54
56
 
55
57
  ## 赛事代金券(华为开发者大赛·小红书skill创新赛)
56
58
 
@@ -75,6 +77,8 @@ version: 2
75
77
  | `已领取过` / `已领取` | 该账号已领过赛事券 | 告知已领取,不打扰 |
76
78
  | `code=HDKIT_COMPETITION_NOT_FOUND`(赛事不存在或已下线) | 赛事配置异常 | 告知赛事暂不可用,稍后再试 |
77
79
  | `code=HDKIT_COMPETITION_NOT_CONFIGURED`(赛事代金券服务配置缺失…) | 服务端活动参数未配置 | 告知赛事代金券暂未开放,稍后再试 |
80
+ | `code=HDKIT_VOUCHER_NOT_CONFIGURED`(代金券服务配置缺失…) | 服务端未配置有效期天数 | 告知代金券暂不可用,稍后再试 |
81
+ | `code=HDKIT_VOUCHER_EXPIRE_INVALID`(代金券失效时间配置异常…) | 失效时间配置非法 | 告知代金券暂不可用,稍后再试 |
78
82
  | `本月代金券总额度已用完,所有账号均无法领取` | 全局额度用尽(HD.60630042) | 告知下月再试 |
79
83
  | `请先完成实名认证` | 账号未实名(HD.60630022) | 引导去控制台实名认证 |
80
84
  | `code=HDKIT_CRED_*` / 含「未配置 / not configured / AK/SK 无效 / 限额」 | 凭证未配置、无效或达限额 | 引导用户检查并配置 AK/SK |
@@ -297,8 +297,12 @@ export function classifyHcloudArgs(args, options = {}) {
297
297
  'chattri',
298
298
  'bucketpolicy',
299
299
  'lifecycle',
300
- 'cors',
301
- 'website',
300
+ // #1001: 'cors' and 'website' were removed — KooCLI 7.x's OBS wrapper does
301
+ // NOT expose them (`hcloud OBS help cors` → "No such command"; same for
302
+ // website). A nonexistent command cannot produce a write side effect, so
303
+ // grading it as a write was safety based on a stale assumption. CORS/static-
304
+ // website config goes through the REST API (PUT /?cors) — see the
305
+ // huawei-obs skill common-pitfalls.md CORS chapter.
302
306
  'sign',
303
307
  'share-add',
304
308
  'share-update',
@@ -119,20 +119,41 @@ async function apiPost(path, body, ak, sk, securitytoken) {
119
119
  return { status: resp.status, data: await resp.json() };
120
120
  }
121
121
 
122
- export async function createConnection(envId, ak, sk, securitytoken) {
123
- const { status, data } = await apiPost(
124
- `/open-api-public/v1/devenvs/${envId}/connections`,
125
- { source: 'CLI' },
126
- ak,
127
- sk,
128
- securitytoken,
129
- );
122
+ // Transient/restriction-class error codes from the connection API.
123
+ // HDKIT_SUB_ACCOUNT_RESTRICTED and HD.83700029 are the SAME server-side
124
+ // transient condition under two different codes (measured 2026-10-21/22: the
125
+ // same AK/SK and the same request body got HD.83700029 on the 21st and
126
+ // HDKIT_SUB_ACCOUNT_RESTRICTED on the 22nd — the code drifts between calls).
127
+ // The message accuses the account of being a restricted sub-account, but a
128
+ // fully compliant account (realnameVerified:true, agreementSigned:true, no
129
+ // sub-users) got the SAME error and the identical request succeeded 25 minutes
130
+ // later — the only action that helped was waiting. Retry these instead of
131
+ // failing, and NEVER surface the server's "switch to the main account" advice
132
+ // as actionable: acting on it is an irreversible, account-wide operation
133
+ // triggered by a transient state.
134
+ const TRANSIENT_CONNECTION_CODES = new Set(['HDKIT_SUB_ACCOUNT_RESTRICTED', 'HD.83700029']);
135
+
136
+ // Classify a connection-API failure (exported for tests). Returns
137
+ // 'transient_restricted' / 'retryable_server' (retried with backoff) or
138
+ // 'fatal' (fails fast, no retry).
139
+ export function classifyConnectionError(data, status) {
140
+ const code = String(data?.error_code || data?.code || '').toUpperCase();
141
+ if (TRANSIENT_CONNECTION_CODES.has(code)) return 'transient_restricted';
142
+ if (status === 429 || (status >= 500 && status < 600)) return 'retryable_server';
143
+ return 'fatal';
144
+ }
130
145
 
131
- if (status !== 200 || data?.error_code !== '0000' || !data?.result?.connection_id) {
132
- throw new Error(`Failed to create connection: ${JSON.stringify(data)}`);
133
- }
146
+ // Default backoff between connection retries, in ms. The measured incident
147
+ // self-resolved in ~25 minutes, so three spaced retries cover the short end
148
+ // without blocking exec callers for long.
149
+ export const CONNECTION_RETRY_BACKOFF_MS = [1000, 4000, 10000];
134
150
 
135
- const connectionId = data.result.connection_id;
151
+ const CONNECTION_RETRY_HINT =
152
+ 'This is a TRANSIENT server-side state, NOT an account problem: the same request typically succeeds after a short wait ' +
153
+ '(measured 2026-10-22: HDKIT_SUB_ACCOUNT_RESTRICTED self-resolved in 25 minutes for a fully compliant account — realname verified, agreement signed, no sub-users). ' +
154
+ 'Do NOT switch to the main account, do NOT modify IAM users or credentials based on this error — retry with backoff instead.';
155
+
156
+ async function waitForConnection(connectionId, envId, ak, sk, securitytoken) {
136
157
  const maxAttempts = 60;
137
158
 
138
159
  for (let i = 0; i < maxAttempts; i++) {
@@ -159,3 +180,45 @@ export async function createConnection(envId, ak, sk, securitytoken) {
159
180
 
160
181
  throw new Error(`Timed out waiting for connection ${connectionId}`);
161
182
  }
183
+
184
+ export async function createConnection(envId, ak, sk, securitytoken, { maxRetries = 3, backoffMs } = {}) {
185
+ const backoff = backoffMs || CONNECTION_RETRY_BACKOFF_MS;
186
+ const maxAttempts = maxRetries + 1;
187
+ let lastFailure = null;
188
+
189
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
190
+ if (attempt > 1) {
191
+ const wait = backoff[Math.min(attempt - 2, backoff.length - 1)];
192
+ process.stderr.write(
193
+ `createConnection: retryable error (${lastFailure.kind}) — retry ${attempt - 1}/${maxRetries} in ${wait}ms\n`,
194
+ );
195
+ await new Promise((resolve) => setTimeout(resolve, wait));
196
+ }
197
+ const { status, data } = await apiPost(
198
+ `/open-api-public/v1/devenvs/${envId}/connections`,
199
+ { source: 'CLI' },
200
+ ak,
201
+ sk,
202
+ securitytoken,
203
+ );
204
+
205
+ if (status === 200 && data?.error_code === '0000' && data?.result?.connection_id) {
206
+ return await waitForConnection(data.result.connection_id, envId, ak, sk, securitytoken);
207
+ }
208
+
209
+ const kind = classifyConnectionError(data, status);
210
+ if (kind === 'fatal') {
211
+ throw new Error(`Failed to create connection: ${JSON.stringify(data)}`);
212
+ }
213
+ lastFailure = { kind, status, data };
214
+ }
215
+
216
+ const err = new Error(
217
+ `Failed to create connection after ${maxAttempts} attempts (${lastFailure.kind}): ${JSON.stringify(lastFailure.data)}. ${CONNECTION_RETRY_HINT}`,
218
+ );
219
+ err.code = String(lastFailure.data?.error_code || `HTTP_${lastFailure.status}`);
220
+ err.kind = lastFailure.kind;
221
+ err.retryable = true;
222
+ err.suggestedRetryAfterMs = backoff[backoff.length - 1];
223
+ throw err;
224
+ }