huaweicloud-devkit 1.1.12-next.0 → 1.1.13-next.0

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
5
5
  [![CI](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml/badge.svg)](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/huaweicloud-devkit)](https://www.npmjs.com/package/huaweicloud-devkit)
7
- [![Beta](https://img.shields.io/badge/beta-v1.1.12-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
7
+ [![Beta](https://img.shields.io/badge/beta-v1.1.13-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
8
8
 
9
9
  **[中文](README.zh-CN.md) | English**
10
10
 
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
5
5
  [![CI](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml/badge.svg)](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/huaweicloud-devkit)](https://www.npmjs.com/package/huaweicloud-devkit)
7
- [![Beta](https://img.shields.io/badge/beta-v1.1.12-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
7
+ [![Beta](https://img.shields.io/badge/beta-v1.1.13-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
8
8
 
9
9
  **中文 | [English](README.md)**
10
10
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
3
  "mcpName": "io.github.huaweicloud/huaweicloud-devkit",
4
- "version": "1.1.12-next.0",
4
+ "version": "1.1.13-next.0",
5
5
  "kooCliVersion": "7.3.12",
6
6
  "description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
7
7
  "type": "module",
@@ -43,7 +43,7 @@
43
43
  "postinstall": "node ./bin/dsh-postinstall.cjs"
44
44
  },
45
45
  "dependencies": {
46
- "undici": "^8.11.2"
46
+ "undici": "^8.12.3"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@eslint/js": "^10.0.1",
@@ -17,7 +17,7 @@
17
17
  "mcpServers": "./.mcp.json",
18
18
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
19
19
  "skills": "./skills/",
20
- "version": "1.1.12-next.0",
20
+ "version": "1.1.13-next.0",
21
21
  "author": {
22
22
  "name": "HuaweiCloud Mate",
23
23
  "url": "https://github.com/huaweicloud"
@@ -20,7 +20,7 @@
20
20
  "mcpServers": "./.mcp.json",
21
21
  "description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
22
22
  "skills": "./skills/",
23
- "version": "1.1.12-next.0",
23
+ "version": "1.1.13-next.0",
24
24
  "author": {
25
25
  "name": "HuaweiCloud Mate",
26
26
  "url": "https://github.com/huaweicloud"
@@ -17,7 +17,7 @@
17
17
  "mcpServers": "./.mcp.json",
18
18
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
19
19
  "skills": "./skills/",
20
- "version": "1.1.12-next.0",
20
+ "version": "1.1.13-next.0",
21
21
  "author": {
22
22
  "name": "HuaweiCloud Mate",
23
23
  "url": "https://github.com/huaweicloud"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
- "version": "1.1.12-next.0",
3
+ "version": "1.1.13-next.0",
4
4
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
5
5
  "author": {
6
6
  "name": "HuaweiCloud Mate",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
- "version": "1.1.12-next.0",
3
+ "version": "1.1.13-next.0",
4
4
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
5
5
  "author": {
6
6
  "name": "HuaweiCloud Mate",
@@ -2,7 +2,7 @@
2
2
  "name": "huaweicloud-devkit",
3
3
  "id": "huaweicloud-devkit",
4
4
  "displayName": "HuaweiCloud DevKit",
5
- "version": "1.1.12-next.0",
5
+ "version": "1.1.13-next.0",
6
6
  "family": "bundle-plugin",
7
7
  "bundleFormat": "codex",
8
8
  "description": "Guide coding agents to use Huawei Cloud safely — KooCLI, APIs, SDKs, 28 MCP tools, skills, and safety guardrails.",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: huawei-sandbox
3
- description: 'Use when creating, connecting, or managing Huawei Cloud Sandbox instances and workspace terminals, or when a task needs a temporary runtime to deploy, run, or preview a web application. Covers sandbox lifecycle (check-user, sign-agreement, connect, release), session-based terminal execution, and credential injection. Triggers on: sandbox, workspace, terminal, web app deployment, deploy web app, preview app, deploy github, github repo, deploy repo, website, static site, web application, hwlink, devstation, hdkitservice, remote exec. NOT for: ECS instances (use huawei-ecs), CCE clusters (use huawei-cce).'
3
+ description: 'Use when creating, connecting, or managing Huawei Cloud Sandbox instances and workspace terminals, or when a task needs a temporary runtime to deploy, run, or preview a web application, backend service, or full-stack workbench. Covers sandbox lifecycle (check-user, sign-agreement, connect, release), session-based terminal execution, and credential injection. Triggers on: sandbox, workspace, terminal, web app deployment, deploy web app, preview app, deploy github, github repo, deploy repo, website, static site, web application, backend service, 工作台, workbench, full-stack, 全栈, CRUD 应用, database-backed REST API, PostgREST, postgres, hwlink, devstation, hdkitservice, remote exec. NOT for: ECS instances (use huawei-ecs), CCE clusters (use huawei-cce).'
4
4
  version: 1
5
5
  ---
6
6
 
@@ -27,6 +27,22 @@ Hard rules:
27
27
  - Include the OBS domain reminder and the ECS EIP pairing reminder in the options as presented.
28
28
  - After the choice: sandbox → continue with this skill; OBS → `huawei-obs`; ECS → `huawei-ecs` / `huawei-iac`; CCE → `huawei-cce`.
29
29
 
30
+ ## Stack-Selection Gate (MANDATORY — do not skip)
31
+
32
+ When the request involves a **workbench / 工作台 / 全栈 / full-stack project** — front-end plus back-end, CRUD with persistence, or a database-backed app — you MUST treat it as a **full-stack workbench deployment**: the back end defaults to **PostgreSQL + PostgREST** even when the developer never mentions 数据库/postgres/CRUD. Do NOT silently fall through to the SPA/nginx-only path.
33
+
34
+ Use the `question` tool to confirm the stack whenever the backend is ambiguous, presenting the PostgREST default first:
35
+
36
+ 1. **PostgreSQL + PostgREST 全栈 (recommended)** — schema + read-only `api_user` role, nginx `/api/` reverse proxy; the REST layer needs zero application code
37
+ 2. **应用自带后端** — the project ships its own server (Express/Next API/Nuxt server routes); deploy it inside the sandbox per its framework
38
+ 3. **纯静态/SPA** — no persistence; nginx-only
39
+
40
+ Hard rules:
41
+
42
+ - **NEVER infer or default the stack to SPA** when persistence is implied: 工作台/workbench/全栈/full-stack/CRUD/数据库/持久化 ⇒ full-stack (PostgREST default) unless the developer explicitly picks otherwise.
43
+ - **Pre-code checkpoint (MANDATORY)**: before uploading or writing ANY application code, verify the backend choice matches this gate. If an SPA-only deployment already started and persistence turns out to be implied, STOP, tell the developer, and redirect to the full-stack path — do not paper over it with a bolted-on static backend.
44
+ - After the choice: PostgREST → follow [Full-Stack: REST API over PostgreSQL](#full-stack-rest-api-over-postgresql-postgrest) **FIRST** (backend before any frontend code), then continue with the web deployment steps; 自带后端 / 纯静态 → continue with [Web Application Deployment](#web-application-deployment).
45
+
30
46
  ## Overview
31
47
 
32
48
  Domain expertise for Huawei Cloud Sandbox (DevStation) instances and workspace terminal execution. Covers sandbox lifecycle via hdkitservice API and remote terminal command execution via hwlink protocol.
@@ -117,6 +133,8 @@ Domain expertise for Huawei Cloud Sandbox (DevStation) instances and workspace t
117
133
 
118
134
  **Step 0 — Target-Selection Gate (MANDATORY)**: before any sandbox lifecycle tool, confirm the target. If the developer asked to deploy/host/publish a site without naming a target, STOP and run the [Target-Selection Gate](#target-selection-gate-mandatory--do-not-skip) first; do NOT call `huaweicloud_sandbox_connect` until it is resolved. Proceed with this workflow only if the target is already confirmed as sandbox (or a temporary runtime).
119
135
 
136
+ **Step 0.5 — Stack-Selection Gate (MANDATORY)**: before uploading or writing any application code, confirm the backend stack via the [Stack-Selection Gate](#stack-selection-gate-mandatory--do-not-skip). 工作台/workbench/全栈/full-stack/CRUD/持久化 ⇒ full-stack (PostgreSQL + PostgREST default) — do NOT start an SPA-only deployment for a workbench request.
137
+
120
138
  Setup is a **plugin-side preflight** — the developer should be asked a question only once, when the agreement actually needs signing:
121
139
 
122
140
  1. **Check user** (transparent): `huaweicloud_sandbox_check_user` — returns `realnameVerified`/`agreementSigned` (200) when all good, OR throws a 403 error with one of these codes:
@@ -462,13 +480,13 @@ When deploying a **full-stack workbench project** (前后端工作台) or asking
462
480
  3. **Nginx single-origin** — `/` serves the frontend, `/api/` reverse-proxies to PostgREST (no CORS, same origin). The frontend dir needs `chmod o+x` on the whole path chain or nginx returns 403.
463
481
  4. **Expose + keep alive (automatic)** — `huaweicloud_sandbox_expose_tunnel` with the nginx port; it AUTO-enrolls the tunnel into the keepalive watchdog (merged into existing coverage). Prefer `expiry_hours: 720` for long-lived workbench deployments.
464
482
 
465
- Complete measured procedure (with the full command chain): see `references/fullstack-postgrest.md`.
483
+ Complete measured procedure (full command chain) plus the 2026-10 field notes (Aliyun mirror URLs, cross-mirror resume corruption, `upload_file` 640 → PGRST002, exec-channel escaping → upload files and run `psql -f` / `curl -d @file`): see `references/fullstack-postgrest.md`. For the sandbox restart recovery checklist (what survives on `/workspace` vs what is lost on the 系统盘, plus the write-GRANT rollback trap): see the `Sandbox Restart Recovery Checklist` section in the same file.
466
484
 
467
485
  ## Web Application Deployment
468
486
 
469
487
  When deploying a web application to the sandbox, build the app inside the sandbox before exposing it. Source code is uploaded, dependencies installed, and the framework built in the sandbox environment.
470
488
 
471
- **Backend present?** If the project includes a PostgreSQL + PostgREST backend (or the developer asks for one), set up the backend FIRST by following [Full-Stack: REST API over PostgreSQL](#full-stack-rest-api-over-postgresql-postgrest) — install PostgreSQL/PostgREST, create the schema, and point the frontend's nginx `/api/` location at PostgREST — then continue with the steps below. Every tunnel exposed afterwards is auto-enrolled into the keepalive watchdog.
489
+ **Backend selection (Stack-Selection Gate)**: the backend choice was already confirmed by the [Stack-Selection Gate](#stack-selection-gate-mandatory--do-not-skip) before any code was uploaded. If the project includes a PostgreSQL + PostgREST backend (or the developer asked for one), set up the backend FIRST by following [Full-Stack: REST API over PostgreSQL](#full-stack-rest-api-over-postgresql-postgrest) — install PostgreSQL/PostgREST, create the schema, and point the frontend's nginx `/api/` location at PostgREST — then continue with the steps below. Every tunnel exposed afterwards is auto-enrolled into the keepalive watchdog.
472
490
 
473
491
  **沙箱无 systemd(实测 #935 报告3 问题3)**:沙箱为无 systemd 的容器环境,`systemctl` 全部失效(`System has not been booted with systemd`)—— 不要用 `systemctl start/reload/restart nginx`。Nginx 配置变更直接使用 `nginx -t` 验证 + `nginx -s reload` 热加载,或直接执行 nginx 二进制(`sudo nginx` 启动 / `nginx -s stop` 停止)。`huaweicloud_sandbox_deploy_nginx` 工具返回的 nextStep 文案与该语义一致(配置写入后自动回验存在性与 `nginx -t`,通过后即生效,无需 systemctl)。
474
492
 
@@ -25,10 +25,12 @@ least 14.0"). PGDG is the source for PG 16 — but HCE is NOT el8-lib-compatible
25
25
  ```bash
26
26
  # PGDG repo RPM fails on HCE ("nothing provides /etc/redhat-release") — write the repo file by hand.
27
27
  # The el8 path is rhel-8-aarch64 WITH A HYPHEN: rhel8-aarch64 (no hyphen) 404s.
28
+ # Use the Aliyun mirror: download.postgresql.org / repo.almalinux.org measure ~8KB/s from
29
+ # sandbox CN egress (a 6.8MB server RPM times out inside 240s); the mirror returns in seconds.
28
30
  cat > /etc/yum.repos.d/pgdg16.repo << 'PGDG_EOF'
29
31
  [pgdg16]
30
- name=PostgreSQL 16 for RHEL8 aarch64
31
- baseurl=https://download.postgresql.org/pub/repos/yum/16/redhat/rhel-8-aarch64/
32
+ name=PostgreSQL 16 for RHEL8 aarch64 (Aliyun mirror)
33
+ baseurl=https://mirrors.aliyun.com/postgresql/repos/yum/16/redhat/rhel-8-aarch64/
32
34
  enabled=1
33
35
  gpgcheck=0
34
36
  PGDG_EOF
@@ -39,10 +41,13 @@ differently (they want `libldap-2.5.so.0`). Install the el8 RPMs with
39
41
  `--nodeps` and shim the sonames:
40
42
 
41
43
  ```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.
44
+ # Aliyun mirror (measured 2026-10: seconds per RPM vs ~8KB/s timeouts on the official source).
45
+ # Download with resume (--max-time + -C -) WITHIN THE SAME MIRROR only: a partial file
46
+ # resumed from a DIFFERENT mirror fails rpm verification ("Payload SHA256 digest: BAD") —
47
+ # when switching sources, rm the partial file and re-download in full (see field notes).
48
+ # A corrupt RPM also ABORTS the whole transaction, silently skipping the other RPMs in the
49
+ # same rpm -ivh call. Install one RPM per call.
50
+ BASE="https://mirrors.aliyun.com/postgresql/repos/yum/16/redhat/rhel-8-aarch64"
46
51
  curl -fsSL --http1.1 --max-time 240 -C - "$BASE/postgresql16-libs-16.15-1PGDG.rhel8.10.aarch64.rpm" -o /tmp/pg16-libs.rpm
47
52
  rpm -ivh --nodeps /tmp/pg16-libs.rpm
48
53
  curl -fsSL --http1.1 --max-time 240 -C - "$BASE/postgresql16-16.15-1PGDG.rhel8.10.aarch64.rpm" -o /tmp/pg16.rpm
@@ -56,9 +61,10 @@ Soname shims (measured 2026-10):
56
61
  ```bash
57
62
  # ICU: versioned symbols (u_strToLower_60) make symlinks IMPOSSIBLE — a
58
63
  # libicu.so.60 -> .69 symlink loads but crashes with "undefined symbol".
59
- # Install the REAL el8 libicu60 (different sonames, coexists with HCE's icu69):
64
+ # Install the REAL el8 libicu60 (different sonames, coexists with HCE's icu69);
65
+ # AlmaLinux official repo is also ~8KB/s from the sandbox — use the Aliyun mirror:
60
66
  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
67
+ "https://mirrors.aliyun.com/almalinux/8/BaseOS/aarch64/os/Packages/libicu-60.3-2.el8_1.aarch64.rpm" -o /tmp/libicu60.rpm
62
68
  rpm -ivh --nodeps --force /tmp/libicu60.rpm # --force: license file conflicts with icu69
63
69
 
64
70
  # LDAP: postgres only uses it for LDAP auth (not needed here) — symlinks suffice.
@@ -72,12 +78,16 @@ ln -sf /usr/lib64/libreadline.so.8 /usr/lib64/libreadline.so.7
72
78
  ldconfig # registers /usr/pgsql-16/lib (libpq.so.5)
73
79
  ```
74
80
 
75
- Init and start (PG refuses root; no systemd on HCE):
81
+ Init and start (PG refuses root; no systemd on HCE). **The data directory
82
+ MUST live on the data disk** (`/workspace`), not `/var/lib/pgsql` —
83
+ `/var/lib/pgsql` is on the 系统盘 (system disk) and a sandbox reboot rolls it
84
+ back to the image state, destroying all databases. `/workspace` is the data
85
+ disk and survives reboots (#999):
76
86
 
77
87
  ```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"
88
+ mkdir -p /workspace/pgsql/16/data && chown postgres:postgres /workspace/pgsql/16/data
89
+ su - postgres -c "/usr/pgsql-16/bin/initdb -D /workspace/pgsql/16/data"
90
+ su - postgres -c "/usr/pgsql-16/bin/pg_ctl -D /workspace/pgsql/16/data -l /workspace/pgsql/16/logfile start"
81
91
  ```
82
92
 
83
93
  ## Step 2: PostgREST (static build) + schema
@@ -87,8 +97,30 @@ static). **Dynamic Ubuntu builds do NOT work** — HCE glibc 2.34 is older than
87
97
  Ubuntu 22.04's 2.35 and the binaries fail on load.
88
98
 
89
99
  ```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
100
+ # Asset name has NO patch version: it is postgrest-v16.5-linux-static-aarch64.tar.xz
101
+ # (v16.4 likewise) — writing "v16.5.0" 404s and is easily misdiagnosed as a broken proxy.
102
+ # Verify the real asset name via the GitHub API before downloading:
103
+ curl -fsSL https://api.github.com/repos/PostgREST/postgrest/releases \
104
+ | grep -o '"name": *"postgrest-v16[^"]*aarch64[^"]*"'
105
+ # GitHub direct is slow from the sandbox; prefix proxies measure ~500KB/s (2026-10):
106
+ # https://ghfast.top/https://github.com/... or https://gh-proxy.com/https://github.com/...
107
+ # ⚠️ Those proxies are THIRD-PARTY: never extract their bytes unverified (supply-chain
108
+ # risk). Pull the official asset sha256 digest from the GitHub releases API, download
109
+ # through the proxy, and verify with sha256sum BEFORE extracting — this also catches
110
+ # partial downloads (a truncated tar.xz fails the check instead of a cryptic tar error).
111
+ PGRST_SHA256="$(curl -fsSL https://api.github.com/repos/PostgREST/postgrest/releases/tags/v16.5 \
112
+ | grep -A30 '"name": *"postgrest-v16.5-linux-static-aarch64.tar.xz"' \
113
+ | grep -o '"digest": *"sha256:[a-f0-9]*"' | head -1 \
114
+ | sed 's/.*sha256://; s/"$//')"
115
+ # ⚠️ The sed strips both the `sha256:` prefix and the closing quote.
116
+ # Using `cut` by delimiter instead would leave a trailing double-quote on the
117
+ # hash, making `sha256sum -c -` fail with "no properly formatted checksum
118
+ # lines found" even when the download is correct.
119
+ test -n "$PGRST_SHA256" # empty digest = API lookup failed; do NOT proceed unverified
120
+ curl -fsSL --http1.1 --max-time 240 -C - -o /tmp/pgrst.tar.xz \
121
+ "https://ghfast.top/https://github.com/PostgREST/postgrest/releases/download/v16.5/postgrest-v16.5-linux-static-aarch64.tar.xz"
122
+ echo "$PGRST_SHA256 /tmp/pgrst.tar.xz" | sha256sum -c - # must print ": OK" before tar
123
+ mkdir -p /opt/postgrest && tar -xJf /tmp/pgrst.tar.xz -C /opt/postgrest
92
124
 
93
125
  cat > /opt/postgrest/pgrst.conf << 'PGRST_EOF'
94
126
  db-uri = "postgres://api_user:<db-password>@localhost:5432/postgres"
@@ -97,8 +129,11 @@ db-anon-role = "api_user"
97
129
  server-host = "127.0.0.1"
98
130
  server-port = 3000
99
131
  PGRST_EOF
132
+ # ⚠️ Via the MCP exec channel, do NOT build pgrst.conf / SQL inline (escaping breaks
133
+ # them — see field notes below): write the file locally and huaweicloud_sandbox_upload_file it.
100
134
 
101
- # Schema + read-only API role (the anon role gates all writes)
135
+ # Schema + read-only API role (the anon role gates all writes).
136
+ # Same escaping trap: prefer a local schema.sql → upload_file → psql -f (field notes).
102
137
  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
138
 
104
139
  setsid nohup /opt/postgrest/postgrest /opt/postgrest/pgrst.conf > /tmp/pgrst.log 2>&1 < /dev/null &
@@ -145,5 +180,131 @@ devbridge host -p <port> -e 720
145
180
  ```
146
181
 
147
182
  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).
183
+ (data, 200), `POST /api/todos` (**401 by default** — the anon role has SELECT
184
+ only). To enable writes on one table, `GRANT INSERT ON api.todos TO api_user`
185
+ (never a broad grant to anon); after that targeted grant the same POST returns
186
+ **201**.
187
+
188
+ ## Field notes (2026-10 retest): 5 measured traps
189
+
190
+ Re-measured 2026-10-23 on HCE 2.0 (aarch64, no systemd) with PG 16.15 +
191
+ PostgREST 16.5; all five traps reproduced and solved, end-to-end verified
192
+ (page 200 / API 200 / POST 401 anon-default, 201 after `GRANT INSERT` on
193
+ `api.todos`).
194
+
195
+ ### 1. RPM sources: use the Aliyun mirror
196
+
197
+ `download.postgresql.org` and `repo.almalinux.org` measure **~8KB/s** from the
198
+ sandbox CN egress (the 6.8MB server RPM times out mid-transfer at 240s). The
199
+ Aliyun mirror completes in seconds:
200
+
201
+ - PG 16: `https://mirrors.aliyun.com/postgresql/repos/yum/16/redhat/rhel-8-aarch64/`
202
+ - libicu60: `https://mirrors.aliyun.com/almalinux/8/BaseOS/aarch64/os/Packages/`
203
+ - PostgREST: `ghfast.top` / `gh-proxy.com` prefix proxies (~500KB/s) — but see
204
+ trap 3 first: the asset name must be exact, and the proxy is third-party:
205
+ fetch the official GitHub asset digest and `sha256sum -c` the tarball before
206
+ extracting (see the Step 2 download block).
207
+
208
+ ### 2. Cross-mirror resume corrupts RPMs
209
+
210
+ Resuming a partial download with `-C -` **from a different mirror** produces a
211
+ file that fails rpm verification (`Payload SHA256 digest: BAD`). When switching
212
+ sources, `rm` the partial file and re-download it in full — never resume across
213
+ mirrors.
214
+
215
+ ### 3. PostgREST 404 is usually the asset name, not the proxy
216
+
217
+ The release asset is `postgrest-v16.5-linux-static-aarch64.tar.xz` — the version
218
+ has **no patch number** (v16.4 likewise); guessing `v16.5.0` returns 404 and
219
+ gets misdiagnosed as "proxy broken". Check the real name first:
220
+
221
+ ```bash
222
+ curl -fsSL https://api.github.com/repos/PostgREST/postgrest/releases \
223
+ | grep -o '"name": *"postgrest-v16[^"]*aarch64[^"]*"'
224
+ ```
225
+
226
+ ### 4. `upload_file` lands as 640 → silent schema failure → PGRST002
227
+
228
+ Files uploaded by root via `huaweicloud_sandbox_upload_file` arrive as
229
+ `rw-r-----` (640). `su - postgres -c "psql -f /tmp/schema.sql"` then fails with
230
+ `Permission denied`, the schema/role are **silently not created**, and
231
+ PostgREST starts but keeps logging
232
+ `PGRST002: Could not query the database for the schema cache`.
233
+
234
+ Fix: `chmod o+r` the uploaded file before `psql -f`. When troubleshooting
235
+ PGRST002, first confirm the schema/role actually exist (`\dn`, `\du`) — the
236
+ `psql -f` error may have scrolled out of the output tail you looked at.
237
+
238
+ ### 5. MCP exec channel mangles nested quotes — upload files instead
239
+
240
+ Nested quotes / inline JSON (pgrst.conf, `psql -c` SQL, `curl -d '{...}'`) get
241
+ corrupted by the exec channel's multi-layer escaping (symptoms: POST 400, SQL
242
+ written wrong). The heredoc/inline `printf` snippets above are for a direct
243
+ shell; over MCP, always use the file-upload pattern:
244
+
245
+ ```bash
246
+ # locally: write schema.sql / pgrst.conf / payload.json, then upload_file them, then on the sandbox:
247
+ chmod o+r /tmp/schema.sql && su - postgres -c "/usr/pgsql-16/bin/psql -f /tmp/schema.sql"
248
+ curl -s -X POST -H "Content-Type: application/json" -d @/tmp/payload.json http://127.0.0.1:3000/todos
249
+ ```
250
+
251
+ ## Sandbox Restart Recovery Checklist (2026-10)
252
+
253
+ A sandbox reboot rolls the **系统盘 (system disk)** back to the image state —
254
+ everything installed under `/`, `/usr`, `/etc`, `$HOME` is lost. Only
255
+ `/workspace` (the data disk) survives. Below is the measured recovery
256
+ checklist, assuming PG data was initialized on `/workspace/pgsql/16/data` per
257
+ Step 1 above.
258
+
259
+ ### Lost on reboot (系统盘 — must rebuild)
260
+
261
+ | Component | Location | Recovery action |
262
+ | ---------------------- | ------------------------- | -------------------------------------------------------------------------------- |
263
+ | PostgreSQL 16 RPMs | `/usr/pgsql-16/` | Re-run the Step 1 `rpm -ivh --nodeps` block |
264
+ | Soname shims | `/usr/lib64/lib*.so.*` | Re-run the Step 1 symlink + `ldconfig` block |
265
+ | `postgres` user | `/etc/passwd` entry | `useradd -r -m postgres` then `chown postgres:postgres /workspace/pgsql/16/data` |
266
+ | PostgREST binary | `/opt/postgrest/` | Re-download + sha256-verify + extract (Step 2) |
267
+ | pgrst.conf | `/opt/postgrest/` | Re-upload via `huaweicloud_sandbox_upload_file` |
268
+ | nginx config | `/etc/nginx/conf.d/` | Re-create via `huaweicloud_sandbox_deploy_nginx` |
269
+ | DevBridge 0.2.x binary | `~/.huawei/bin/` (系统盘) | Re-install to `/workspace/.local/bin` (see #999) |
270
+ | DevBridge tunnel | in-memory | Re-run `huaweicloud_sandbox_expose_tunnel` |
271
+ | `/tmp` credentials | `/tmp/hw_creds.sh` etc. | Re-run `huaweicloud_sandbox_credentials` |
272
+
273
+ ### Survives reboot (/workspace — no rebuild needed)
274
+
275
+ | Component | Location | Notes |
276
+ | ---------------------- | -------------------------- | -------------------------------------------- |
277
+ | Frontend pages | `/workspace/<repo>/` | Uploaded project persists |
278
+ | PG data directory | `/workspace/pgsql/16/data` | Schema, roles, tables, seed data ALL survive |
279
+ | DevBridge 0.2.x binary | `/workspace/.local/bin/` | If installed per #999 guidance |
280
+
281
+ Because PG data lives on `/workspace`, a restart does **NOT** require rebuilding
282
+ the schema (`schema.sql`) or re-seeding data — `initdb` is also unnecessary.
283
+ Only the PG server RPMs and the `postgres` user need rebuilding; then
284
+ `pg_ctl -D /workspace/pgsql/16/data start` resumes the surviving cluster
285
+ immediately.
286
+
287
+ ### Write-permission GRANT rollback trap (POST 401 → GRANT → 201)
288
+
289
+ The `postgres` system user and PG RPMs live on the 系统盘, so after a reboot
290
+ you must recreate the user and re-install PG before `pg_ctl` can start the
291
+ surviving data directory. Once PG starts against the surviving
292
+ `/workspace/pgsql/16/data`, all roles and grants in the PG catalog are intact
293
+ — no re-grant is needed.
294
+
295
+ **However**, if you rebuild the schema from a `schema.sql` that only contains
296
+ `GRANT SELECT` (the read-only anon default), writes are blocked: `POST
297
+ /api/todos` returns **401**. The write grant (`GRANT INSERT, UPDATE, DELETE`)
298
+ was stored in the PG catalog; re-running a SELECT-only script overwrites it.
299
+ The measured fix (2026-10):
300
+
301
+ ```sql
302
+ GRANT INSERT, UPDATE, DELETE ON api.todos TO api_user;
303
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA api TO api_user;
304
+ ```
305
+
306
+ After this targeted re-grant the same `POST /api/todos` returns **201**
307
+ (measured 2026-10: POST 401 → `GRANT INSERT, UPDATE, DELETE` → POST 201).
308
+ This mirrors the Step 4 anon-role gating: `db-anon-role` is read-only by
309
+ default; targeted write grants must be re-applied after any schema rebuild.
310
+ Never grant broad write access to the anon role.
@@ -1,8 +1,12 @@
1
1
  import { TOOL_DEFINITIONS, callTool } from './tools.mjs';
2
2
  import { peekCachedUpdateInfo, applyUpdateHint, readInstalledVersion } from './update-check.mjs';
3
- import { initTelemetry } from './telemetry/telemetry.mjs';
3
+ import { initTelemetry, flushEvents } from './telemetry/telemetry.mjs';
4
4
  import { detectAgent } from './telemetry/agent-detect.mjs';
5
5
 
6
+ // #1014 P0: 领券类工具同步上报——callTool 完毕后 await flushEvents(),
7
+ // 防止进程提前退出导致领券遥测丢失。
8
+ const P0_SYNC_FLUSH_TOOLS = new Set(['huaweicloud_voucher_claim', 'huaweicloud_competition_voucher_claim']);
9
+
6
10
  const pkgVersion = readInstalledVersion() || '0.0.0';
7
11
 
8
12
  // 会话内首个非 check/upgrade 工具调用附加 _updateInfo,只消费一次。
@@ -78,6 +82,9 @@ export async function dispatch(method, params, opts = {}) {
78
82
  throw invalidParamsError;
79
83
  }
80
84
  const result = await callTool(params.name, params.arguments || {});
85
+ if (P0_SYNC_FLUSH_TOOLS.has(params.name)) {
86
+ await flushEvents().catch(() => {});
87
+ }
81
88
  const decorated = _decorateResult(sessionId, params.name, result);
82
89
  return {
83
90
  content: [
@@ -1,6 +1,7 @@
1
1
  import { getCredentials } from './hwlink-api.mjs';
2
2
  import { getProxyDispatcher } from '../proxy/proxy-agent.mjs';
3
3
  import { cacheUserHash } from '../telemetry/telemetry.mjs';
4
+ import { detectAgent } from '../telemetry/agent-detect.mjs';
4
5
  import { readInstalledVersion } from '../update-check.mjs';
5
6
 
6
7
  function getHdkitBaseUrl() {
@@ -21,6 +22,8 @@ async function hdkitRequest(method, path, body, timeoutMs = 300000) {
21
22
  'X-HW-AK': ak,
22
23
  'X-HW-SK': sk,
23
24
  'X-HW-Client-Version': readInstalledVersion() || '0.0.0',
25
+ // #1040: 对外请求统一携带 X-HW-Agent(值=detectAgent 结果 harness)
26
+ 'X-HW-Agent': detectAgent().harness,
24
27
  };
25
28
  if (securitytoken) {
26
29
  headers['X-HW-Security-Token'] = securitytoken;
@@ -75,6 +75,13 @@ function configRoot(target = 'opencode') {
75
75
  return join(home, '.config', target);
76
76
  }
77
77
 
78
+ // Must match telemetry.mjs GLOBAL_TELEMETRY_DIR root: HUAWEICLOUD_DEVKIT_HOME
79
+ // overrides homedir() so sandbox/multi-HOME environments stay consistent (#1014).
80
+ function devkitTelemetryDir() {
81
+ const home = (process.env.HUAWEICLOUD_DEVKIT_HOME || '').trim() || homedir();
82
+ return join(home, '.huaweicloud-devkit', 'telemetry');
83
+ }
84
+
78
85
  function opencodeSkillsDir() {
79
86
  return join(configRoot('opencode'), 'skills');
80
87
  }
@@ -1814,7 +1821,7 @@ function deployWorkBuddyHooks() {
1814
1821
 
1815
1822
  // 4. Ensure telemetry output directories exist
1816
1823
  mkdirSync(join(workbuddyPluginsDir(), 'telemetry'), { recursive: true });
1817
- mkdirSync(join(homedir(), '.huaweicloud-devkit', 'telemetry'), { recursive: true });
1824
+ mkdirSync(devkitTelemetryDir(), { recursive: true });
1818
1825
  }
1819
1826
 
1820
1827
  // ── AtomCode hooks deployment ────────────────────────────────
@@ -1982,10 +1989,10 @@ function uninstallWorkBuddy() {
1982
1989
  console.log(` Removed hook tracker: ${trackerPath}`);
1983
1990
  }
1984
1991
 
1985
- const devkitTelemetryDir = join(homedir(), '.huaweicloud-devkit', 'telemetry');
1986
- if (existsSync(devkitTelemetryDir)) {
1987
- rmSync(devkitTelemetryDir, { recursive: true, force: true });
1988
- console.log(` Removed telemetry cache: ${devkitTelemetryDir}`);
1992
+ const devkitTelemetryPath = devkitTelemetryDir();
1993
+ if (existsSync(devkitTelemetryPath)) {
1994
+ rmSync(devkitTelemetryPath, { recursive: true, force: true });
1995
+ console.log(` Removed telemetry cache: ${devkitTelemetryPath}`);
1989
1996
  }
1990
1997
  }
1991
1998
 
@@ -1,24 +1,31 @@
1
- import { AGENTS, matchAgent, detectVersion, installSegment } from './agent-registry.mjs';
1
+ import { AGENTS, matchAgent, detectVersion } from './agent-registry.mjs';
2
2
 
3
3
  /**
4
- * 仅返回 harness 字符串(或 null),不返回 version。
5
- * 用于简单场景(如 keepalive 判断、测试断言)。
4
+ * 仅返回 harness 字符串,不返回 version。
5
+ * #1040: 白名单 / AGENT_HARNESS 未命中时一律返回 'unknown'——
6
+ * 不再透传 clientInfo.name(startup、voucher-claim*、test* 等探针类 clientName 同样归 unknown),
7
+ * 也不拼接 installSegment,避免 agent 名出现 '|' 污染。
6
8
  */
7
9
  export function detectAgentHarness(clientInfo = {}) {
8
- if (process.env.AGENT_HARNESS) return process.env.AGENT_HARNESS;
10
+ const envHarness = process.env.AGENT_HARNESS;
11
+ if (envHarness && AGENTS.some((a) => a.id === envHarness)) return envHarness;
9
12
  for (const agent of AGENTS) {
10
13
  if (matchAgent(agent, clientInfo)) return agent.id;
11
14
  }
12
- return clientInfo.name || null;
15
+ return 'unknown';
13
16
  }
14
17
 
15
18
  export function detectAgent(clientInfo = {}) {
16
- if (process.env.AGENT_HARNESS) {
17
- const cfg = AGENTS.find((a) => a.id === process.env.AGENT_HARNESS) || null;
18
- return {
19
- harness: process.env.AGENT_HARNESS,
20
- version: detectVersion(cfg ? cfg.version : null) || clientInfo.version || '0.0.0',
21
- };
19
+ // AGENT_HARNESS 仅在命中白名单时生效;未命中视为不可信输入,不透传任意环境值。
20
+ const envHarness = process.env.AGENT_HARNESS;
21
+ if (envHarness) {
22
+ const cfg = AGENTS.find((a) => a.id === envHarness) || null;
23
+ if (cfg) {
24
+ return {
25
+ harness: envHarness,
26
+ version: detectVersion(cfg.version) || clientInfo.version || '0.0.0',
27
+ };
28
+ }
22
29
  }
23
30
 
24
31
  for (const agent of AGENTS) {
@@ -30,11 +37,9 @@ export function detectAgent(clientInfo = {}) {
30
37
  }
31
38
  }
32
39
 
33
- const seg = installSegment();
34
- const base = clientInfo.name || 'unknown';
35
- const harness = seg ? `${base}|${seg}` : base;
40
+ // #1040: 兜底收敛 'unknown'——安装位置信息走遥测独立字段 installSegment,不进入 agent 名。
36
41
  return {
37
- harness: harness.length > 32 ? harness.slice(0, 32) : harness,
42
+ harness: 'unknown',
38
43
  version: clientInfo.version || '0.0.0',
39
44
  };
40
45
  }
@@ -10,6 +10,10 @@ const selfPath = (() => {
10
10
  }
11
11
  })();
12
12
 
13
+ /**
14
+ * #1040: 安装位置段——仅用于遥测的独立安装位置字段(telemetry 事件 installSegment),
15
+ * 不进入 agent 名/harness;AGENTS 白名单与 matchAgent 规则不受安装位置影响。
16
+ */
13
17
  export function installSegment() {
14
18
  const parts = selfPath.split('/').filter(Boolean);
15
19
  const srcIdx = parts.lastIndexOf('src');
@@ -14,6 +14,7 @@ import { createHash, randomUUID } from 'node:crypto';
14
14
  import { fileURLToPath } from 'node:url';
15
15
 
16
16
  import { fetchWithProxy } from '../proxy/proxy-agent.mjs';
17
+ import { installSegment } from './agent-registry.mjs';
17
18
 
18
19
  const __filename = fileURLToPath(import.meta.url);
19
20
  const __dirname = dirname(__filename);
@@ -68,7 +69,7 @@ const MAX_RETRIES = 3;
68
69
  const DEFAULT_ENDPOINT = 'https://devkit.huaweicloud.com/rest/developer/server/hdkitservice/telemetry/events';
69
70
 
70
71
  let eventQueue = [];
71
- let isFlushing = false;
72
+ let currentFlush = null;
72
73
  let flushTimer = null;
73
74
  let lastCheckedDate = '';
74
75
  let installId = null;
@@ -207,6 +208,9 @@ function buildEvent(raw) {
207
208
  os: osTypeStr,
208
209
  osVersion: osVersionStr,
209
210
  };
211
+ // #1040: 安装位置独立字段——原拼接进 harness 的 installSegment 改为单列上报。
212
+ const seg = installSegment();
213
+ if (seg) event.installSegment = sanitizeValue(seg);
210
214
  const cap = raw.capability || capabilityFromKey(raw.key);
211
215
  if (cap) event.capability = cap;
212
216
  return event;
@@ -214,7 +218,9 @@ function buildEvent(raw) {
214
218
 
215
219
  export function enqueueEvent(raw) {
216
220
  if (!isTelemetryEnabled()) return;
217
- if (!installId) return;
221
+ // #1014: installId 未初始化时(如未走 initialize 直接 tools/call)不再丢弃事件,
222
+ // 惰性恢复,避免 telemetry_event 缺记录。
223
+ if (!installId) installId = generateOrRecoverInstallId();
218
224
 
219
225
  ingestHookEvents();
220
226
 
@@ -324,10 +330,37 @@ export function clearUserHash() {
324
330
  } catch {}
325
331
  }
326
332
 
327
- function flushEvents() {
328
- if (isFlushing) return;
329
- if (eventQueue.length === 0) return;
330
- isFlushing = true;
333
+ // #1014: flush 前兜底补齐队列事件的缺失身份字段——
334
+ // userHash 在 enqueue 之后才解析到(领券响应 / user-hash 文件)时,
335
+ // 已入队事件的 userHash 仍为 null,这里统一回填,降低 user_hash null 占比。
336
+ function backfillIdentityFields() {
337
+ try {
338
+ if (!installId) installId = generateOrRecoverInstallId();
339
+ if (!userHash) loadUserHash();
340
+ for (const event of eventQueue) {
341
+ if (!event.installId && installId) event.installId = installId;
342
+ if (event.userHash == null && userHash) event.userHash = userHash;
343
+ }
344
+ } catch (_) {
345
+ debugLog('BACKFILL identity fields failed');
346
+ }
347
+ }
348
+
349
+ // #1014: flush 可被 await(P0 领券类工具同步上报);
350
+ // 并发调用复用同一次 in-flight flush(currentFlush 声明于模块顶部)。
351
+ export function flushEvents() {
352
+ if (currentFlush) return currentFlush;
353
+ if (eventQueue.length === 0) return Promise.resolve();
354
+ currentFlush = performFlush().finally(() => {
355
+ currentFlush = null;
356
+ });
357
+ return currentFlush;
358
+ }
359
+
360
+ async function performFlush() {
361
+ // flush 前统一回填身份字段(含 userHash 重解析,#1014):
362
+ // 入队时身份未解析的事件此前会永久携带 null userHash。
363
+ backfillIdentityFields();
331
364
 
332
365
  const batch = eventQueue.splice(0, BATCH_SIZE);
333
366
  const keys = batch.map((e) => e.key).join(',');
@@ -339,34 +372,72 @@ function flushEvents() {
339
372
  const controller = new AbortController();
340
373
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
341
374
 
342
- fetchWithProxy(endpoint, {
343
- method: 'POST',
344
- headers: { 'Content-Type': 'application/json' },
345
- body: JSON.stringify(batch.map(({ _retries, ...rest }) => rest)),
346
- signal: controller.signal,
347
- })
348
- .then((resp) => {
349
- clearTimeout(timer);
350
- debugLog(`POST status=${resp.status} events=${batch.length}`);
351
- if (resp.ok) {
352
- for (const event of batch) {
353
- if (event.key === 'dau:active_today') touchFile(dauStampPath());
354
- if (event.key === 'plugin:install') touchFile(installStampPath());
355
- if (event.key === 'plugin:first_use') touchFile(firstUseStampPath());
356
- }
357
- } else if (resp.status >= 400 && resp.status < 500) {
358
- debugLog(`POST status=${resp.status} dropping ${batch.length} events (client error)`);
359
- } else {
360
- requeueEvents(batch);
375
+ try {
376
+ const resp = await fetchWithProxy(endpoint, {
377
+ method: 'POST',
378
+ // #1040: 对外上报统一携带 X-HW-Agent(值=detectAgent 结果 harness,initTelemetry 缓存)
379
+ headers: { 'Content-Type': 'application/json', 'X-HW-Agent': agentHarness || 'unknown' },
380
+ body: JSON.stringify(batch.map(({ _retries, ...rest }) => rest)),
381
+ signal: controller.signal,
382
+ });
383
+ clearTimeout(timer);
384
+ debugLog(`POST status=${resp.status} events=${batch.length}`);
385
+ if (resp.ok) {
386
+ for (const event of batch) {
387
+ if (event.key === 'dau:active_today') touchFile(dauStampPath());
388
+ if (event.key === 'plugin:install') touchFile(installStampPath());
389
+ if (event.key === 'plugin:first_use') touchFile(firstUseStampPath());
361
390
  }
362
- isFlushing = false;
363
- })
364
- .catch((error) => {
365
- clearTimeout(timer);
366
- debugLog(`POST FAIL err=${error.message} events=${batch.length}`);
391
+ } else if (resp.status >= 400 && resp.status < 500) {
392
+ debugLog(`POST status=${resp.status} dropping ${batch.length} events (client error)`);
393
+ } else {
367
394
  requeueEvents(batch);
368
- isFlushing = false;
395
+ }
396
+ } catch (error) {
397
+ clearTimeout(timer);
398
+ debugLog(`POST FAIL err=${error.message} events=${batch.length}`);
399
+ requeueEvents(batch);
400
+ }
401
+ }
402
+
403
+ // #1014 P1: 排空整个队列(退出兜底用,单次 flush 只发一个 batch)。
404
+ export async function flushAllEvents() {
405
+ const maxBatches = Math.ceil(MAX_QUEUE_SIZE / BATCH_SIZE) + 1;
406
+ for (let i = 0; i < maxBatches && eventQueue.length > 0; i++) {
407
+ await flushEvents();
408
+ }
409
+ }
410
+
411
+ const EXIT_FLUSH_TIMEOUT_MS = 3000;
412
+ let exitFlushRegistered = false;
413
+
414
+ // #1014 P1: SIGTERM/SIGINT 退出兜底 flush(有界超时,避免阻塞退出)。
415
+ // 返回反注册函数(仅测试使用)。
416
+ export function registerExitFlush() {
417
+ if (exitFlushRegistered) return () => {};
418
+ exitFlushRegistered = true;
419
+ const flushAndExit = () => {
420
+ if (eventQueue.length === 0) {
421
+ // eslint-disable-next-line n/no-process-exit -- signal shutdown with nothing to flush
422
+ process.exit(0);
423
+ }
424
+ const cap = new Promise((resolve) => {
425
+ const t = setTimeout(resolve, EXIT_FLUSH_TIMEOUT_MS);
426
+ t.unref?.();
427
+ });
428
+ const done = flushAllEvents().catch(() => {});
429
+ Promise.race([done, cap]).finally(() => {
430
+ // eslint-disable-next-line n/no-process-exit -- signal shutdown after bounded exit flush
431
+ process.exit(0);
369
432
  });
433
+ };
434
+ process.once('SIGTERM', flushAndExit);
435
+ process.once('SIGINT', flushAndExit);
436
+ return () => {
437
+ process.removeListener('SIGTERM', flushAndExit);
438
+ process.removeListener('SIGINT', flushAndExit);
439
+ exitFlushRegistered = false;
440
+ };
370
441
  }
371
442
 
372
443
  function requeueEvents(batch) {
@@ -390,6 +461,9 @@ export function initTelemetry({ harness, version }) {
390
461
 
391
462
  loadUserHash();
392
463
 
464
+ // #1014 P1: SIGTERM/SIGINT 退出兜底 flush(队列空时立即退出,零开销)。
465
+ registerExitFlush();
466
+
393
467
  if (!isTelemetryEnabled()) return;
394
468
 
395
469
  if (!stampExists(installStampPath()) && !existsSync(installCounterPath())) {
@@ -54,7 +54,7 @@ import {
54
54
  parseStsExpiry,
55
55
  resolveCredentialsWithRuntime,
56
56
  } from './auth/credentials.mjs';
57
- import { trackToolInvoke, trackSkillRetrieve, clearUserHash } from './telemetry/telemetry.mjs';
57
+ import { trackToolInvoke, trackSkillRetrieve, clearUserHash, cacheUserHash } from './telemetry/telemetry.mjs';
58
58
  import { fetchWithProxy } from './proxy/proxy-agent.mjs';
59
59
  import { fingerprint, runHcloudConfigure, runHcloudProfileDelete, resolveManagedProfile } from './auth/reconcile.mjs';
60
60
  import {
@@ -1785,10 +1785,17 @@ export async function callTool(name, rawArgs = {}, opts = {}) {
1785
1785
  }
1786
1786
  case 'huaweicloud_voucher_status':
1787
1787
  return await hdkitVoucherStatus(args.domain_id);
1788
- case 'huaweicloud_voucher_claim':
1789
- return await hdkitVoucherClaim(args.domain_id);
1790
- case 'huaweicloud_competition_voucher_claim':
1791
- return await hdkitCompetitionVoucherClaim(args.domain_id);
1788
+ case 'huaweicloud_voucher_claim': {
1789
+ const result = await hdkitVoucherClaim(args.domain_id);
1790
+ // #1014 P0: 领券成功后补缓存 userHash(响应携带时),flush 前兜底补齐队列缺失字段。
1791
+ if (result?.userHash) cacheUserHash(result.userHash);
1792
+ return result;
1793
+ }
1794
+ case 'huaweicloud_competition_voucher_claim': {
1795
+ const result = await hdkitCompetitionVoucherClaim(args.domain_id);
1796
+ if (result?.userHash) cacheUserHash(result.userHash);
1797
+ return result;
1798
+ }
1792
1799
  case 'huaweicloud_check_update':
1793
1800
  return await handleCheckUpdate(args, { sessionId: opts?.sessionId, doQuery: opts?.doQuery });
1794
1801
  case 'huaweicloud_upgrade':