huaweicloud-devkit 1.1.10-next.4 → 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.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/huaweicloud-core/.claude-plugin/plugin.json +1 -1
  5. package/plugins/huaweicloud-core/.codex-plugin/plugin.json +1 -1
  6. package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +1 -1
  7. package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +1 -1
  8. package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +1 -1
  9. package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
  10. package/plugins/huaweicloud-core/safety/rules/cloud-risk-rules.json +1 -7
  11. package/plugins/huaweicloud-core/skills/huawei-ecs/references/troubleshooting.md +18 -17
  12. package/plugins/huaweicloud-core/skills/huawei-obs/SKILL.md +17 -16
  13. package/plugins/huaweicloud-core/skills/huawei-obs/references/common-pitfalls.md +168 -1
  14. package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +75 -13
  15. package/plugins/huaweicloud-core/skills/huawei-sandbox/references/fullstack-postgrest.md +149 -0
  16. package/plugins/huaweicloud-core/skills/huawei-sandbox/references/tunnel-persistence.md +121 -0
  17. package/plugins/huaweicloud-core/skills/huawei-voucher/SKILL.md +13 -9
  18. package/plugins/huaweicloud-core/src/account-type.mjs +1 -10
  19. package/plugins/huaweicloud-core/src/auth/agent-registration.mjs +1 -1
  20. package/plugins/huaweicloud-core/src/auth/credentials.mjs +4 -92
  21. package/plugins/huaweicloud-core/src/auth/service.mjs +2 -24
  22. package/plugins/huaweicloud-core/src/hcloud-cli.mjs +4 -43
  23. package/plugins/huaweicloud-core/src/mcp-config-merge.mjs +1 -1
  24. package/plugins/huaweicloud-core/src/risk-rule-engine.mjs +1 -1
  25. package/plugins/huaweicloud-core/src/safety-policy.mjs +7 -3
  26. package/plugins/huaweicloud-core/src/sandbox/hdkitservice-api.mjs +6 -28
  27. package/plugins/huaweicloud-core/src/sandbox/hwlink-api.mjs +75 -12
  28. package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +396 -9
  29. package/plugins/huaweicloud-core/src/setup-cli.mjs +9 -91
  30. package/plugins/huaweicloud-core/src/tools.mjs +260 -5
  31. package/integrations/opencode/package-lock.json +0 -24
  32. package/integrations/opencode/package.json +0 -10
  33. package/integrations/opencode/plugins/huaweicloud-devkit.js +0 -194
  34. package/plugins/huaweicloud-core/src/auth/token-exchange.mjs +0 -100
@@ -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 |
@@ -112,16 +112,7 @@ export async function checkAccountType({ region } = {}) {
112
112
  }
113
113
  const identity = parseCallerIdentity(identityResult.stdout);
114
114
  if (!identity.ok) {
115
- // hcloud ran but stdout was not the expected JSON — usually a credentials
116
- // problem surfacing as free-text error output, not a parser bug.
117
- const snippet = String(identityResult.stdout || '')
118
- .slice(0, 200)
119
- .replace(/\s+/g, ' ');
120
- return {
121
- ok: false,
122
- error: identity.error + (snippet ? ` (raw: ${snippet})` : ''),
123
- hint: 'GetCallerIdentity 未返回 JSON,通常是凭据未就绪:请确认已运行 auth init、或平台已注入可用 STS(先用 huaweicloud_auth_status 查看 usableCredentials/credentialPanel)。',
124
- };
115
+ return { ok: false, error: identity.error };
125
116
  }
126
117
 
