huaweicloud-devkit 1.1.9 → 1.1.10-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 +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 -1
- package/plugins/huaweicloud-core/skills/huawei-ecs/references/create-instance.md +26 -0
- package/plugins/huaweicloud-core/skills/huawei-ecs/references/hc-activity.md +16 -0
- package/plugins/huaweicloud-core/skills/huawei-ecs/references/troubleshooting.md +51 -0
- package/plugins/huaweicloud-core/skills/huawei-ecs/references/windows-ssh.md +24 -0
- package/plugins/huaweicloud-core/skills/huawei-iac/SKILL.md +38 -36
- package/plugins/huaweicloud-core/skills/huawei-iam/SKILL.md +35 -16
- package/plugins/huaweicloud-core/skills/huawei-iam/references/quick-ref.md +35 -0
- package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +8 -1
- package/plugins/huaweicloud-core/src/account-type.mjs +135 -0
- package/plugins/huaweicloud-core/src/hcloud-cli.mjs +15 -1
- package/plugins/huaweicloud-core/src/safety-policy.mjs +36 -5
- package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +172 -21
- package/plugins/huaweicloud-core/src/tools.mjs +55 -5
|
@@ -55,6 +55,41 @@ hcloud IAM CreateLoginToken # create login token
|
|
|
55
55
|
hcloud IAM GetAccountSummaryV5 # account summary
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
## Primary vs Sub-Account Judgment
|
|
59
|
+
|
|
60
|
+
**Rule (validated against 6 live credentials, zero misjudgment): `username == domainName` → primary account; otherwise IAM sub-user.**
|
|
61
|
+
|
|
62
|
+
The primary account's operator user is named after the domain, and an IAM user cannot occupy that name (account-unique), so the rule has no false positives.
|
|
63
|
+
|
|
64
|
+
Preferred path — one MCP call:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
huaweicloud_check_account_type # optional region param, defaults to configured cli-region
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
CLI fallback (two read-only calls, no identity-policy permission required; works for both primary and sub-user credentials):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
hcloud STS GetCallerIdentity --cli-region=<region>
|
|
74
|
+
# -> principal_urn "iam::<account_id>:user:<username>" gives username; account_id gives domainId
|
|
75
|
+
hcloud IAM KeystoneListAuthDomains
|
|
76
|
+
# -> domains[0].name gives domainName (domains[0].id must equal account_id — consistency check)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Example: `principal_urn ...:user:dingchunlei` + domain name `es_hwcddbdt_OpenCapability_team` → names differ → IAM sub-user. `...:user:keco_1919aa` + domain name `keco_1919aa` → equal → primary account.
|
|
80
|
+
|
|
81
|
+
**Do NOT use these two tempting-but-wrong rules** (both disproved in live testing):
|
|
82
|
+
|
|
83
|
+
| Wrong rule | Why it fails |
|
|
84
|
+
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
85
|
+
| `ShowDomainPasswordPolicy` succeeds → primary | Permission-gated, not account-type-gated: a sub-user with IAM permissions also succeeds |
|
|
86
|
+
| `principal_id == account_id` → primary | False for primary accounts too (observed on three separate primary samples; IDs only share a prefix) |
|
|
87
|
+
|
|
88
|
+
Edge cases:
|
|
89
|
+
|
|
90
|
+
- Agency-session credentials (`principal_urn` starts with `sts::` or contains `assumed-agency`): the rule does not apply — report the identity only.
|
|
91
|
+
- `KeystoneValidateToken` looks like a one-call shortcut (its token payload has both names) but requires an existing token as `--X-Subject-Token`, which AK/SK signing does not provide.
|
|
92
|
+
|
|
58
93
|
## Security Best Practices
|
|
59
94
|
|
|
60
95
|
1. **NEVER create IAM users** — use IAM Identity Center or temporary STS tokens
|
|
@@ -294,7 +294,7 @@ fi
|
|
|
294
294
|
Authentication tries three credential sources in order, each verified with a real login + `auth status` before use; a failure falls through to the next tier:
|
|
295
295
|
|
|
296
296
|
- **Tier 1 — keyring API Key** (preferred): recent sandbox images (hd-space-ai-shell line) get `HW_DEVBRIDGE_API_KEY` auto-injected by the platform into the sandbox keyring — **zero user action, long-lived key**. Read it via `hwcloud keyring get HW_DEVBRIDGE_API_KEY 2>&1` (⚠ the value goes to **stderr** — `2>&1` is mandatory or you silently capture an empty string) and accept only values starting with `devbridge_` (`(not found)` and error text are rejected). If the first read fails, a light revive (restart the D-Bus session bus at `unix:path=/run/dbus-session`, replace a locked keyring daemon with `printf '\n' | gnome-keyring-daemon --unlock`) retries once — no package installs.
|
|
297
|
-
- **Tier 2 — AK/SK** (image builds, legacy sandboxes): image builds keep `--access-key/--secret-key`. STS credentials **must** include `--security-token "$HW_SECURITY_TOKEN"` or the gateway rejects the request with 401 `APIGW.0301`. 0.1.x binaries additionally
|
|
297
|
+
- **Tier 2 — AK/SK** (image builds, legacy sandboxes): image builds keep `--access-key/--secret-key`. STS credentials **must** include `--security-token "$HW_SECURITY_TOKEN"` or the gateway rejects the request with 401 `APIGW.0301`. **The `--huaweicloud` SSO login flag does NOT exist in sandbox devbridge 0.2.x binaries (e.g. 0.2.2)** — 文档超前于二进制:不要尝试 `--huaweicloud`,直接使用 `devbridge auth login --access-key/--secret-key`(上方脚本以 `auth login --help` 探测为准)。0.1.x binaries additionally advertised `--huaweicloud` (use it only when their help advertises it).
|
|
298
298
|
- **Tier 3 — `/tmp/hw_api_key`** (release builds, user-provided one-time): the file is written by `huaweicloud_sandbox_credentials` from the local `HW_API_KEY` env (preferred) or the `api_key` param.
|
|
299
299
|
|
|
300
300
|
`huaweicloud_sandbox_expose_tunnel` runs this waterfall automatically and reports the winning mode (`API_KEY_KEYRING` / `AKSK_SUPPORTED` / `API_KEY_FILE`) or `NO_CREDENTIAL` with a non-sensitive per-tier `attempts` diagnostic. Manual equivalent:
|
|
@@ -324,6 +324,11 @@ devbridge auth status # separate step: a status failure must not mask the logi
|
|
|
324
324
|
- **`AKSK_SUPPORTED`** → done. The temporary AK/SK injected by `huaweicloud_sandbox_credentials` is used directly (validated against IAM before injection). If login fails, surface the CLI error — expired STS token → re-run `huaweicloud_sandbox_credentials`.
|
|
325
325
|
- **`NO_CREDENTIAL`** → first re-run `huaweicloud_sandbox_credentials` and retry (refreshes STS, most common quick fix). Still failing means an older sandbox whose image cannot auto-acquire (images are frozen at sandbox creation and cannot be upgraded in place) — guide the developer through a one-time API Key creation (wait for the key before continuing):
|
|
326
326
|
|
|
327
|
+
**沙箱凭证与 devbridge 版本差异陷阱(实测 #935 坑2/坑3)**:
|
|
328
|
+
|
|
329
|
+
- 沙箱内 devbridge 0.2.2 无技能文档所述 `--huaweicloud` 登录标志(文档超前于二进制)—— 应直接使用 `devbridge auth login --access-key/--secret-key`(`auth login --help` 探测到 `--access-key` 支持时走该分支,见上方手动等价脚本)。
|
|
330
|
+
- 沙箱内 MCP 注入的 STS 凭证为 obsutil 加密态存储:devbridge 读到密文凭证会报 `APIGW.0301`。需先上传本地 DevKit 明文凭证库(AK 20 位 / SK 40 位,通过 `huaweicloud_sandbox_credentials` 注入),再创建隧道。
|
|
331
|
+
|
|
327
332
|
1. **Why (one sentence)**: "沙箱的地址生成服务发布版构建已移除 AK/SK 登录,仅支持 API Key(镜像内置构建仍支持 AK/SK,Agent 已自动探测)。API Key 按账号管理,创建一次长期可用、所有沙箱通用。"
|
|
328
333
|
2. **Where (exact steps)**: open https://devstation.connect.huaweicloud.com/space/devbridge/apikey → 登录控制台 → 选择 DevBridge 场景 → 点击"创建"。**完整值仅在创建时展示一次,立即复制**(`devbridge_` 开头)。
|
|
329
334
|
3. **How to hand it over** (present both, recommend the first):
|
|
@@ -403,6 +408,8 @@ This eliminates the most common deployment failure — historical tunnels from p
|
|
|
403
408
|
|
|
404
409
|
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.
|
|
405
410
|
|
|
411
|
+
**沙箱无 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)。
|
|
412
|
+
|
|
406
413
|
### Step 1: Detect Framework Locally
|
|
407
414
|
|
|
408
415
|
**Always call `huaweicloud_detect_framework` first** before connecting to the sandbox. It scans the local project and returns:
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { runHcloud } from './hcloud-cli.mjs';
|
|
2
|
+
|
|
3
|
+
// Parse `hcloud STS GetCallerIdentity` stdout into structured identity fields.
|
|
4
|
+
// URN forms (see https://support.huaweicloud.com/usermanual-iam5/iam_01_1275.html):
|
|
5
|
+
// iam::<account_id>:user:<username> — user credentials (primary or IAM sub-user)
|
|
6
|
+
// sts::<account_id>::assumed-agency:<name>/… — agency session credentials (rule does not apply)
|
|
7
|
+
export function parseCallerIdentity(stdout) {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = typeof stdout === 'string' ? JSON.parse(stdout) : stdout;
|
|
11
|
+
} catch {
|
|
12
|
+
return { ok: false, error: 'GetCallerIdentity returned non-JSON output.' };
|
|
13
|
+
}
|
|
14
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.principal_urn || !parsed.account_id) {
|
|
15
|
+
return { ok: false, error: 'GetCallerIdentity response missing principal_urn/account_id.' };
|
|
16
|
+
}
|
|
17
|
+
const principalUrn = String(parsed.principal_urn);
|
|
18
|
+
let urnType = 'unknown';
|
|
19
|
+
let username;
|
|
20
|
+
const userMatch = principalUrn.match(/^iam::[^:]+:user:(.+)$/);
|
|
21
|
+
if (userMatch) {
|
|
22
|
+
urnType = 'user';
|
|
23
|
+
username = userMatch[1];
|
|
24
|
+
} else if (/^sts::/.test(principalUrn) || /assumed-agency/.test(principalUrn)) {
|
|
25
|
+
urnType = 'agency';
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
accountId: String(parsed.account_id),
|
|
30
|
+
principalUrn,
|
|
31
|
+
principalId: parsed.principal_id ? String(parsed.principal_id) : undefined,
|
|
32
|
+
urnType,
|
|
33
|
+
username,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Parse `hcloud IAM KeystoneListAuthDomains` stdout into the domain list.
|
|
38
|
+
export function parseAuthDomains(stdout) {
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = typeof stdout === 'string' ? JSON.parse(stdout) : stdout;
|
|
42
|
+
} catch {
|
|
43
|
+
return { ok: false, error: 'KeystoneListAuthDomains returned non-JSON output.' };
|
|
44
|
+
}
|
|
45
|
+
const domains = Array.isArray(parsed?.domains) ? parsed.domains : [];
|
|
46
|
+
if (domains.length === 0) {
|
|
47
|
+
return { ok: false, error: 'IAM returned no domain information. Verify the AK/SK is valid.' };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
domains: domains.map((d) => ({ id: String(d.id), name: String(d.name || '') })),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Judge primary vs sub-account. Rule (validated against 6 real credentials + 1 report case):
|
|
56
|
+
// username == domainName -> primary account (its operator user is named after the domain)
|
|
57
|
+
// username != domainName -> IAM sub-user (a sub-user cannot occupy the domain name)
|
|
58
|
+
// Two tempting-but-wrong rules, kept here so they are not "rediscovered":
|
|
59
|
+
// - ShowDomainPasswordPolicy is NOT primary-only (it is permission-gated; a sub-user with IAM
|
|
60
|
+
// permissions can call it) — observed live.
|
|
61
|
+
// - principal_id == account_id does NOT hold for primary accounts either — observed on three
|
|
62
|
+
// separate primary-account samples.
|
|
63
|
+
export function judgeAccountType(identity, domains) {
|
|
64
|
+
if (identity.urnType !== 'user') {
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
applicable: false,
|
|
68
|
+
reason:
|
|
69
|
+
'Credentials are an agency session (principal_urn is not iam::…:user:…). ' +
|
|
70
|
+
'The primary/sub-account judgment applies to user credentials only.',
|
|
71
|
+
principalUrn: identity.principalUrn,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const matched = domains.domains.find((d) => d.id === identity.accountId);
|
|
75
|
+
if (!matched) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error:
|
|
79
|
+
'Inconsistent identity response: GetCallerIdentity account_id does not match any domain ' +
|
|
80
|
+
'returned by KeystoneListAuthDomains. Retry, and re-run "npx huaweicloud-devkit auth init" if it persists.',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const isPrimaryAccount = identity.username === matched.name;
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
applicable: true,
|
|
87
|
+
isPrimaryAccount,
|
|
88
|
+
domainId: matched.id,
|
|
89
|
+
domainName: matched.name,
|
|
90
|
+
username: identity.username,
|
|
91
|
+
userId: identity.principalId,
|
|
92
|
+
principalUrn: identity.principalUrn,
|
|
93
|
+
evidence: isPrimaryAccount
|
|
94
|
+
? `username == domainName ("${identity.username}") — the primary account's operator user is named after the domain.`
|
|
95
|
+
: `username ("${identity.username}") != domainName ("${matched.name}") — the caller is an IAM user under the domain.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Orchestrate the two read-only hcloud calls and judge the account type.
|
|
100
|
+
export async function checkAccountType({ region } = {}) {
|
|
101
|
+
const regionArgs = region ? [`--cli-region=${region}`] : [];
|
|
102
|
+
const identityResult = await runHcloud(['STS', 'GetCallerIdentity', ...regionArgs], {
|
|
103
|
+
timeoutMs: 30000,
|
|
104
|
+
maxRetries: 0,
|
|
105
|
+
}).catch((error) => ({ ok: false, error: error.message }));
|
|
106
|
+
if (!identityResult.ok) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: identityResult.error || 'STS GetCallerIdentity failed.',
|
|
110
|
+
hint: 'Re-run "npx huaweicloud-devkit auth init" (invalid/expired AK/SK is the most common cause, e.g. APIGW.0301).',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const identity = parseCallerIdentity(identityResult.stdout);
|
|
114
|
+
if (!identity.ok) {
|
|
115
|
+
return { ok: false, error: identity.error };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const domainsResult = await runHcloud(['IAM', 'KeystoneListAuthDomains'], {
|
|
119
|
+
timeoutMs: 30000,
|
|
120
|
+
maxRetries: 0,
|
|
121
|
+
}).catch((error) => ({ ok: false, error: error.message }));
|
|
122
|
+
if (!domainsResult.ok) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: domainsResult.error || 'IAM KeystoneListAuthDomains failed.',
|
|
126
|
+
hint: 'Re-run "npx huaweicloud-devkit auth init" if credentials are suspected expired.',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const domains = parseAuthDomains(domainsResult.stdout);
|
|
130
|
+
if (!domains.ok) {
|
|
131
|
+
return { ok: false, error: domains.error };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return judgeAccountType(identity, domains);
|
|
135
|
+
}
|
|
@@ -799,8 +799,22 @@ export function extractApiError(stdout) {
|
|
|
799
799
|
return null;
|
|
800
800
|
}
|
|
801
801
|
|
|
802
|
+
// A4 (#935): KooCLI 7.2.x prints a version-upgrade banner on EVERY command
|
|
803
|
+
// output (e.g. 「可执行 hcloud update 更新至 7.3.12」), which floods agent
|
|
804
|
+
// conversations and drowns the actual result. Banner lines are stripped from
|
|
805
|
+
// command output here; the only upgrade hint DevKit emits is check_cli/doctor's
|
|
806
|
+
// soft, one-shot mismatch hint — kooCliVersion in package.json is the single
|
|
807
|
+
// source of truth for the paired version, and check_cli/doctor keep their
|
|
808
|
+
// non-blocking semantics.
|
|
809
|
+
export function stripKooCliUpdateBanner(output) {
|
|
810
|
+
return String(output || '')
|
|
811
|
+
.split('\n')
|
|
812
|
+
.filter((line) => !(/hcloud\s+update/i.test(line) && /更新至|update to/i.test(line)))
|
|
813
|
+
.join('\n');
|
|
814
|
+
}
|
|
815
|
+
|
|
802
816
|
export function redactOutput(output) {
|
|
803
|
-
let text = String(output || '');
|
|
817
|
+
let text = stripKooCliUpdateBanner(String(output || ''));
|
|
804
818
|
const bracketIdx = text.indexOf('{');
|
|
805
819
|
if (bracketIdx > 0) text = text.substring(bracketIdx);
|
|
806
820
|
try {
|
|
@@ -394,7 +394,13 @@ function splitSimpleCommand(command) {
|
|
|
394
394
|
return (
|
|
395
395
|
String(command)
|
|
396
396
|
.match(/"[^"]*"|'[^']*'|\S+/g)
|
|
397
|
-
|
|
397
|
+
// Strip ALL quote characters (not just the outer pair): POSIX shells
|
|
398
|
+
// remove quotes before executing, so an embedded-quote form like
|
|
399
|
+
// `hcloud ECS De'leteServer …` actually runs `DeleteServer`. Stripping
|
|
400
|
+
// only outer quotes left `De'leteServer` in the token and let the write/
|
|
401
|
+
// secret/execution matchers miss it (#927). This hcloud classification
|
|
402
|
+
// path only — credential-var lookbehind still sees raw text with quotes.
|
|
403
|
+
.map((part) => part.replace(/['"]/g, '')) || []
|
|
398
404
|
);
|
|
399
405
|
}
|
|
400
406
|
|
|
@@ -478,12 +484,37 @@ export function extractInnerCommand(text, depth = 0) {
|
|
|
478
484
|
// ^hcloud still catches wrapped commands and prompt-injections using
|
|
479
485
|
// separators. sudo\s+ preserves the dev-baseline behaviour where
|
|
480
486
|
// `sudo hcloud …` is treated as a real command, not documentation (#760 P0).
|
|
487
|
+
// Keyword-locator pattern shared by the raw-text match and the quote-stripped
|
|
488
|
+
// retry in extractHcloudSubcommand (`im` flag, no `g` — no lastIndex state).
|
|
489
|
+
const HCLOUD_KEYWORD_PATTERN = /(?:^|[;|&]\s*|\$\(\s*|`\s*|sudo\s+|\r?\n\s*)(hcloud(?:\.exe)?\b)/im;
|
|
490
|
+
|
|
481
491
|
function extractHcloudSubcommand(text) {
|
|
482
492
|
const t = String(text || '');
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
493
|
+
// `m` flag + newline in the separator class: a hcloud command at the start
|
|
494
|
+
// of any line (not just the first) must be located and fully classified —
|
|
495
|
+
// previously `echo hello\nhcloud ECS DeleteServer …` fell through as
|
|
496
|
+
// not_huaweicloud allow (#927).
|
|
497
|
+
const match = HCLOUD_KEYWORD_PATTERN.exec(t);
|
|
498
|
+
if (match) {
|
|
499
|
+
const hcloudIndex = match.index + match[0].length - match[1].length;
|
|
500
|
+
return t.slice(hcloudIndex);
|
|
501
|
+
}
|
|
502
|
+
// Keyword-embedded quotes fallback (#927 B1): POSIX shells strip quotes
|
|
503
|
+
// before execution, so `hc'loud ECS DeleteServer …` runs `hcloud …` — but
|
|
504
|
+
// the literal match above never sees the keyword through the quotes and the
|
|
505
|
+
// command fell through as not_huaweicloud allow (its de-quoted form is
|
|
506
|
+
// DENY). Retry on a quote-stripped COPY used only for hcloud keyword
|
|
507
|
+
// location; the returned subcommand is the de-quoted text, matching the
|
|
508
|
+
// de-quote semantics splitSimpleCommand already applies to tokens.
|
|
509
|
+
// Credential/env-var checks in classifyTextCommand still run on the raw
|
|
510
|
+
// text — the two paths stay independent — and natural-language mentions
|
|
511
|
+
// ("see hcloud docs", "echo hcloud is a CLI") match the same anchored
|
|
512
|
+
// pattern, so no new false positives are introduced.
|
|
513
|
+
const stripped = t.replace(/['"]/g, '');
|
|
514
|
+
const strippedMatch = HCLOUD_KEYWORD_PATTERN.exec(stripped);
|
|
515
|
+
if (!strippedMatch) return null;
|
|
516
|
+
const hcloudIndex = strippedMatch.index + strippedMatch[0].length - strippedMatch[1].length;
|
|
517
|
+
return stripped.slice(hcloudIndex);
|
|
487
518
|
}
|
|
488
519
|
|
|
489
520
|
export function classifyTextCommand(command, options = {}) {
|
|
@@ -226,12 +226,48 @@ export function buildExposeRemediation(port) {
|
|
|
226
226
|
return `In the sandbox: source /tmp/hw_creds.sh; source /tmp/hw_api_key 2>/dev/null; devbridge delete-all; devbridge create <name>; devbridge port create <tunnelId> -p ${port} --protocol http -a; nohup devbridge host <tunnelId> -p ${port} > /tmp/host.log 2>&1 & If deploy_nginx reported a different (auto-incremented) port in its "port" field, use THAT port instead of the one shown here. Full procedure in huawei-sandbox skill, Step 7 (Expose via DevBridge).`;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
// #953: classify a local upload path BEFORE touching the file, so the error names
|
|
230
|
+
// the real problem instead of a misleading "not found" when an entire parent
|
|
231
|
+
// directory is missing (agent-side paths often do not exist at all).
|
|
232
|
+
// kind: 'file' (uploadFileWithSession) | 'directory' (uploadProjectWithSession).
|
|
233
|
+
// Returns null when the path exists with the expected type; otherwise an Error
|
|
234
|
+
// whose message distinguishes missing-path from missing-parent, with a copy-to-tmp
|
|
235
|
+
// hint that names the actual OS temp dir.
|
|
236
|
+
export function classifyLocalUploadPath(localPath, kind = 'file') {
|
|
237
|
+
const expected = kind === 'directory' ? 'directory' : 'regular file';
|
|
238
|
+
const prefix = kind === 'directory' ? 'sandbox upload project' : 'sandbox upload';
|
|
239
|
+
if (existsSync(localPath)) {
|
|
240
|
+
const stat = statSync(localPath);
|
|
241
|
+
const matches = kind === 'directory' ? stat.isDirectory() : stat.isFile();
|
|
242
|
+
if (!matches) {
|
|
243
|
+
return new Error(`${prefix}: path is not a ${expected}: ${localPath}`);
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
232
246
|
}
|
|
233
|
-
|
|
234
|
-
|
|
247
|
+
const parent = dirname(localPath);
|
|
248
|
+
if (!existsSync(parent)) {
|
|
249
|
+
return new Error(
|
|
250
|
+
`${prefix}: directory not found: ${parent}. The DevKit MCP server reads files from ` +
|
|
251
|
+
`the machine it runs on. Verify the path, or first copy the ${expected} to the local ` +
|
|
252
|
+
`temp directory (${tmpdir()}) and upload from there.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return new Error(`${prefix}: local ${expected} not found: ${localPath}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// #953: reset command for uploadFileWithSession. mkdir -p the remote parent BEFORE any
|
|
259
|
+
// chunk writes — the `printf >>` appends land in "${remotePath}.b64tmp" next to the
|
|
260
|
+
// target, so an absent directory (e.g. /workspace/game2048/) would fail the very first
|
|
261
|
+
// batch. This aligns base64-path behavior with upload_project's file server (which
|
|
262
|
+
// already makedirs). Both paths are quoted; "path with spaces" is covered by tests.
|
|
263
|
+
export function buildUploadResetCommand(remotePath, tmpPath) {
|
|
264
|
+
return `mkdir -p "$(dirname "${remotePath}")" && rm -f "${tmpPath}"`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function uploadFileWithSession(workspaceId, localPath, remotePath, username = 'root', timeoutMs = 30000) {
|
|
268
|
+
const localError = classifyLocalUploadPath(localPath, 'file');
|
|
269
|
+
if (localError) {
|
|
270
|
+
throw localError;
|
|
235
271
|
}
|
|
236
272
|
const content = readFileSync(localPath);
|
|
237
273
|
const base64 = content.toString('base64');
|
|
@@ -239,7 +275,7 @@ export async function uploadFileWithSession(workspaceId, localPath, remotePath,
|
|
|
239
275
|
const chunks = splitBase64Chunks(base64);
|
|
240
276
|
const tmp = `${remotePath}.b64tmp`;
|
|
241
277
|
|
|
242
|
-
const reset = await execWithSession(workspaceId,
|
|
278
|
+
const reset = await execWithSession(workspaceId, buildUploadResetCommand(remotePath, tmp), username, timeoutMs);
|
|
243
279
|
if (reset.exitCode !== 0) {
|
|
244
280
|
throw new Error(`sandbox upload: failed to reset temp file: ${reset.stdout || reset.error || reset.exitCode}`);
|
|
245
281
|
}
|
|
@@ -639,11 +675,9 @@ export async function uploadProjectWithSession(
|
|
|
639
675
|
'Set HW_WORKSPACE_ID env var or ensure huaweicloud_sandbox_connect was called first.',
|
|
640
676
|
);
|
|
641
677
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if (!statSync(localDir).isDirectory()) {
|
|
646
|
-
throw new Error(`sandbox upload project: path is not a directory: ${localDir}`);
|
|
678
|
+
const localError = classifyLocalUploadPath(localDir, 'directory');
|
|
679
|
+
if (localError) {
|
|
680
|
+
throw localError;
|
|
647
681
|
}
|
|
648
682
|
|
|
649
683
|
rotateUploadLog();
|
|
@@ -740,6 +774,56 @@ export async function uploadProjectWithSession(
|
|
|
740
774
|
};
|
|
741
775
|
}
|
|
742
776
|
|
|
777
|
+
// A1 (#935): normalize the deploy_nginx configName to a bare filename. A value
|
|
778
|
+
// carrying path separators (e.g. `/root/hc-activity.conf`, `./x/y.conf`) used to
|
|
779
|
+
// be spliced verbatim into `/etc/nginx/conf.d/${configName}.conf`, producing the
|
|
780
|
+
// broken path `/etc/nginx/conf.d//root/hc-activity.conf` (nginx never serves it,
|
|
781
|
+
// the tunnel answers 502). Only the basename is kept and the `.conf` suffix is
|
|
782
|
+
// stripped before the path is rebuilt as `/etc/nginx/conf.d/<name>.conf`. A
|
|
783
|
+
// pure filename (no separators) keeps the previous behavior unchanged.
|
|
784
|
+
export function resolveNginxConfigPath(configName, project) {
|
|
785
|
+
const raw = String(configName || project || '');
|
|
786
|
+
const base = raw.split(/[\\/]/).filter(Boolean).pop() || '';
|
|
787
|
+
const name = base.replace(/\.conf$/i, '') || 'default';
|
|
788
|
+
return `/etc/nginx/conf.d/${name}.conf`;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// A1 (#935): parse the write-verification block appended to the deploy_nginx
|
|
792
|
+
// script. The old flow returned ok:true whenever the deploy script exited 0,
|
|
793
|
+
// even when the tee write silently failed — the config file never existed and
|
|
794
|
+
// the tunnel answered 502. The verification block emits explicit markers:
|
|
795
|
+
// DB_NGINX_CONFIG_MISSING=1 — the target config file does not exist
|
|
796
|
+
// DB_NGINX_T=PASS|FAIL — nginx -t verdict, with failure output echoed
|
|
797
|
+
// as `DB_NGINX_T_OUTPUT: ...` lines
|
|
798
|
+
export function parseDeployNginxVerifyOutput(stdout) {
|
|
799
|
+
const text = String(stdout || '');
|
|
800
|
+
const configMissing = /DB_NGINX_CONFIG_MISSING=1/.test(text);
|
|
801
|
+
const nginxTest = (text.match(/^DB_NGINX_T=(\w+)$/m) || [])[1] || '';
|
|
802
|
+
const nginxTestOutput = (text.match(/^DB_NGINX_T_OUTPUT: .*$/gm) || [])
|
|
803
|
+
.map((line) => line.replace(/^DB_NGINX_T_OUTPUT: /, ''))
|
|
804
|
+
.join('\n');
|
|
805
|
+
return { configMissing, nginxTest, nginxTestOutput };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// A1 (#935): the deploy verdict. A deploy script exit of 0 alone is NOT proof
|
|
809
|
+
// of success — the tee write can fail silently (broken pipe) and nginx -t can
|
|
810
|
+
// reject the config. Returns ok:false with an explicit, actionable error for
|
|
811
|
+
// any verification failure; a pure function so the verdict is unit-testable.
|
|
812
|
+
export function evaluateDeployNginxVerify(result, verify, configPath) {
|
|
813
|
+
const exitCode = result?.exitCode;
|
|
814
|
+
const writeVerified = exitCode === 0 && !verify.configMissing && verify.nginxTest === 'PASS';
|
|
815
|
+
if (writeVerified) return { ok: true };
|
|
816
|
+
const problems = [];
|
|
817
|
+
if (exitCode !== 0) problems.push(`deploy script exited with code ${exitCode}`);
|
|
818
|
+
if (verify.configMissing) problems.push(`nginx config file ${configPath} was not created (tee write failed)`);
|
|
819
|
+
if (verify.nginxTest !== 'PASS')
|
|
820
|
+
problems.push(`nginx -t did not pass${verify.nginxTestOutput ? ` — ${verify.nginxTestOutput}` : ''}`);
|
|
821
|
+
return {
|
|
822
|
+
ok: false,
|
|
823
|
+
error: `sandbox deploy nginx: configuration write verification failed — ${problems.join('; ')}.`,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
743
827
|
export async function deployNginx(
|
|
744
828
|
workspaceId,
|
|
745
829
|
{ nginxType, port, project, outputDir, nodePort, publicPort, configName },
|
|
@@ -874,10 +958,14 @@ fi`;
|
|
|
874
958
|
throw new Error(`sandbox deploy nginx: unknown nginxType "${nginxType}". Must be one of: spa, proxy, static`);
|
|
875
959
|
}
|
|
876
960
|
|
|
961
|
+
// A1 (#935): configName is normalized to a bare filename so a path-shaped
|
|
962
|
+
// value can never produce `/etc/nginx/conf.d//root/xxx.conf` again.
|
|
963
|
+
const configPath = resolveNginxConfigPath(configName, project);
|
|
964
|
+
|
|
877
965
|
const cmd = [
|
|
878
966
|
resolveScript,
|
|
879
967
|
`sudo mkdir -p /etc/nginx/conf.d`,
|
|
880
|
-
`sudo tee
|
|
968
|
+
`sudo tee ${configPath} > /dev/null << 'NGINX_EOF'`,
|
|
881
969
|
config,
|
|
882
970
|
`NGINX_EOF`,
|
|
883
971
|
`# Resolve symlinks for chmod (chmod does not follow symlinks on Linux)`,
|
|
@@ -886,6 +974,11 @@ fi`;
|
|
|
886
974
|
`find "$REAL_PROJECT" -type f -path "*/node_modules/.bin/*" -exec chmod +x {} \\; 2>/dev/null || true`,
|
|
887
975
|
`if pgrep -x nginx > /dev/null 2>&1; then sudo killall -9 nginx 2>/dev/null; sleep 1; fi
|
|
888
976
|
sudo nginx`,
|
|
977
|
+
`# A1 (#935): verify the config write took effect before reporting success`,
|
|
978
|
+
`NGINX_CONFIG_PATH=${configPath}`,
|
|
979
|
+
`if [ ! -f "$NGINX_CONFIG_PATH" ]; then echo "DB_NGINX_CONFIG_MISSING=1"; fi`,
|
|
980
|
+
`NGINX_T_OUTPUT=$(sudo nginx -t 2>&1 || true)`,
|
|
981
|
+
`if echo "$NGINX_T_OUTPUT" | grep -Eq "syntax is ok|test is successful"; then echo "DB_NGINX_T=PASS"; else echo "DB_NGINX_T=FAIL"; echo "$NGINX_T_OUTPUT" | sed "s/^/DB_NGINX_T_OUTPUT: /"; fi`,
|
|
889
982
|
].join('\n');
|
|
890
983
|
|
|
891
984
|
const result = await execOneShot(workspaceId, cmd, username, timeoutMs);
|
|
@@ -912,13 +1005,36 @@ fi`;
|
|
|
912
1005
|
const driftWarning = tunnelActive ? formatPortDriftWarning(basePort, targetPort) : undefined;
|
|
913
1006
|
if (driftWarning) warnings.push(driftWarning);
|
|
914
1007
|
|
|
1008
|
+
// A1 (#935): verify the config write before reporting success. Any
|
|
1009
|
+
// verification failure returns ok:false with an explicit, actionable error.
|
|
1010
|
+
const verify = parseDeployNginxVerifyOutput(result.stdout);
|
|
1011
|
+
const verdict = evaluateDeployNginxVerify(result, verify, configPath);
|
|
1012
|
+
if (!verdict.ok) {
|
|
1013
|
+
return {
|
|
1014
|
+
ok: false,
|
|
1015
|
+
error: verdict.error,
|
|
1016
|
+
nginxType,
|
|
1017
|
+
port: targetPort,
|
|
1018
|
+
nodePort: effectiveNodePort || undefined,
|
|
1019
|
+
outputPath,
|
|
1020
|
+
projectPath,
|
|
1021
|
+
configPath,
|
|
1022
|
+
exitCode: result.exitCode,
|
|
1023
|
+
stdout: result.stdout,
|
|
1024
|
+
nextStep: 'fix_and_retry',
|
|
1025
|
+
warning: warnings.length > 0 ? warnings.join(' | ') : undefined,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
915
1029
|
return {
|
|
916
|
-
ok:
|
|
1030
|
+
ok: true,
|
|
917
1031
|
nginxType,
|
|
918
1032
|
port: targetPort,
|
|
919
1033
|
nodePort: effectiveNodePort || undefined,
|
|
920
1034
|
outputPath,
|
|
921
1035
|
projectPath,
|
|
1036
|
+
configPath,
|
|
1037
|
+
verification: { configPath, nginxTest: verify.nginxTest },
|
|
922
1038
|
exitCode: result.exitCode,
|
|
923
1039
|
stdout: result.stdout,
|
|
924
1040
|
nextStep: 'expose_via_devbridge',
|
|
@@ -1318,11 +1434,22 @@ export function buildDevbridgeExposeScript(port, expiryHours = DEVBRIDGE_HOST_EX
|
|
|
1318
1434
|
// not abort the script.
|
|
1319
1435
|
`if command -v setsid > /dev/null 2>&1; then setsid nohup devbridge host -p ${port} -e ${expiryHours} > ${hostLog} 2>&1 < /dev/null & else nohup devbridge host -p ${port} -e ${expiryHours} > ${hostLog} 2>&1 < /dev/null & fi`,
|
|
1320
1436
|
`disown 2>/dev/null || true`,
|
|
1321
|
-
`sleep
|
|
1322
|
-
// Port-anchored extraction: a multi-port host prints one "Tunnel URL:" line per
|
|
1437
|
+
`sleep 6`,
|
|
1438
|
+
// Port-anchored extraction (#943): a multi-port host prints one "Tunnel URL:" line per
|
|
1323
1439
|
// port, so a port-blind `sed | tail -1` can pick another port's URL.
|
|
1324
1440
|
`TUNNEL_URL=$(grep -oE "https://[A-Za-z0-9_-]+-${port}\\.devbridge-s2\\.hwtunnel\\.com" ${hostLog} | head -1)`,
|
|
1325
|
-
|
|
1441
|
+
// A2 (#935): probe with retries + backoff — 502/000 before the gateway is
|
|
1442
|
+
// ready is not a failure; the LAST probe decides reachability. The body is
|
|
1443
|
+
// captured every probe so DB_TUNNEL_BODY reflects the final one (#943).
|
|
1444
|
+
`HTTP_CODE="000"`,
|
|
1445
|
+
`if [ -n "$TUNNEL_URL" ]; then`,
|
|
1446
|
+
` for i in 1 2 3 4; do`,
|
|
1447
|
+
` HTTP_CODE=$(curl -s -o /tmp/.db_probe_body -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
|
|
1448
|
+
` echo "DB_PROBE_$i=$HTTP_CODE"`,
|
|
1449
|
+
` if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "304" ]; then break; fi`,
|
|
1450
|
+
` if [ "$i" != "4" ]; then sleep 5; fi`,
|
|
1451
|
+
` done`,
|
|
1452
|
+
`fi`,
|
|
1326
1453
|
`echo "DB_HOST_LOG="`,
|
|
1327
1454
|
`tail -n 5 ${hostLog}`,
|
|
1328
1455
|
`echo "DB_TUNNEL_URL=$TUNNEL_URL"`,
|
|
@@ -1362,6 +1489,30 @@ export function buildDevbridgeVerifyScript(tunnelUrl) {
|
|
|
1362
1489
|
].join('\n');
|
|
1363
1490
|
}
|
|
1364
1491
|
|
|
1492
|
+
// A2 (#935): parse the probe sequence emitted by buildDevbridgeExposeScript.
|
|
1493
|
+
// Each probe echo is `DB_PROBE_<n>=<code>`; reachability is decided by the
|
|
1494
|
+
// LAST probe — intermediate 502/000 probes are gateway-not-ready noise, not
|
|
1495
|
+
// failures, so they must not leak into the tool's verdict.
|
|
1496
|
+
export function parseDevbridgeProbeSequence(stdout) {
|
|
1497
|
+
const text = String(stdout || '');
|
|
1498
|
+
const probes = [];
|
|
1499
|
+
for (const m of text.matchAll(/^DB_PROBE_(\d+)=(\S*)$/gm)) {
|
|
1500
|
+
probes.push({ attempt: Number(m[1]), httpCode: m[2] });
|
|
1501
|
+
}
|
|
1502
|
+
const finalHttpCode = probes.length > 0 ? probes[probes.length - 1].httpCode : '';
|
|
1503
|
+
return { probes, finalHttpCode };
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// A2 (#935): the reachability warning must come from the LAST probe's HTTP
|
|
1507
|
+
// status, not from a single early curl — the old single-shot check warned
|
|
1508
|
+
// "Tunnel reachable check returned HTTP (expected 200/304)" while the same
|
|
1509
|
+
// log showed DB_HTTP_CODE=200. 200/304 = no warning; anything else surfaces
|
|
1510
|
+
// as-is.
|
|
1511
|
+
export function formatTunnelReachabilityWarning(httpCode) {
|
|
1512
|
+
if (httpCode === '200' || httpCode === '304') return undefined;
|
|
1513
|
+
return `Tunnel reachable check returned HTTP ${httpCode || '000'} (expected 200/304). The URL may not be serving yet — the final probe decides.`;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1365
1516
|
// Parse the raw stdout of buildDevbridgeExposeScript into structured fields.
|
|
1366
1517
|
// `gatewayTunnelError` marks a URL the s2 gateway itself reports as dead
|
|
1367
1518
|
// (404 "tunnel not found" / "port not configured") — it can never serve content
|
|
@@ -1553,15 +1704,15 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
|
|
|
1553
1704
|
}
|
|
1554
1705
|
}
|
|
1555
1706
|
|
|
1707
|
+
// A2 (#935): the reachability warning uses the FINAL probe's HTTP status —
|
|
1708
|
+
// intermediate 502/000 probes (gateway not ready yet) must not leak into
|
|
1709
|
+
// the verdict, and a sequence of 502/000 → 200 must NOT warn.
|
|
1556
1710
|
if (parsed.badGateway)
|
|
1557
1711
|
warnings.push(
|
|
1558
1712
|
'Tunnel reachable but the gateway reports "bad gateway" — the local service on the exposed port is not serving.',
|
|
1559
1713
|
);
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
`Tunnel reachable check returned HTTP ${parsed.httpCode} (expected 200/304). The URL may not be serving yet.`,
|
|
1563
|
-
);
|
|
1564
|
-
}
|
|
1714
|
+
const reachabilityWarning = formatTunnelReachabilityWarning(parsed.httpCode);
|
|
1715
|
+
if (reachabilityWarning) warnings.push(reachabilityWarning);
|
|
1565
1716
|
const expiresAt =
|
|
1566
1717
|
parsed.expiryHours !== undefined
|
|
1567
1718
|
? new Date(Date.now() + parsed.expiryHours * 3_600_000).toISOString()
|