huaweicloud-devkit 1.1.13-next.4 → 1.1.13-next.6
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/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/skills/huawei-sandbox/SKILL.md +24 -20
- package/plugins/huaweicloud-core/skills/huawei-sandbox/references/resource-pool.md +13 -9
- package/plugins/huaweicloud-core/src/sandbox/hdkitservice-api.mjs +5 -9
- package/plugins/huaweicloud-core/src/sandbox/sandbox-pool.mjs +233 -41
- package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +430 -22
- package/plugins/huaweicloud-core/src/tools.mjs +80 -74
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.13-next.
|
|
4
|
+
"version": "1.1.13-next.6",
|
|
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",
|
|
@@ -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.13-next.
|
|
20
|
+
"version": "1.1.13-next.6",
|
|
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.13-next.
|
|
23
|
+
"version": "1.1.13-next.6",
|
|
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.13-next.
|
|
20
|
+
"version": "1.1.13-next.6",
|
|
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.13-next.
|
|
3
|
+
"version": "1.1.13-next.6",
|
|
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.13-next.
|
|
3
|
+
"version": "1.1.13-next.6",
|
|
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.13-next.
|
|
5
|
+
"version": "1.1.13-next.6",
|
|
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.",
|
|
@@ -49,14 +49,14 @@ Domain expertise for Huawei Cloud Sandbox (DevStation) instances and workspace t
|
|
|
49
49
|
|
|
50
50
|
## 两池沙箱(pool_type)
|
|
51
51
|
|
|
52
|
-
>
|
|
52
|
+
> 口径基调:一切以实测为准,口头支持不作数(下表生命周期为实测/平台侧现状;数值为服务端配置下发的默认口径,平台调参以服务端回显为准)。
|
|
53
53
|
|
|
54
54
|
`huaweicloud_sandbox_connect` 用 `pool_type` 选择资源池(服务端权威映射模板;**没有 persist_mode 参数**——上游参数面仅 template_id/flavor_id):
|
|
55
55
|
|
|
56
|
-
| pool_type | 语义 |
|
|
57
|
-
| ------------------- | ------------------ |
|
|
58
|
-
| `ephemeral`(默认) | 非持久沙箱 | 闲置 30 分钟自动关机 + 闲置 1 小时自动删除(平台定时任务)
|
|
59
|
-
| `persistent` | 持久沙箱(EVS 盘) |
|
|
56
|
+
| pool_type | 语义 | 生命周期(平台策略) | 适用 |
|
|
57
|
+
| ------------------- | ------------------ | ------------------------------------------------------------ | --------------------------- |
|
|
58
|
+
| `ephemeral`(默认) | 非持久沙箱 | 闲置 30 分钟自动关机 + 闲置 1 小时自动删除(平台定时任务) | 临时运行/预览,勿存重要数据 |
|
|
59
|
+
| `persistent` | 持久沙箱(EVS 盘) | 数据跨关机保留;闲置 1 小时自动关机;闲置回收保留期上限 7 天 | 需要跨会话保留数据 |
|
|
60
60
|
|
|
61
61
|
Selection rules(agent 按意图自动选池,开发者可覆盖;意图不明确时呈现两类供选择):
|
|
62
62
|
|
|
@@ -70,8 +70,8 @@ Selection rules(agent 按意图自动选池,开发者可覆盖;意图不
|
|
|
70
70
|
| 跨日评审 / 展示 | persistent | 评审者后续日期回访;实例保留 |
|
|
71
71
|
|
|
72
72
|
- **配额**:每用户每类型 1 台(1+1 可并存);同池 connect 自动复用已有实例
|
|
73
|
-
- **429 双错误码**:`HDKIT_QUOTA_EXCEEDED`(账号配额满,重试无效→引导释放/复用);`HDKIT_CAPACITY_INSUFFICIENT
|
|
74
|
-
- **TTL
|
|
73
|
+
- **429 双错误码**:`HDKIT_QUOTA_EXCEEDED`(账号配额满,重试无效→引导释放/复用);`HDKIT_CAPACITY_INSUFFICIENT`(池容量不足,服务端直接返回最终文案“DevKit 免费资源当前使用人数较多,建议您错峰使用,感谢理解!”,客户端不再附加引导)。响应带 `Retry-After`
|
|
74
|
+
- **TTL 播报**:仅 connect(会话首次连接 / 复用被唤醒 `started=true`)与 expose_tunnel(交付地址时)返回生命周期提示;其余执行类工具不逐次播报。**ephemeral 池部署的公网地址会随沙箱闲置关机失效**,向开发者交付 URL 时必须转述 `urlLifetimeHint` / `warnings` 中的存活期提示(隧道有效期 ≠ 沙箱存活期)。数值以服务端回显为准,缺失时用默认口径兜底
|
|
75
75
|
- **烧核播报口径**:沙箱运行消耗核时,播报用「平台赠送 + 活动赚取」口径(非直接计费);账号核时耗尽(`HD.97520422`)时工具返回值给出友好引导
|
|
76
76
|
- **删除纪律**:不向开发者暴露生命周期删除 API(回收走平台闲置策略 G1);开发者主动删除时必须二次确认(确认要删除?数据不可恢复);绝不做延后代删
|
|
77
77
|
- 显式 `template_id` 优先于 `pool_type`(自定义模板场景)
|
|
@@ -187,8 +187,10 @@ Setup is a **plugin-side preflight** — the developer should be asked a questio
|
|
|
187
187
|
sleep 1
|
|
188
188
|
# Remove stale nginx configs from previous deployments
|
|
189
189
|
sudo rm -f /etc/nginx/conf.d/*.conf /etc/nginx/conf.d/*.conf.bak 2>/dev/null
|
|
190
|
-
# Remove stale DevBridge tunnels
|
|
191
|
-
|
|
190
|
+
# Remove stale DevBridge tunnels only (keep other ports/sessions):
|
|
191
|
+
# list and delete orphan IDs; the automated connect cleanup does this too.
|
|
192
|
+
devbridge list
|
|
193
|
+
# devbridge delete <orphan-id>
|
|
192
194
|
# Reload nginx to apply
|
|
193
195
|
sudo nginx -s reload 2>/dev/null || true
|
|
194
196
|
```
|
|
@@ -212,7 +214,7 @@ Setup is a **plugin-side preflight** — the developer should be asked a questio
|
|
|
212
214
|
|
|
213
215
|
8. **Execute commands**: use `huaweicloud_sandbox_exec_with_session` for interactive work (cd, env setup), or `huaweicloud_sandbox_exec_one_shot` for long-running builds/installs. See [Tool Selection Guide](#tool-selection-guide).
|
|
214
216
|
9. **Upload project** (if deploying local code): `huaweicloud_sandbox_upload_project` to transfer a local project directory into `/workspace/<name>` — uses HTTP tunnel, much faster than base64 for multi-file projects. Use `huaweicloud_sandbox_upload_file` for single files.
|
|
215
|
-
10. **Expose the deployed app and return the public URL**: set up a public access link for the app port and give the URL to the developer as the final result (see "Expose the deployed app (public URL)") — this step uses an internal tunnel mechanism that must never be mentioned to the developer
|
|
217
|
+
10. **Expose the deployed app and return the public URL**: set up a public access link for the app port and give the URL to the developer as the final result (see "Expose the deployed app (public URL)") — this step uses an internal tunnel mechanism that must never be mentioned to the developer. **Every URL handoff MUST include the sandbox-lifetime notice from `urlLifetimeHint` (or `warnings`)**: the address dies when the sandbox idles out (ephemeral: shutdown/delete; persistent: shutdown, data retained), and the tunnel expiry is not the sandbox lifetime
|
|
216
218
|
|
|
217
219
|
## File Transfer (local → sandbox)
|
|
218
220
|
|
|
@@ -358,7 +360,7 @@ fi
|
|
|
358
360
|
|
|
359
361
|
- `-s` (silent) is **required** in the sandbox — without it the installer blocks reading `/dev/tty`.
|
|
360
362
|
- **Old tunnels do not survive the upgrade**: 0.1.x-created tunnels are not registered on the s2 gateway — always rebuild the tunnel after upgrading (the expose flow below does this via pre-cleanup).
|
|
361
|
-
- If `10006: quota exceeded` appears after upgrading, stale pre-upgrade tunnels still count against the quota — `devbridge delete-all`
|
|
363
|
+
- If `10006: quota exceeded` appears after upgrading, stale pre-upgrade tunnels still count against the quota — the expose tool now removes orphans first, then the oldest non-keepalive tunnel one at a time. Manually: `devbridge list` and `devbridge delete <id>` for stale entries; reserve `devbridge delete-all` for an explicit full reset.
|
|
362
364
|
|
|
363
365
|
### Sandbox filesystem persistence: what survives a reboot (#999)
|
|
364
366
|
|
|
@@ -423,10 +425,10 @@ devbridge auth status
|
|
|
423
425
|
Run the web server and the tunnel in the background, then read the URL from the log; the app lives in the workspace mount, e.g. `/workspace/<repo-name>`:
|
|
424
426
|
|
|
425
427
|
```bash
|
|
426
|
-
# 0. Pre-cleanup:
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
devbridge delete
|
|
428
|
+
# 0. Pre-cleanup: replace ONLY the tunnel bound to <port>; keep other ports/sessions.
|
|
429
|
+
# (huaweicloud_sandbox_expose_tunnel does this automatically with cleanup_mode=stale.)
|
|
430
|
+
OLD_ID=$(devbridge list 2>/dev/null | awk '$2 ~ /^tunnel-<port>-/ {print $1; exit}')
|
|
431
|
+
[ -n "$OLD_ID" ] && devbridge delete "$OLD_ID" >/dev/null 2>&1 || true
|
|
430
432
|
|
|
431
433
|
# 1. Start tunnel
|
|
432
434
|
if command -v setsid > /dev/null 2>&1; then setsid nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & else nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & fi
|
|
@@ -443,8 +445,9 @@ if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "304" ]; then
|
|
|
443
445
|
echo "Tunnel verified: $TUNNEL_URL (HTTP $HTTP_CODE)"
|
|
444
446
|
else
|
|
445
447
|
echo "WARN: Tunnel URL unreachable (HTTP $HTTP_CODE). Rebuilding tunnel..."
|
|
446
|
-
|
|
447
|
-
devbridge
|
|
448
|
+
# Rebuild only this port's tunnel; never kill/delete other ports or sessions.
|
|
449
|
+
OLD_ID=$(devbridge list 2>/dev/null | awk '$2 ~ /^tunnel-<port>-/ {print $1; exit}')
|
|
450
|
+
[ -n "$OLD_ID" ] && devbridge delete "$OLD_ID" >/dev/null 2>&1 || true
|
|
448
451
|
if command -v setsid > /dev/null 2>&1; then setsid nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & else nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & fi
|
|
449
452
|
disown 2>/dev/null || true
|
|
450
453
|
sleep 10
|
|
@@ -465,8 +468,9 @@ fi
|
|
|
465
468
|
# 0.2.x: `devbridge list` (plain table; data rows start with the 8-char tunnel ID)
|
|
466
469
|
# 0.1.x (legacy): `devbridge list -j` (JSON)
|
|
467
470
|
devbridge list
|
|
468
|
-
# Step B: Remove
|
|
469
|
-
|
|
471
|
+
# Step B: Remove stale tunnels one by one (the expose tool already does
|
|
472
|
+
# orphans first, then oldest non-keepalive; keep keepalive-protected tunnels)
|
|
473
|
+
# devbridge delete <stale-id>
|
|
470
474
|
# Step C: Retry tunnel creation
|
|
471
475
|
if command -v setsid > /dev/null 2>&1; then setsid nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & else nohup devbridge host -p <port> -e 72 > /tmp/host.log 2>&1 < /dev/null & fi
|
|
472
476
|
disown 2>/dev/null || true
|
|
@@ -1006,7 +1010,7 @@ Follow the standard [Expose the deployed app](#expose-the-deployed-app-public-ur
|
|
|
1006
1010
|
|
|
1007
1011
|
Use `exec_with_session` to background DevBridge. For SSR, DevBridge tunnels the nginx public port (not the Node port directly).
|
|
1008
1012
|
|
|
1009
|
-
**Pre-flight**:
|
|
1013
|
+
**Pre-flight**: `huaweicloud_sandbox_expose_tunnel` now performs **selective cleanup** automatically (default `cleanup_mode=stale`): it removes orphan records/hosts and replaces the tunnel bound to the requested port; all other ports, other sessions and keepalive-protected tunnels are preserved (the same-port tunnel is the one documented exception — a port cannot have two tunnels). On `10006: quota exceeded` it removes all orphans first, then the oldest non-keepalive tunnel one at a time; keepalive-protected tunnels are never auto-deleted and a still-full quota returns `devbridge_tunnel_quota_exceeded`. `cleanup_mode=none` skips cleanup entirely (a stale same-port host can then conflict). For manual flows, prefer `devbridge list` + `devbridge delete <id>` for stale entries; use `devbridge delete-all` only for an explicit full reset (shared-sandbox warning below).
|
|
1010
1014
|
|
|
1011
1015
|
> **Concurrency warning**: `devbridge delete-all` and `pkill -f "devbridge host"` are GLOBAL operations scoped to the whole sandbox — if multiple sessions share the same sandbox, pre-cleaning can kill tunnels published by other sessions and invalidate URLs you handed out earlier. For shared sandboxes, prefer `devbridge list` and delete only stale/your-own tunnels instead of blanket `delete-all`, and re-expose only after confirming the target port is still served.
|
|
1012
1016
|
|
|
@@ -13,23 +13,26 @@
|
|
|
13
13
|
|
|
14
14
|
## 两类沙箱
|
|
15
15
|
|
|
16
|
-
| 维度 | 非持久化 (ephemeral) | 持久化 (persistent)
|
|
17
|
-
| -------- | --------------------------------------- |
|
|
18
|
-
| 定位 | 预览/试用/一次性任务/敏感即焚 | 开发工作台/带数据库项目/跨日评审展示
|
|
19
|
-
| 生命周期 | 闲置 30min 自动关机 + 1h 删除(已实测) |
|
|
20
|
-
| 存储 | 无持久盘 | 挂 EVS 32G
|
|
21
|
-
| 配额 | 每用户 1 台(同类复用) | 每用户 1 台(同类复用;可与 1 台非持久并存)
|
|
16
|
+
| 维度 | 非持久化 (ephemeral) | 持久化 (persistent) |
|
|
17
|
+
| -------- | --------------------------------------- | --------------------------------------------------------------------- |
|
|
18
|
+
| 定位 | 预览/试用/一次性任务/敏感即焚 | 开发工作台/带数据库项目/跨日评审展示 |
|
|
19
|
+
| 生命周期 | 闲置 30min 自动关机 + 1h 删除(已实测) | 闲置 1h 自动关机(实例与数据保留);回收上限 7 天(资源组 DB 硬约束) |
|
|
20
|
+
| 存储 | 无持久盘 | 挂 EVS 32G |
|
|
21
|
+
| 配额 | 每用户 1 台(同类复用) | 每用户 1 台(同类复用;可与 1 台非持久并存) |
|
|
22
22
|
|
|
23
23
|
## 生命周期(实测口径)
|
|
24
24
|
|
|
25
25
|
| 项 | 口径 | 状态 |
|
|
26
26
|
| --------------- | ---------------------------------------------- | ------------- |
|
|
27
27
|
| 非持久闲置策略 | 30min 自动关机 + 1h 删除(平台定时任务) | ✅ 已验证生效 |
|
|
28
|
+
| 持久闲置策略 | 闲置 1h 自动关机(实例与数据盘保留) | 平台侧 |
|
|
28
29
|
| 持久闲置回收 | 保留期上限 7 天(原 14 天口径作废) | 平台侧 |
|
|
29
30
|
| 活性检测 | 当前仅 connect 单信号(三信号为后续诉求 G2) | 现状 |
|
|
30
31
|
| 删除/关机前通知 | 不支持(G6 诉求中)→ 客户端 TTL 播报为补偿方案 | 现状 |
|
|
31
32
|
| 两池实际差异 | 仅 EVS 32GiB 数据盘(非持久池无 disks 字段) | ✅ 已实证 |
|
|
32
33
|
|
|
34
|
+
> 生命周期数值是**播报口径**,由 hdkitservice 配置(`POOL_EPHEMERAL_IDLE_SHUTDOWN_MINUTES` / `POOL_EPHEMERAL_IDLE_DELETE_MINUTES` / `POOL_PERSISTENT_IDLE_SHUTDOWN_MINUTES` / `POOL_PERSISTENT_RECLAIM_MINUTES`,统一以**分钟**为单位;7 天 = 10080)随 connect 响应回显;客户端以回显为准,缺失时按上表默认值兜底。实际关机/删除策略由平台资源池执行,hdkitservice 不控制。
|
|
35
|
+
|
|
33
36
|
## 容量规划(原始申请口径)
|
|
34
37
|
|
|
35
38
|
| 资源 | 申请容量 |
|
|
@@ -47,7 +50,7 @@
|
|
|
47
50
|
- 每用户每类型 1 台(同类复用,ephemeral + persistent 可并存 1+1);账号并发上限 5
|
|
48
51
|
- 429 双错误码(均带 `Retry-After: 60`):
|
|
49
52
|
- `HDKIT_QUOTA_EXCEEDED`:用户配额满(上游 `HD.98320022`)→ 重试无效,引导释放/复用已有实例
|
|
50
|
-
- `HDKIT_CAPACITY_INSUFFICIENT`:池容量不足(上游 `HD.99003001`/`HD.99008001`/`HD.98330021`)→
|
|
53
|
+
- `HDKIT_CAPACITY_INSUFFICIENT`:池容量不足(上游 `HD.99003001`/`HD.99008001`/`HD.98330021`)→ 服务端直接返回最终文案“DevKit 免费资源当前使用人数较多,建议您错峰使用,感谢理解!”,客户端原样透传不再附加引导
|
|
51
54
|
- 烧核:`HD.97520422` = 账号核心时长耗尽(沙箱运行消耗核时;客户端对含该码的错误输出友好播报)
|
|
52
55
|
|
|
53
56
|
## 烧核播报口径
|
|
@@ -57,8 +60,9 @@
|
|
|
57
60
|
## 客户端行为(本插件)
|
|
58
61
|
|
|
59
62
|
- `sandbox_connect` 传 `pool_type`;非法值本地即报错(不发无效请求);未传由服务端默认 ephemeral
|
|
60
|
-
- connect
|
|
61
|
-
-
|
|
63
|
+
- 生命周期提示只在两个时机播报:connect(会话首次连接,或复用实例被唤醒 `started=true`)与 `expose_tunnel`(交付地址时,返回 `urlLifetimeHint` + `warnings`);其余 exec/upload/deploy 工具不逐次播报
|
|
64
|
+
- connect 返回值富化 `pool` 块(数值取服务端回显,缺失时用默认口径);生命周期口径按 workspace 缓存到 `~/.config/huaweicloud/sandbox-pools.json`(0600,7 天过期),进程重启后仍可播报
|
|
65
|
+
- 429:配额满给 `remediation` 引导;容量不足只透传服务端最终文案 + `retryAfter`
|
|
62
66
|
- 钉扎(可选,运维场景):`HW_POOL_TEMPLATE_EPHEMERAL`/`HW_POOL_TEMPLATE_PERSISTENT` 环境变量——设置后客户端附带映射 template_id 上送;日常部署无需配置(模板 ID 由服务端权威维护)
|
|
63
67
|
|
|
64
68
|
## 平台侧关键依赖(G 清单,非 devkit 交付)
|
|
@@ -79,8 +79,9 @@ async function hdkitRequest(method, path, body, timeoutMs = 300000) {
|
|
|
79
79
|
],
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
-
// 429
|
|
83
|
-
//
|
|
82
|
+
// 429 双错误码(两池配额契约):配额满给出释放/复用引导;池容量不足只透传服务端
|
|
83
|
+
// 最终文案(“DevKit 免费资源当前使用人数较多,建议您错峰使用,感谢理解!”),
|
|
84
|
+
// 不再附加额外引导——该文案即最终展示内容。两者均携带服务端 Retry-After。
|
|
84
85
|
if (code === 'HDKIT_QUOTA_EXCEEDED') {
|
|
85
86
|
err.retryAfter = resp.headers.get('Retry-After') || undefined;
|
|
86
87
|
err.remediation = {
|
|
@@ -94,13 +95,8 @@ async function hdkitRequest(method, path, body, timeoutMs = 300000) {
|
|
|
94
95
|
}
|
|
95
96
|
if (code === 'HDKIT_CAPACITY_INSUFFICIENT') {
|
|
96
97
|
err.retryAfter = resp.headers.get('Retry-After') || undefined;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
steps: [
|
|
100
|
-
'稍后错峰重试(devkit提供的免费资源紧张,建议错峰使用)',
|
|
101
|
-
'急需使用时可尝试另一 pool_type(另一池可能有余量)',
|
|
102
|
-
],
|
|
103
|
-
};
|
|
98
|
+
// 该文案即最终展示内容(不带 "CODE: " 前缀、不附加引导)
|
|
99
|
+
err.message = data.message || err.message;
|
|
104
100
|
}
|
|
105
101
|
// 烧核播报(缺口#5 代码层):账号核心时长耗尽——上游码 HD.97520422 经
|
|
106
102
|
// hdkitservice 以 HDKIT_UPSTREAM_ERROR 包装透传,客户端按消息内错误码识别。
|
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
// 两池沙箱(pool_type)客户端支持。
|
|
2
2
|
// 机制(定稿):池 = 平台预置模板,客户端传 pool_type(ephemeral/persistent),
|
|
3
3
|
// 服务端权威映射 template_id;显式 template_id 优先(运维钉扎场景)。
|
|
4
|
-
// -
|
|
4
|
+
// - 非持久池(默认):闲置自动关机 + 自动删除(平台策略)
|
|
5
5
|
// - 持久池:EVS 数据盘跨关机保留,闲置回收保留期上限 7 天
|
|
6
|
+
// - 生命周期数值由 hdkitservice 配置回显(仅播报口径,实际策略由平台执行)
|
|
6
7
|
// - 配额:每用户每类型 1 台(同类复用),账号并发上限 5
|
|
7
8
|
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
|
|
8
13
|
const POOL_TYPES = new Set(['ephemeral', 'persistent']);
|
|
9
14
|
|
|
10
|
-
/**
|
|
15
|
+
/** 默认生命周期口径:与服务端默认一致;服务端回显优先,缺失时兜底 */
|
|
16
|
+
export const DEFAULT_POOL_LIFECYCLE = {
|
|
17
|
+
ephemeral: { idleShutdownMinutes: 30, idleDeleteMinutes: 60, reclaimMinutes: null },
|
|
18
|
+
// 7 天 = 10080 分钟(与服务端一致,统一以分钟为单位)
|
|
19
|
+
persistent: { idleShutdownMinutes: 60, idleDeleteMinutes: null, reclaimMinutes: 7 * 24 * 60 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** workspaceId → { poolType, lifecycle, updatedAt }:connect 回显后缓存 */
|
|
11
25
|
const workspacePool = new Map();
|
|
26
|
+
/** 本进程内已播报过 connect 提示的 workspace(会话级,不落盘) */
|
|
27
|
+
const announcedWorkspaces = new Set();
|
|
28
|
+
let cacheLoaded = false;
|
|
29
|
+
|
|
30
|
+
function cachePath() {
|
|
31
|
+
return process.env.HW_SANDBOX_POOL_CACHE_PATH || join(homedir(), '.config', 'huaweicloud', 'sandbox-pools.json');
|
|
32
|
+
}
|
|
12
33
|
|
|
13
34
|
/**
|
|
14
35
|
* 校验并归一化 pool_type;未传时返回 null(交由服务端默认 ephemeral,不在客户端伪造语义)。
|
|
@@ -20,7 +41,7 @@ export function validatePoolType(raw) {
|
|
|
20
41
|
if (!POOL_TYPES.has(normalized)) {
|
|
21
42
|
throw new Error(
|
|
22
43
|
`invalid pool_type "${raw}" (expected ephemeral or persistent). ` +
|
|
23
|
-
'ephemeral =
|
|
44
|
+
'ephemeral = 非持久沙箱(闲置自动关机/删除);persistent = 持久沙箱(数据盘保留,闲置回收上限 7 天)',
|
|
24
45
|
);
|
|
25
46
|
}
|
|
26
47
|
return normalized;
|
|
@@ -37,68 +58,239 @@ export function pinnedTemplateFor(poolType) {
|
|
|
37
58
|
return '';
|
|
38
59
|
}
|
|
39
60
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
function positiveNumber(value) {
|
|
62
|
+
const n = Number(value);
|
|
63
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
43
64
|
}
|
|
44
65
|
|
|
45
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* 归一化生命周期口径:服务端字段(snake_case 官方契约)+ camelCase 双读兜底;
|
|
68
|
+
* 缺失/非法时回退客户端默认值(与服务端默认一致)。
|
|
69
|
+
*/
|
|
70
|
+
function normalizeLifecycle(poolType, raw = {}) {
|
|
71
|
+
const defaults = DEFAULT_POOL_LIFECYCLE[poolType];
|
|
72
|
+
if (!defaults) return null;
|
|
73
|
+
const pick = (...keys) => {
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const value = positiveNumber(raw[key]);
|
|
76
|
+
if (value !== null) return value;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
const idleShutdownMinutes =
|
|
81
|
+
pick('pool_idle_shutdown_minutes', 'poolIdleShutdownMinutes', 'idleShutdownMinutes') ??
|
|
82
|
+
defaults.idleShutdownMinutes;
|
|
83
|
+
const idleDeleteMinutes =
|
|
84
|
+
poolType === 'ephemeral'
|
|
85
|
+
? (pick('pool_idle_delete_minutes', 'poolIdleDeleteMinutes', 'idleDeleteMinutes') ?? defaults.idleDeleteMinutes)
|
|
86
|
+
: null;
|
|
87
|
+
const reclaimMinutes =
|
|
88
|
+
poolType === 'persistent'
|
|
89
|
+
? (pick('pool_reclaim_minutes', 'poolReclaimMinutes', 'reclaimMinutes') ?? defaults.reclaimMinutes)
|
|
90
|
+
: null;
|
|
91
|
+
return { idleShutdownMinutes, idleDeleteMinutes, reclaimMinutes };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ensureCacheLoaded() {
|
|
95
|
+
if (cacheLoaded) return;
|
|
96
|
+
cacheLoaded = true;
|
|
97
|
+
try {
|
|
98
|
+
const raw = JSON.parse(readFileSync(cachePath(), 'utf8'));
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
for (const [wsId, entry] of Object.entries(raw?.workspaces || {})) {
|
|
101
|
+
if (!entry || !POOL_TYPES.has(entry.poolType)) continue;
|
|
102
|
+
if (!Number.isFinite(entry.updatedAt) || now - entry.updatedAt > CACHE_TTL_MS) continue;
|
|
103
|
+
workspacePool.set(wsId, {
|
|
104
|
+
poolType: entry.poolType,
|
|
105
|
+
lifecycle: normalizeLifecycle(entry.poolType, entry.lifecycle || {}),
|
|
106
|
+
updatedAt: entry.updatedAt,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// 首次使用 / 文件损坏:按空缓存处理
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function persistCache() {
|
|
115
|
+
try {
|
|
116
|
+
ensureCacheLoaded();
|
|
117
|
+
const file = cachePath();
|
|
118
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
119
|
+
const workspaces = {};
|
|
120
|
+
for (const [wsId, entry] of workspacePool) workspaces[wsId] = entry;
|
|
121
|
+
const payload = JSON.stringify({ workspaces }, null, 2);
|
|
122
|
+
const tmp = `${file}.tmp`;
|
|
123
|
+
writeFileSync(tmp, payload, { mode: 0o600 });
|
|
124
|
+
try {
|
|
125
|
+
renameSync(tmp, file);
|
|
126
|
+
} catch {
|
|
127
|
+
writeFileSync(file, payload, { mode: 0o600 });
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// 缓存失败不影响主流程(仅丢失跨进程提示能力)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 记录 workspace 的池归属与生命周期口径(来自服务端 connect 回显)。
|
|
136
|
+
* 无 pool_type 回显(自定义模板 / 老服务端)不记录,避免编造池语义。
|
|
137
|
+
*/
|
|
138
|
+
export function rememberWorkspacePool(workspaceId, poolType, lifecycleRaw) {
|
|
139
|
+
if (!workspaceId || !POOL_TYPES.has(poolType)) return;
|
|
140
|
+
workspacePool.set(workspaceId, {
|
|
141
|
+
poolType,
|
|
142
|
+
lifecycle: normalizeLifecycle(poolType, lifecycleRaw || {}),
|
|
143
|
+
updatedAt: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
persistCache();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 清理 workspace 池缓存(测试用 / 会话关闭时) */
|
|
46
149
|
export function forgetWorkspacePool(workspaceId) {
|
|
47
|
-
if (workspaceId === undefined)
|
|
48
|
-
|
|
150
|
+
if (workspaceId === undefined) {
|
|
151
|
+
workspacePool.clear();
|
|
152
|
+
announcedWorkspaces.clear();
|
|
153
|
+
} else {
|
|
154
|
+
workspacePool.delete(workspaceId);
|
|
155
|
+
announcedWorkspaces.delete(workspaceId);
|
|
156
|
+
}
|
|
157
|
+
persistCache();
|
|
49
158
|
}
|
|
50
159
|
|
|
51
160
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
161
|
+
* connect 生命周期提示的播报判定:会话首次 connect 播报;
|
|
162
|
+
* 复用实例被本次 connect 唤醒(started=true)时再次播报;其余复用静默。
|
|
163
|
+
*/
|
|
164
|
+
export function shouldAnnounceConnect(workspaceId, result = {}) {
|
|
165
|
+
const started = result.started === true || result.startedFromStopped === true;
|
|
166
|
+
if (!workspaceId) return true;
|
|
167
|
+
if (!announcedWorkspaces.has(workspaceId)) {
|
|
168
|
+
announcedWorkspaces.add(workspaceId);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return started;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 分钟数 → 中文口径(60 的整数倍显示为小时) */
|
|
175
|
+
function formatMinutes(minutes, fallback) {
|
|
176
|
+
const n = Number(minutes) > 0 ? Number(minutes) : fallback;
|
|
177
|
+
if (n % 60 === 0 && n >= 60) return `${n / 60} 小时`;
|
|
178
|
+
return `${n} 分钟`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 回收保留期:分钟 → 中文口径(1440 的整数倍显示为天) */
|
|
182
|
+
function formatReclaim(minutes, fallback) {
|
|
183
|
+
const n = Number(minutes) > 0 ? Number(minutes) : fallback;
|
|
184
|
+
if (n % 1440 === 0 && n >= 1440) return `${n / 1440} 天`;
|
|
185
|
+
return `${n} 分钟`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* exec/upload/deploy 等工具的内部池上下文(不再默认并入工具返回,避免逐次刷屏)。
|
|
190
|
+
* expose_tunnel 用 poolLifecycle 生成交付地址提示;poolMode 用于 DevBridge key 分池。
|
|
54
191
|
*/
|
|
55
192
|
export function poolContextFor(workspaceId) {
|
|
56
|
-
|
|
193
|
+
ensureCacheLoaded();
|
|
194
|
+
const entry = workspacePool.get(workspaceId);
|
|
195
|
+
if (!entry) return undefined;
|
|
196
|
+
const { poolType, lifecycle } = entry;
|
|
197
|
+
const shutdown = formatMinutes(lifecycle.idleShutdownMinutes, DEFAULT_POOL_LIFECYCLE[poolType].idleShutdownMinutes);
|
|
57
198
|
if (poolType === 'ephemeral') {
|
|
199
|
+
const del = formatMinutes(lifecycle.idleDeleteMinutes, DEFAULT_POOL_LIFECYCLE.ephemeral.idleDeleteMinutes);
|
|
58
200
|
return {
|
|
59
201
|
poolMode: 'ephemeral',
|
|
60
|
-
|
|
202
|
+
poolLifecycle: { poolType, ...lifecycle },
|
|
203
|
+
persistenceHint: `非持久沙箱:闲置 ${shutdown}自动关机、${del}自动删除,重要数据请及时保存或迁出`,
|
|
61
204
|
};
|
|
62
205
|
}
|
|
63
|
-
|
|
206
|
+
const reclaim = formatReclaim(lifecycle.reclaimMinutes, DEFAULT_POOL_LIFECYCLE.persistent.reclaimMinutes);
|
|
207
|
+
return {
|
|
208
|
+
poolMode: 'persistent',
|
|
209
|
+
poolLifecycle: { poolType, ...lifecycle },
|
|
210
|
+
persistenceHint: `持久沙箱:闲置 ${shutdown}自动关机(数据盘保留);闲置回收保留期最长 ${reclaim}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildConnectTtlHint(poolType, lifecycle) {
|
|
215
|
+
if (poolType === 'ephemeral') {
|
|
216
|
+
const shutdown = formatMinutes(lifecycle.idleShutdownMinutes, DEFAULT_POOL_LIFECYCLE.ephemeral.idleShutdownMinutes);
|
|
217
|
+
const del = formatMinutes(lifecycle.idleDeleteMinutes, DEFAULT_POOL_LIFECYCLE.ephemeral.idleDeleteMinutes);
|
|
64
218
|
return {
|
|
65
|
-
|
|
66
|
-
|
|
219
|
+
lifecycle: `idle shutdown ${lifecycle.idleShutdownMinutes}min + auto-delete ${lifecycle.idleDeleteMinutes}min (platform policy)`,
|
|
220
|
+
ttl_hint: `非持久沙箱:闲置 ${shutdown}自动关机、${del}自动删除(平台策略)。重要数据请及时保存或迁出。`,
|
|
67
221
|
};
|
|
68
222
|
}
|
|
69
|
-
|
|
223
|
+
const shutdown = formatMinutes(lifecycle.idleShutdownMinutes, DEFAULT_POOL_LIFECYCLE.persistent.idleShutdownMinutes);
|
|
224
|
+
const reclaim = formatReclaim(lifecycle.reclaimMinutes, DEFAULT_POOL_LIFECYCLE.persistent.reclaimMinutes);
|
|
225
|
+
return {
|
|
226
|
+
lifecycle: `idle shutdown ${lifecycle.idleShutdownMinutes}min; EVS data disk retained; idle reclaim within ${lifecycle.reclaimMinutes}min max`,
|
|
227
|
+
ttl_hint: `持久沙箱:闲置 ${shutdown}自动关机(实例与数据盘保留,重新连接可继续使用);闲置回收保留期最长 ${reclaim},到期实例与数据将被删除,长期不用请自行导出。`,
|
|
228
|
+
};
|
|
70
229
|
}
|
|
71
230
|
|
|
72
231
|
/**
|
|
73
|
-
* connect
|
|
74
|
-
* 仅当服务端回显 pool_type
|
|
75
|
-
*
|
|
76
|
-
*
|
|
232
|
+
* connect 结果池富化(生命周期播报)。
|
|
233
|
+
* - 仅当服务端回显 pool_type 才生成提示(自定义模板不编造池语义)
|
|
234
|
+
* - announce=false 时不播报(会话内重复复用)
|
|
235
|
+
* - 数值以服务端回显为准,缺失时用客户端默认口径兜底
|
|
77
236
|
*/
|
|
78
|
-
export function enrichConnectResult(result) {
|
|
237
|
+
export function enrichConnectResult(result, options = {}) {
|
|
79
238
|
if (!result || typeof result !== 'object') return result;
|
|
80
239
|
const poolType = result.pool_type || result.poolType;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
ttl_hint:
|
|
86
|
-
'非持久沙箱:闲置 30 分钟将自动关机、闲置 1 小时将自动删除(平台定时任务已生效)。' +
|
|
87
|
-
'重要数据请及时保存或迁出;需要跨会话保留数据请改用 pool_type=persistent。',
|
|
88
|
-
};
|
|
89
|
-
} else if (poolType === 'persistent') {
|
|
90
|
-
result.pool = {
|
|
91
|
-
pool_type: 'persistent',
|
|
92
|
-
lifecycle: 'EVS data disk retained; idle reclaim within 7 days max',
|
|
93
|
-
ttl_hint: '持久沙箱:数据盘跨关机保留,闲置回收保留期最长 7 天;长期不用请自行导出数据。',
|
|
94
|
-
};
|
|
240
|
+
const announce = options.announce !== false;
|
|
241
|
+
if (!announce || !POOL_TYPES.has(poolType)) {
|
|
242
|
+
delete result.pool;
|
|
243
|
+
return result;
|
|
95
244
|
}
|
|
245
|
+
const lifecycle = normalizeLifecycle(poolType, result);
|
|
246
|
+
result.pool = {
|
|
247
|
+
pool_type: poolType,
|
|
248
|
+
...buildConnectTtlHint(poolType, lifecycle),
|
|
249
|
+
};
|
|
96
250
|
return result;
|
|
97
251
|
}
|
|
98
252
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
253
|
+
/**
|
|
254
|
+
* 交付公网地址时的沙箱存活期提示(expose_tunnel 专用)。
|
|
255
|
+
* 返回 undefined 表示池语义未知(自定义模板 / 无缓存),不编造提示。
|
|
256
|
+
*/
|
|
257
|
+
export function buildExposeLifetimeNotice(poolContext, expiryHours) {
|
|
258
|
+
const lifecycle = poolContext?.poolLifecycle;
|
|
259
|
+
if (!lifecycle) return undefined;
|
|
260
|
+
const poolType = lifecycle.poolType;
|
|
261
|
+
const tunnelHours = Number(expiryHours) > 0 ? Number(expiryHours) : 72;
|
|
262
|
+
if (poolType === 'ephemeral') {
|
|
263
|
+
const shutdown = formatMinutes(lifecycle.idleShutdownMinutes, DEFAULT_POOL_LIFECYCLE.ephemeral.idleShutdownMinutes);
|
|
264
|
+
const del = formatMinutes(lifecycle.idleDeleteMinutes, DEFAULT_POOL_LIFECYCLE.ephemeral.idleDeleteMinutes);
|
|
265
|
+
return {
|
|
266
|
+
sandboxLifetime: {
|
|
267
|
+
poolType,
|
|
268
|
+
idleShutdownMinutes: lifecycle.idleShutdownMinutes,
|
|
269
|
+
idleDeleteMinutes: lifecycle.idleDeleteMinutes,
|
|
270
|
+
},
|
|
271
|
+
urlLifetimeHint:
|
|
272
|
+
`该公网地址随沙箱存亡:非持久沙箱闲置 ${shutdown}自动关机(地址随即失效)、` +
|
|
273
|
+
`闲置 ${del}自动删除(地址与数据永久消失)。隧道有效期 ${tunnelHours} 小时不等于沙箱存活期;` +
|
|
274
|
+
'自动续期只保隧道,不能阻止沙箱关机。',
|
|
275
|
+
warning:
|
|
276
|
+
`URL 存活期提示:非持久沙箱闲置 ${shutdown}自动关机、${del}自动删除,此地址届时失效` +
|
|
277
|
+
`(隧道 ${tunnelHours} 小时有效期不等于沙箱存活期)`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const shutdown = formatMinutes(lifecycle.idleShutdownMinutes, DEFAULT_POOL_LIFECYCLE.persistent.idleShutdownMinutes);
|
|
281
|
+
const reclaim = formatReclaim(lifecycle.reclaimMinutes, DEFAULT_POOL_LIFECYCLE.persistent.reclaimMinutes);
|
|
282
|
+
return {
|
|
283
|
+
sandboxLifetime: {
|
|
284
|
+
poolType,
|
|
285
|
+
idleShutdownMinutes: lifecycle.idleShutdownMinutes,
|
|
286
|
+
reclaimMinutes: lifecycle.reclaimMinutes,
|
|
287
|
+
},
|
|
288
|
+
urlLifetimeHint:
|
|
289
|
+
`该公网地址随沙箱存亡:持久沙箱闲置 ${shutdown}自动关机(地址失效,但实例与数据盘保留,` +
|
|
290
|
+
`重新连接沙箱后可重新暴露端口恢复访问);闲置回收保留期最长 ${reclaim},到期实例与数据将被删除。` +
|
|
291
|
+
`隧道有效期 ${tunnelHours} 小时不等于沙箱存活期;自动续期只保隧道,不能阻止沙箱关机。`,
|
|
292
|
+
warning:
|
|
293
|
+
`URL 存活期提示:持久沙箱闲置 ${shutdown}自动关机,此地址届时失效(实例与数据保留,` +
|
|
294
|
+
`重新连接后可恢复);闲置回收上限 ${reclaim}`,
|
|
295
|
+
};
|
|
104
296
|
}
|