127
118
  const domainsResult = await runHcloud(['IAM', 'KeystoneListAuthDomains'], {
@@ -31,7 +31,7 @@ function opencodeConfigFile() {
31
31
  function readJsonSafe(path) {
32
32
  if (!existsSync(path)) return null;
33
33
  try {
34
- return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
34
+ return JSON.parse(readFileSync(path, 'utf8'));
35
35
  } catch {
36
36
  return null;
37
37
  }
@@ -50,18 +50,11 @@ export function obsConfigPath() {
50
50
  return process.env.HCLOUD_OBS_CONFIG_PATH || join(homedir(), '.obsutilconfig');
51
51
  }
52
52
 
53
- // Read a JSON file, stripping a leading UTF-8 BOM (U+FEFF) if present. CodeArts
54
- // writes mcp_settings.json with a BOM on Windows; plain JSON.parse would throw.
55
- export function readJsonFile(path) {
56
- const text = readFileSync(path, 'utf8');
57
- return JSON.parse(text.replace(/^\uFEFF/, ''));
58
- }
59
-
60
53
  export function readGlobalCredentials() {
61
54
  const path = globalCredentialsPath();
62
55
  if (!existsSync(path)) return null;
63
56
  try {
64
- return readJsonFile(path);
57
+ return JSON.parse(readFileSync(path, 'utf8'));
65
58
  } catch {
66
59
  return null;
67
60
  }
@@ -332,7 +325,7 @@ export function readCodeArtsCredentials() {
332
325
  const path = join(dir, '.codeartsdoer', 'mcp', 'mcp_settings.json');
333
326
  try {
334
327
  if (!existsSync(path)) continue;
335
- const config = readJsonFile(path);
328
+ const config = JSON.parse(readFileSync(path, 'utf8'));
336
329
  const server = pickDevkitMcpServer(config?.mcpServers);
337
330
  if (!server?.env) continue;
338
331
 
@@ -362,7 +355,7 @@ export function readCodeArtsCredentials() {
362
355
  for (const path of workPaths) {
363
356
  try {
364
357
  if (!existsSync(path)) continue;
365
- const config = readJsonFile(path);
358
+ const config = JSON.parse(readFileSync(path, 'utf8'));
366
359
  const entry = readWorkEnvironmentEntry(config);
367
360
  if (entry) return entry;
368
361
  } catch {
@@ -400,87 +393,6 @@ export function resolveCredentialsWithRuntime(options = {}) {
400
393
  return resolveCredentials(options);
401
394
  }
402
395
 
403
- export function resolveXAuthToken() {
404
- const envToken = process.env.HW_X_AUTH_TOKEN;
405
- if (present(envToken)) return envToken;
406
-
407
- const parentCwd = getParentCwd();
408
- const searchDirs = [process.env.CODEARTS_PROJECT_DIR, parentCwd, process.cwd(), homedir()];
409
- for (const dir of searchDirs) {
410
- if (!dir) continue;
411
- const path = join(dir, '.codeartsdoer', 'mcp', 'mcp_settings.json');
412
- try {
413
- if (!existsSync(path)) continue;
414
- const config = readJsonFile(path);
415
- const server = pickDevkitMcpServer(config?.mcpServers);
416
- const token = server?.env?.HW_X_AUTH_TOKEN;
417
- if (present(token)) return token;
418
- } catch {}
419
- }
420
-
421
- const workPaths = [
422
- join(homedir(), '.codearts', 'mcp', 'mcp_settings.json'),
423
- join(homedir(), '.codeartswork', 'mcp', 'mcp_settings.json'),
424
- ];
425
- for (const path of workPaths) {
426
- try {
427
- if (!existsSync(path)) continue;
428
- const config = readJsonFile(path);
429
- const server = pickDevkitMcpServer(config?.mcp);
430
- const token = server?.environment?.HW_X_AUTH_TOKEN;
431
- if (present(token)) return token;
432
- } catch {}
433
- }
434
- return null;
435
- }
436
-
437
- export function resolveXAuthTokenExpiry() {
438
- const v = process.env.HW_X_AUTH_TOKEN_EXPIRES_AT;
439
- if (v && typeof v === 'string' && v.trim()) {
440
- const asNum = Number(v.trim());
441
- if (Number.isFinite(asNum) && asNum > 0) return asNum > 1e12 ? asNum : asNum * 1000;
442
- const t = Date.parse(v.trim());
443
- if (Number.isFinite(t)) return t;
444
- }
445
-
446
- // Fallback: 未注入 HW_X_AUTH_TOKEN_EXPIRES_AT 时,从 token 字符串自解析过期时间。
447
- // X-Auth-Token 是 CMS/PKCS#7 SignedData(base64 DER),其 eContent 内嵌明文 JSON
448
- // (含 token.expires_at);base64 解码 + 提取 {"token" JSON 即可读出,无需依赖注入方显式给过期时间。
449
- const token = resolveXAuthToken();
450
- if (!token) return null;
451
- return parseXAuthTokenExpiry(token);
452
- }
453
-
454
- function parseXAuthTokenExpiry(token) {
455
- try {
456
- const pad = token + '='.repeat((4 - (token.length % 4)) % 4);
457
- const text = Buffer.from(pad.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
458
- const idx = text.indexOf('{"token"');
459
- if (idx < 0) return null;
460
- const tail = text.slice(idx);
461
- let depth = 0;
462
- let end = -1;
463
- for (let i = 0; i < tail.length; i += 1) {
464
- if (tail[i] === '{') depth += 1;
465
- else if (tail[i] === '}') {
466
- depth -= 1;
467
- if (depth === 0) {
468
- end = i + 1;
469
- break;
470
- }
471
- }
472
- }
473
- if (end < 0) return null;
474
- const obj = JSON.parse(tail.slice(0, end));
475
- const expiresAt = obj?.token?.expires_at;
476
- if (!expiresAt || typeof expiresAt !== 'string') return null;
477
- const t = Date.parse(expiresAt);
478
- return Number.isFinite(t) ? t : null;
479
- } catch {
480
- return null;
481
- }
482
- }
483
-
484
396
  export function lastSyncPath() {
485
397
  return join(baseHome(), '.config', 'huaweicloud', '.last_sync');
486
398
  }
@@ -489,7 +401,7 @@ export function readLastSync() {
489
401
  const path = lastSyncPath();
490
402
  if (!existsSync(path)) return null;
491
403
  try {
492
- return readJsonFile(path);
404
+ return JSON.parse(readFileSync(path, 'utf8'));
493
405
  } catch {
494
406
  return null;
495
407
  }
@@ -11,7 +11,6 @@ import {
11
11
  readCodeArtsCredentials,
12
12
  readGlobalCredentials,
13
13
  resolveCredentialsWithRuntime,
14
- resolveXAuthToken,
15
14
  writeLastSync,
16
15
  writeObsConfig,
17
16
  } from './credentials.mjs';
@@ -80,14 +79,6 @@ export function computeOnboarding({ credentials, reconciled } = {}) {
80
79
  message = '已从码道 MCP settings 读取到临时凭证,MCP 工具可直接使用。';
81
80
  return { needsSetup: false, scenario, reason, message, steps: [], accountHint };
82
81
  }
83
- if (resolveXAuthToken() && !s1Has && !envHasCreds) {
84
- // X-Auth-Token authentication: no AK/SK injected but a token is present —
85
- // devkit exchanges it for temporary AK/SK on demand, so no manual setup.
86
- scenario = 0;
87
- reason = 'token-auth';
88
- message = '已使用 X-Auth-Token 认证,MCP 工具可直接使用,无需配置 AK/SK。';
89
- return { needsSetup: false, scenario, reason, message, steps: [], accountHint };
90
- }
91
82
  if (s1Has && !envHasCreds) {
92
83
  scenario = 1;
93
84
  reason = 's1-only';
@@ -176,7 +167,6 @@ export function getAuthStatus(target = 'all') {
176
167
  const s4Creds = readCodeArtsCredentials();
177
168
  const onboarding = computeOnboarding({ credentials, reconciled });
178
169
  const stsExpiry = computeStsExpiry();
179
- const tokenAuth = resolveXAuthToken();
180
170
  const credentialPanel = {
181
171
  sts: stsExpiry, // 临时 STS(MCP==沙箱同源)
182
172
  s1: {
@@ -184,23 +174,11 @@ export function getAuthStatus(target = 'all') {
184
174
  fingerprint: credentials?.ak ? fingerprint(credentials.ak, credentials.sk) : null,
185
175
  persistent: true, // 永久,无过期
186
176
  },
187
- token: tokenAuth ? { active: true } : { active: false },
188
- activeSource: stsExpiry ? 'temporary-sts' : credentials?.ak ? 's1-persistent' : tokenAuth ? 'x-auth-token' : 'none',
177
+ activeSource: stsExpiry ? 'temporary-sts' : credentials?.ak ? 's1-persistent' : 'none',
189
178
  };
190
179
  return {
191
180
  target,
192
- // usableCredentials: any credential source that makes the MCP/backend path
193
- // actually work right now (S1, CodeArts settings, platform STS triplet,
194
- // X-Auth-Token, or runtime creds) — supersedes the misleading
195
- // credentialsConfigured/onboarding split (#P4).
196
- usableCredentials: Boolean(
197
- (credentials?.ak && credentials?.sk) ||
198
- (s4Creds?.ak && s4Creds?.sk) ||
199
- envHasRealTriplet() ||
200
- tokenAuth ||
201
- hasRuntimeCredentials(),
202
- ),
203
- credentialsConfigured: Boolean(credentials?.ak && credentials?.sk) || Boolean(tokenAuth),
181
+ credentialsConfigured: Boolean(credentials?.ak && credentials?.sk),
204
182
  credentialsPath: globalCredentialsPath(),
205
183
  mcpSettingsConfigured: Boolean(s4Creds?.ak && s4Creds?.sk),
206
184
  mcpSettingsSource: s4Creds ? 'codearts' : null,
@@ -7,13 +7,7 @@ import { dirname, join } from 'node:path';
7
7
  import { classifyHcloudArgs, redactSecrets, assertAllowed } from './safety-policy.mjs';
8
8
  import { getProxySettings } from './proxy/proxy-config.mjs';
9
9
  import { resolveHcloudCommand } from './hcloud-probe.mjs';
10
- import {
11
- parseStsExpiry,
12
- resolveCredentialsWithRuntime,
13
- resolveXAuthToken,
14
- resolveXAuthTokenExpiry,
15
- } from './auth/credentials.mjs';
16
- import { getTokenCredentials } from './auth/token-exchange.mjs';
10
+ import { parseStsExpiry, resolveCredentialsWithRuntime } from './auth/credentials.mjs';
17
11
 
18
12
  const DEFAULT_TIMEOUT_MS = 60_000;
19
13
  const DEFAULT_FORCE_KILL_AFTER_MS = 2_000;
@@ -303,7 +297,7 @@ export function redactArgsWithObs(rawArgs) {
303
297
  return redactSecrets(out);
304
298
  }
305
299
 
306
- export async function resolveStsInjectArgs(rawArgs) {
300
+ export function resolveStsInjectArgs(rawArgs) {
307
301
  const normalized = Array.isArray(rawArgs) ? rawArgs.map(String) : [];
308
302
  if (normalized.length === 0) return [];
309
303
  const flat = normalized.map(String);
@@ -356,25 +350,7 @@ export async function resolveStsInjectArgs(rawArgs) {
356
350
  } catch {
357
351
  return [];
358
352
  }
359
- if (!creds || !creds.ak || !creds.sk) {
360
- const token = resolveXAuthToken();
361
- if (token) {
362
- const expiry = resolveXAuthTokenExpiry();
363
- if (expiry !== null && expiry - 60000 <= Date.now()) {
364
- const err = new Error('X-Auth-Token 已过期,请重启进程重新注入凭证。');
365
- err.code = 'TOKEN_EXPIRED';
366
- throw err;
367
- }
368
- const temp = await getTokenCredentials(token);
369
- return [
370
- '--cli-access-key=' + temp.ak,
371
- '--cli-secret-key=' + temp.sk,
372
- '--cli-security-token=' + temp.securityToken,
373
- ];
374
- }
375
- return [];
376
- }
377
- if (!creds.securityToken) return [];
353
+ if (!creds || !creds.ak || !creds.sk || !creds.securityToken) return [];
378
354
 
379
355
  // R3: if we can derive an expiry and the token is already at/within 60s of
380
356
  // expiring, skip injection — using a dead token would make a doomed IAM round
@@ -492,22 +468,7 @@ export async function runHcloud(args, options = {}) {
492
468
  // and R3 forbids writing them to disk). Inject per-command so hcloud actually
493
469
  // authenticates with the platform-provided STS. Injection args are appended to
494
470
  // the EXECUTED args only; classification/approval used the original args.
495
- let stsInject;
496
- try {
497
- stsInject = await resolveStsInjectArgs(normalizedArgs);
498
- } catch (error) {
499
- if (error?.code === 'TOKEN_INVALID' || error?.code === 'TOKEN_EXCHANGE_FAILED' || error?.code === 'TOKEN_EXPIRED') {
500
- return {
501
- ok: false,
502
- code: error.code,
503
- error: error.message,
504
- stderr: '',
505
- stdout: '',
506
- plan,
507
- };
508
- }
509
- throw error;
510
- }
471
+ const stsInject = resolveStsInjectArgs(normalizedArgs);
511
472
  if (stsInject.length > 0) {
512
473
  plan.rawArgs = [...plan.rawArgs, ...stsInject];
513
474
  }
@@ -3,7 +3,7 @@
3
3
  // Program-owned fields (the node executable + mcp-server.mjs path, required env
4
4
  // keys) are corrected by the installer; everything else belongs to the user.
5
5
 
6
- export function isPlainObject(value) {
6
+ function isPlainObject(value) {
7
7
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
8
8
  }
9
9
 
@@ -19,7 +19,7 @@ export function loadRiskRules(options = {}) {
19
19
  function redactEvidence(text) {
20
20
  return String(text)
21
21
  .replace(
22
- /((?:access[_-]?key|secret[_-]?key|secret|security[_-]?token|x[_-]?auth[_-]?token|x[_-]?subject[_-]?token|token|authorization|password|passwd|admin[_-]?pass|credential)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi,
22
+ /((?:access[_-]?key|secret[_-]?key|secret|security[_-]?token|x[_-]?auth[_-]?token|token|authorization|password|passwd|admin[_-]?pass|credential)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi,
23
23
  '$1<redacted>',
24
24
  )
25
25
  .replace(/(AK|SK)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)/g, '$1=<redacted>');
@@ -39,7 +39,7 @@ function redactString(text) {
39
39
  // Redact the ENTIRE value of this arg — not just the first whitespace token.
40
40
  .replace(/((?:user[_-]?data|metadata|private[_-]?key)\s*[:=]\s*).*/gi, '$1<redacted>')
41
41
  .replace(
42
- /((?:access[_-]?key|secret[_-]?key|security[_-]?token|x[_-]?auth[_-]?token|x[_-]?subject[_-]?token|token|authorization|password|passwd|admin[_-]?pass|credential)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi,
42
+ /((?:access[_-]?key|secret[_-]?key|security[_-]?token|x[_-]?auth[_-]?token|token|authorization|password|passwd|admin[_-]?pass|credential)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi,
43
43
  '$1<redacted>',
44
44
  )
45
45
  .replace(/(AK|SK)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)/g, '$1=<redacted>')
@@ -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',