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.
- 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/safety/rules/cloud-risk-rules.json +1 -7
- package/plugins/huaweicloud-core/skills/huawei-ecs/references/troubleshooting.md +18 -17
- 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/account-type.mjs +1 -10
- package/plugins/huaweicloud-core/src/auth/agent-registration.mjs +1 -1
- package/plugins/huaweicloud-core/src/auth/credentials.mjs +4 -92
- package/plugins/huaweicloud-core/src/auth/service.mjs +2 -24
- package/plugins/huaweicloud-core/src/hcloud-cli.mjs +4 -43
- package/plugins/huaweicloud-core/src/mcp-config-merge.mjs +1 -1
- package/plugins/huaweicloud-core/src/risk-rule-engine.mjs +1 -1
- package/plugins/huaweicloud-core/src/safety-policy.mjs +7 -3
- package/plugins/huaweicloud-core/src/sandbox/hdkitservice-api.mjs +6 -28
- 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/setup-cli.mjs +9 -91
- package/plugins/huaweicloud-core/src/tools.mjs +260 -5
- package/integrations/opencode/package-lock.json +0 -24
- package/integrations/opencode/package.json +0 -10
- package/integrations/opencode/plugins/huaweicloud-devkit.js +0 -194
- 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:
|
|
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
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
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
|
-
|
|
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')
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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|
|
|
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
|
-
|
|
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',
|