huaweicloud-devkit 1.1.7 → 1.1.8-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.
Files changed (28) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +2 -1
  4. package/plugins/huaweicloud-core/.claude-plugin/plugin.json +1 -1
  5. package/plugins/huaweicloud-core/.codex-plugin/plugin.json +1 -1
  6. package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +1 -1
  7. package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +1 -1
  8. package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +1 -1
  9. package/plugins/huaweicloud-core/hooks/huaweicloud-safety.py +19 -1
  10. package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
  11. package/plugins/huaweicloud-core/safety/policy.json +1 -1
  12. package/plugins/huaweicloud-core/safety/rules/cloud-risk-rules.json +57 -2
  13. package/plugins/huaweicloud-core/skills/huawei-billing/SKILL.md +19 -16
  14. package/plugins/huaweicloud-core/skills/huawei-modelarts/SKILL.md +1 -1
  15. package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +6 -0
  16. package/plugins/huaweicloud-core/skills/huaweicloud-capability-discovery/SKILL.md +1 -1
  17. package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +7 -1
  18. package/plugins/huaweicloud-core/src/auth/service.mjs +36 -0
  19. package/plugins/huaweicloud-core/src/detect-framework.mjs +41 -5
  20. package/plugins/huaweicloud-core/src/hcloud-cli.mjs +46 -7
  21. package/plugins/huaweicloud-core/src/proxy/proxy-config.mjs +94 -1
  22. package/plugins/huaweicloud-core/src/risk-rule-engine.mjs +1 -1
  23. package/plugins/huaweicloud-core/src/safety-policy.mjs +165 -33
  24. package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +181 -10
  25. package/plugins/huaweicloud-core/src/search-market.mjs +40 -1
  26. package/plugins/huaweicloud-core/src/setup-cli.mjs +34 -0
  27. package/plugins/huaweicloud-core/src/tools.mjs +266 -17
  28. package/rules/huawei-agent-rules.mdc +78 -0
@@ -39,7 +39,96 @@ export function clearProxyConfig() {
39
39
  return true;
40
40
  }
41
41
 
42
- function shouldBypassProxy(hostname, noProxyList) {
42
+ function ipv4ToInt(ip) {
43
+ const parts = ip.split('.');
44
+ if (parts.length !== 4) return null;
45
+ let n = 0;
46
+ for (const part of parts) {
47
+ if (!/^\d{1,3}$/.test(part)) return null;
48
+ const v = Number(part);
49
+ if (v > 255) return null;
50
+ n = n * 256 + v;
51
+ }
52
+ return BigInt(n);
53
+ }
54
+
55
+ function ipv6ToBigInt(ip) {
56
+ let addr = ip.toLowerCase();
57
+ if (addr === '::') addr = '0:0:0:0:0:0:0:0';
58
+ const [head, tail] = addr.split('::');
59
+ const headGroups = head ? head.split(':').filter(Boolean) : [];
60
+ const tailGroups = tail ? tail.split(':').filter(Boolean) : [];
61
+ if (addr.includes('::')) {
62
+ const missing = 8 - headGroups.length - tailGroups.length;
63
+ if (missing < 0) return null;
64
+ const groups = [...headGroups, ...Array(missing).fill('0'), ...tailGroups];
65
+ if (groups.length !== 8) return null;
66
+ return groupsToBigInt(groups);
67
+ }
68
+ const groups = head ? head.split(':') : [];
69
+ if (groups.length !== 8) return null;
70
+ return groupsToBigInt(groups);
71
+ }
72
+
73
+ function groupsToBigInt(groups) {
74
+ let n = 0n;
75
+ for (const g of groups) {
76
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
77
+ n = (n << 16n) | BigInt(parseInt(g, 16));
78
+ }
79
+ return n;
80
+ }
81
+
82
+ function ipv4MappedV6ToBigInt(ip) {
83
+ // ::ffff:a.b.c.d — IPv4-mapped IPv6. Compare against IPv4 CIDR too.
84
+ const m = ip.match(/^::ffff:([0-9.]+)$/i);
85
+ if (!m) return null;
86
+ const v4 = ipv4ToInt(m[1]);
87
+ if (v4 === null) return null;
88
+ return v4;
89
+ }
90
+
91
+ function parseCidr(pattern) {
92
+ const slash = pattern.lastIndexOf('/');
93
+ if (slash < 0) return null;
94
+ const ipPart = pattern.slice(0, slash);
95
+ const maskPart = pattern.slice(slash + 1);
96
+ if (!/^\d{1,3}$/.test(maskPart)) return null;
97
+ const prefix = Number(maskPart);
98
+ if (ipPart.includes(':')) {
99
+ const mapped = ipv4MappedV6ToBigInt(ipPart);
100
+ if (mapped !== null) {
101
+ if (prefix < 0 || prefix > 32) return null;
102
+ const mask = prefix === 0 ? 0n : ((1n << 32n) - 1n) ^ ((1n << (32n - BigInt(prefix))) - 1n);
103
+ return { bits: 32n, base: mapped, mask };
104
+ }
105
+ const base = ipv6ToBigInt(ipPart);
106
+ if (base === null) return null;
107
+ if (prefix < 0 || prefix > 128) return null;
108
+ const mask = prefix === 0 ? 0n : ((1n << 128n) - 1n) ^ ((1n << (128n - BigInt(prefix))) - 1n);
109
+ return { bits: 128n, base, mask };
110
+ }
111
+ const base = ipv4ToInt(ipPart);
112
+ if (base === null) return null;
113
+ if (prefix < 0 || prefix > 32) return null;
114
+ const mask = prefix === 0 ? 0n : ((1n << 32n) - 1n) ^ ((1n << (32n - BigInt(prefix))) - 1n);
115
+ return { bits: 32n, base, mask };
116
+ }
117
+
118
+ function ipInCidr(hostname, cidr) {
119
+ if (cidr.bits === 32n) {
120
+ const v4 = ipv4ToInt(hostname);
121
+ if (v4 !== null) return (v4 & cidr.mask) === (cidr.base & cidr.mask);
122
+ const mapped = ipv4MappedV6ToBigInt(hostname);
123
+ if (mapped !== null) return (mapped & cidr.mask) === (cidr.base & cidr.mask);
124
+ return false;
125
+ }
126
+ const v6 = ipv6ToBigInt(hostname);
127
+ if (v6 !== null) return (v6 & cidr.mask) === (cidr.base & cidr.mask);
128
+ return false;
129
+ }
130
+
131
+ export function shouldBypassProxy(hostname, noProxyList) {
43
132
  if (!noProxyList.length) return false;
44
133
  const lower = hostname.toLowerCase();
45
134
  for (const pattern of noProxyList) {
@@ -47,6 +136,10 @@ function shouldBypassProxy(hostname, noProxyList) {
47
136
  if (!p) continue;
48
137
  if (p === lower) return true;
49
138
  if (p === '*') return true;
139
+ if (p.includes('/')) {
140
+ const cidr = parseCidr(p);
141
+ if (cidr && ipInCidr(lower, cidr)) return true;
142
+ }
50
143
  if (p.startsWith('*.')) {
51
144
  const domain = p.slice(1);
52
145
  if (lower.endsWith(domain) || lower === p.slice(2)) return true;
@@ -19,7 +19,7 @@ export function loadRiskRules(options = {}) {
19
19
  function redactEvidence(text) {
20
20
  return String(text)
21
21
  .replace(
22
- /((?:access[_-]?key|secret[_-]?key|security[_-]?token|x[_-]?auth[_-]?token|token|authorization|password|passwd|admin[_-]?pass|credential)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi,
22
+ /((?:access[_-]?key|secret[_-]?key|secret|security[_-]?token|x[_-]?auth[_-]?token|token|authorization|password|passwd|admin[_-]?pass|credential)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi,
23
23
  '$1<redacted>',
24
24
  )
25
25
  .replace(/(AK|SK)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)/g, '$1=<redacted>');
@@ -67,8 +67,9 @@ export function redactSecrets(value, policy = DEFAULT_POLICY) {
67
67
  function stripExecutable(args) {
68
68
  if (!args.length) return [];
69
69
  let current = args;
70
- // Unwrap shell wrappers (bash -c / sh -c 'hcloud ...', sudo hcloud ...) so
71
- // wrapped write commands keep their deny classification (#650 D4-16).
70
+ // Unwrap shell wrappers (bash -c / sh -c 'hcloud ...', sudo hcloud ...,
71
+ // cmd /c "hcloud ...", powershell -Command "hcloud ...") so wrapped write
72
+ // commands keep their deny classification (#650 D4-16, #731 / #760).
72
73
  for (let depth = 0; depth < 5; depth++) {
73
74
  const first = String(current[0]).toLowerCase();
74
75
  const isShell = [
@@ -87,6 +88,22 @@ function stripExecutable(args) {
87
88
  current = splitSimpleCommand(current[2]);
88
89
  continue;
89
90
  }
91
+ // Windows: cmd /c "…" / cmd /k "…" / cmd.exe /c "…"
92
+ if (first === 'cmd' || first === 'cmd.exe') {
93
+ const flag = String(current[1] || '').toLowerCase();
94
+ if ((flag === '/c' || flag === '/k') && current[2]) {
95
+ current = splitSimpleCommand(current[2]);
96
+ continue;
97
+ }
98
+ }
99
+ // Windows: powershell -Command "…" / powershell -c "…" / pwsh -Command "…"
100
+ if (first === 'powershell' || first === 'powershell.exe' || first === 'pwsh' || first === 'pwsh.exe') {
101
+ const flag = String(current[1] || '').toLowerCase();
102
+ if ((flag === '-command' || flag === '-c') && current[2]) {
103
+ current = splitSimpleCommand(current[2]);
104
+ continue;
105
+ }
106
+ }
90
107
  if (first === 'sudo' && current.length > 1) {
91
108
  current = current.slice(1);
92
109
  continue;
@@ -237,7 +254,7 @@ export function classifyHcloudArgs(args, options = {}) {
237
254
  };
238
255
  }
239
256
 
240
- if (/secret[_-]?string|secret[_-]?binary|showsecretversion|getsecretvalue/i.test(joined)) {
257
+ if (/secret[_-]?string|secret[_-]?binary|showsecret|showsecretversion|getsecretvalue/i.test(joined)) {
241
258
  return {
242
259
  decision: 'deny',
243
260
  risk: 'secret',
@@ -381,55 +398,170 @@ function splitSimpleCommand(command) {
381
398
  );
382
399
  }
383
400
 
401
+ // Extract inner command text from shell wrappers: bash -c "…", sh -c '…',
402
+ // eval "…", $(…), `…`, Windows cmd/powershell. Returns an array of extracted
403
+ // inner strings (may include nested extractions). This lets classifyTextCommand
404
+ // inspect the actual payload instead of the wrapper tokens (#758 D4-16).
405
+ export function extractInnerCommand(text, depth = 0) {
406
+ if (depth > 3) return [];
407
+ const t = String(text || '');
408
+ const results = [];
409
+ let m;
410
+
411
+ // bash -c "…" / sh -c '…' / /bin/bash -c "…" / bash.exe -c '…'
412
+ const shellWrap = /\b(?:bash|sh|zsh|dash|\/bin\/(?:bash|sh|zsh|dash)|bash\.exe|sh\.exe)\s+-c\s+(["'])([\s\S]*?)\1/gi;
413
+ while ((m = shellWrap.exec(t)) !== null) {
414
+ if (m[2]) {
415
+ results.push(m[2]);
416
+ results.push(...extractInnerCommand(m[2], depth + 1));
417
+ }
418
+ }
419
+
420
+ // eval "…" / eval '…'
421
+ const evalWrap = /\beval\s+(["'])([\s\S]*?)\1/gi;
422
+ while ((m = evalWrap.exec(t)) !== null) {
423
+ if (m[2]) {
424
+ results.push(m[2]);
425
+ results.push(...extractInnerCommand(m[2], depth + 1));
426
+ }
427
+ }
428
+
429
+ // $(…) command substitution (single-level; nested parens are a known limitation)
430
+ const cmdSubst = /\$\(([^)]+)\)/g;
431
+ while ((m = cmdSubst.exec(t)) !== null) {
432
+ if (m[1]) {
433
+ results.push(m[1]);
434
+ results.push(...extractInnerCommand(m[1], depth + 1));
435
+ }
436
+ }
437
+
438
+ // `…` backtick command substitution (POSIX sh). Treated the same as $(…):
439
+ // the inner text is extracted as an independent candidate and recursed
440
+ // into so that `hcloud ECS DeleteServer …` is classified correctly (#758).
441
+ const backtickSubst = /`([^`]+)`/g;
442
+ while ((m = backtickSubst.exec(t)) !== null) {
443
+ if (m[1]) {
444
+ results.push(m[1]);
445
+ results.push(...extractInnerCommand(m[1], depth + 1));
446
+ }
447
+ }
448
+
449
+ // Windows: cmd /c "…" / cmd /k '…' / cmd.exe /c "…" (#731 / #760).
450
+ // /c runs and terminates; /k runs and keeps the shell — both can wrap hcloud.
451
+ const cmdWrap = /\b(?:cmd|cmd\.exe)\s+\/[ck]\s+(["'])([\s\S]*?)\1/gi;
452
+ while ((m = cmdWrap.exec(t)) !== null) {
453
+ if (m[2]) {
454
+ results.push(m[2]);
455
+ results.push(...extractInnerCommand(m[2], depth + 1));
456
+ }
457
+ }
458
+
459
+ // Windows: powershell -Command "…" / powershell -c '…' / pwsh -Command "…"
460
+ // / pwsh.exe -c "…" (#731 / #760). Covers both Windows PowerShell and
461
+ // PowerShell Core (pwsh). The -Command/-c flag executes the quoted string.
462
+ const psWrap = /\b(?:powershell|powershell\.exe|pwsh|pwsh\.exe)\s+(?:-Command|-c)\s+(["'])([\s\S]*?)\1/gi;
463
+ while ((m = psWrap.exec(t)) !== null) {
464
+ if (m[2]) {
465
+ results.push(m[2]);
466
+ results.push(...extractInnerCommand(m[2], depth + 1));
467
+ }
468
+ }
469
+
470
+ return results;
471
+ }
472
+
473
+ // Find the hcloud keyword in a command position (string start, after shell
474
+ // separators ; | &, after command-substitution openings $( `, or after sudo).
475
+ // Excludes hcloud mentioned in natural-language/echo text to avoid false-
476
+ // positive deny/write on documentation text (#758 D4-11 P1 fix). Candidates
477
+ // from extractInnerCommand (shell wrappers) start with hcloud directly so
478
+ // ^hcloud still catches wrapped commands and prompt-injections using
479
+ // separators. sudo\s+ preserves the dev-baseline behaviour where
480
+ // `sudo hcloud …` is treated as a real command, not documentation (#760 P0).
481
+ function extractHcloudSubcommand(text) {
482
+ const t = String(text || '');
483
+ const match = /(?:^|[;|&]\s*|\$\(\s*|`\s*|sudo\s+)(hcloud(?:\.exe)?\b)/i.exec(t);
484
+ if (!match) return null;
485
+ const hcloudIndex = match.index + match[0].length - match[1].length;
486
+ return t.slice(hcloudIndex);
487
+ }
488
+
384
489
  export function classifyTextCommand(command, options = {}) {
385
490
  const policy = options.policy || DEFAULT_POLICY;
386
491
  const text = String(command || '');
387
492
 
388
- if (matchesAny(text, policy.credentialFilePatterns)) {
389
- return {
390
- decision: 'deny',
391
- risk: 'credential',
392
- reason:
393
- 'Reading Huawei Cloud credential or profile files is blocked. Use redacted profile inspection tools instead.',
394
- };
493
+ // Extract inner commands from shell wrappers (bash -c, sh -c, eval, $()).
494
+ // Each candidate (original + extracted inners) is checked independently so a
495
+ // wrapped credential dump or hcloud write is caught even when the wrapper
496
+ // text alone doesn't match (#758 D4-16, also covers #682 env-dump vector).
497
+ const candidates = [text, ...extractInnerCommand(text)];
498
+
499
+ // Credential file reads — check all candidates.
500
+ for (const candidate of candidates) {
501
+ if (matchesAny(candidate, policy.credentialFilePatterns)) {
502
+ return {
503
+ decision: 'deny',
504
+ risk: 'credential',
505
+ reason:
506
+ 'Reading Huawei Cloud credential or profile files is blocked. Use redacted profile inspection tools instead.',
507
+ };
508
+ }
395
509
  }
396
510
 
397
- if (
398
- /(^|\s)(env|printenv|Get-ChildItem\s+Env:|gci\s+Env:|dir\s+Env:)/i.test(text) &&
399
- /HUAWEICLOUD|HWC_|HCLOUD|OS_/i.test(text)
400
- ) {
401
- return {
402
- decision: 'deny',
403
- risk: 'credential',
404
- reason: 'Dumping cloud credential environment variables is blocked.',
405
- };
511
+ // Env var dumps — check all candidates (covers bash -c "env | grep HUAWEICLOUD", #682).
512
+ for (const candidate of candidates) {
513
+ if (
514
+ /(^|\s)(env|printenv|Get-ChildItem\s+Env:|gci\s+Env:|dir\s+Env:)/i.test(candidate) &&
515
+ /HUAWEICLOUD|HWC_|HW_|HCLOUD|OS_/i.test(candidate)
516
+ ) {
517
+ return {
518
+ decision: 'deny',
519
+ risk: 'credential',
520
+ reason: 'Dumping cloud credential environment variables is blocked.',
521
+ };
522
+ }
406
523
  }
407
524
 
408
525
  // Credential variable references bypass the env-command gate above: HW_ is
409
526
  // the plugin's own documented credential prefix (HW_ACCESS_KEY/HW_SECRET_KEY/
410
- // HW_SECURITY_TOKEN), and `echo $HW_SECRET_KEY` / `printenv HW_ACCESS_KEY`
411
- // previously fell through to allow (#650 D4-2).
527
+ // HW_SECURITY_TOKEN), cloud environments also expose the AWS-style
528
+ // HUAWEICLOUD_SECRET_ACCESS_KEY name, and `echo $HW_SECRET_KEY` /
529
+ // `printenv HW_ACCESS_KEY` / `echo $HUAWEICLOUD_SECRET_ACCESS_KEY` previously
530
+ // fell through to allow (#650 D4-2, #770 D4-2).
412
531
  //
413
532
  // The negative lookbehind exempts literal-NAME references — backslash-escaped
414
533
  // (`\$HW_*`) or single-quoted (`'$HW_*'`, which the shell never expands) —
415
534
  // while unescaped `$HW_*` is a potential expansion/dump regardless of the
416
535
  // command. No command-name whitelist, so no false negative (#650 review).
417
- if (
418
- /(?<!['\\])\$\{?(?:HUAWEICLOUD|HWC|HW|OS)_(?:ACCESS_KEY|SECRET_KEY|SECURITY_TOKEN)/i.test(text) ||
419
- /(?:^|\s)printenv\s+(?:HUAWEICLOUD|HWC|HW|OS)_(?:ACCESS_KEY|SECRET_KEY|SECURITY_TOKEN)/i.test(text)
420
- ) {
421
- return {
422
- decision: 'deny',
423
- risk: 'credential',
424
- reason: 'Printing cloud credential environment variables is blocked.',
425
- };
536
+ for (const candidate of candidates) {
537
+ if (
538
+ /(?<!['\\])\$\{?(?:HUAWEICLOUD|HWC|HW|OS)_(?:SECRET_ACCESS_KEY|ACCESS_KEY|SECRET_KEY|SECURITY_TOKEN)/i.test(
539
+ candidate,
540
+ ) ||
541
+ /(?:^|\s)printenv\s+(?:HUAWEICLOUD|HWC|HW|OS)_(?:SECRET_ACCESS_KEY|ACCESS_KEY|SECRET_KEY|SECURITY_TOKEN)/i.test(
542
+ candidate,
543
+ )
544
+ ) {
545
+ return {
546
+ decision: 'deny',
547
+ risk: 'credential',
548
+ reason: 'Printing cloud credential environment variables is blocked.',
549
+ };
550
+ }
426
551
  }
427
552
 
428
- if (/(^|\s)hcloud(\.exe)?\s+/i.test(text)) {
429
- return classifyHcloudArgs(splitSimpleCommand(text), { ...options, rawCommand: text });
553
+ // hcloud command detection — extract the subcommand starting from the hcloud
554
+ // keyword so prompt-injection prefixes ("Ignore previous instructions and
555
+ // hcloud …") and shell wrappers ("bash -c \"hcloud …\"") are classified
556
+ // correctly (#758 D4-16 + D4-11). Check all candidates (original + inners).
557
+ for (const candidate of candidates) {
558
+ const hcloudSubcommand = extractHcloudSubcommand(candidate);
559
+ if (hcloudSubcommand) {
560
+ return classifyHcloudArgs(splitSimpleCommand(hcloudSubcommand), { ...options, rawCommand: text });
561
+ }
430
562
  }
431
563
 
432
- if (/ShowSecretVersion|GetSecretValue|secret_string|secret_binary/i.test(text)) {
564
+ if (/ShowSecretVersion|ShowSecret|GetSecretValue|secret_string|secret_binary/i.test(text)) {
433
565
  return {
434
566
  decision: 'deny',
435
567
  risk: 'secret',
@@ -913,17 +913,25 @@ export async function deployCheck(
913
913
  const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
914
914
  const isCrossPlatform = frameworkType === 'cross-platform';
915
915
 
916
- const checkScript = [
916
+ const checkScript = buildDeployCheckScript({ port, project, outputPath, isCrossPlatform });
917
+ const result = await execOneShot(workspaceId, checkScript, username, timeoutMs);
918
+ const stdout = String(result.stdout || '');
919
+ return parseDeployCheckOutput(stdout, { port, isCrossPlatform });
920
+ }
921
+
922
+ export function buildDeployCheckScript({ port, project, outputPath, isCrossPlatform: _isCrossPlatform }) {
923
+ return [
917
924
  `echo "=== DEPLOY CHECK ==="`,
918
925
  `PASS=0`,
919
926
  `TOTAL=0`,
920
927
  ``,
921
928
  `TOTAL=$((TOTAL+1))`,
922
- `if curl -s -o /dev/null -w "%{http_code}" http://localhost:${port} 2>/dev/null | grep -qE "^(2|3)"; then`,
923
- ` echo "nginx_serving:PASS (port ${port})"`,
929
+ `HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:${port} 2>/dev/null)`,
930
+ `if [ -n "$HTTP_CODE" ] && [ "$HTTP_CODE" != "000" ]; then`,
931
+ ` echo "nginx_serving:PASS (port ${port}, HTTP \${HTTP_CODE})"`,
924
932
  ` PASS=$((PASS+1))`,
925
933
  `else`,
926
- ` echo "nginx_serving:FAIL"`,
934
+ ` echo "nginx_serving:FAIL (port ${port}, no HTTP response)"`,
927
935
  `fi`,
928
936
  ``,
929
937
  `TOTAL=$((TOTAL+1))`,
@@ -972,12 +980,13 @@ export async function deployCheck(
972
980
  `TUNNEL_URL="https://\${TUNNEL_ID}-${port}.${DEVBRIDGE_TUNNEL_DOMAIN}"`,
973
981
  `probe_tunnel() {`,
974
982
  ` local url="$1" code`,
975
- ` code=$(curl -s -o /tmp/.dc_tunnel_body -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || echo "000")`,
983
+ ` code=$(curl -s -o /tmp/.dc_tunnel_body -w "%{http_code}" --max-time 10 "$url" 2>/dev/null)`,
976
984
  ` [ "$code" = "000" ] && return 1`,
977
985
  ` # A migrated gateway serves a placeholder page with HTTP 200 — treat it as unreachable.`,
978
986
  ` if grep -q "${DEVBRIDGE_MIGRATION_MARKER}" /tmp/.dc_tunnel_body 2>/dev/null; then return 1; fi`,
987
+ ` # Any non-000 HTTP code means the tunnel is connected and nginx is serving`,
988
+ ` # (404/502 = content issue, not a connectivity issue — consistent with nginx_serving).`,
979
989
  ` rm -f /tmp/.dc_tunnel_body`,
980
- ` [ "$code" = "200" ] || [ "$code" = "304" ] || return 1`,
981
990
  ` return 0`,
982
991
  `}`,
983
992
  `if [ -n "$TUNNEL_ID" ] && probe_tunnel "$TUNNEL_URL"; then`,
@@ -1031,16 +1040,18 @@ fi
1031
1040
  ]
1032
1041
  .filter(Boolean)
1033
1042
  .join('\n');
1043
+ }
1034
1044
 
1035
- const result = await execOneShot(workspaceId, checkScript, username, timeoutMs);
1036
- const stdout = String(result.stdout || '');
1045
+ export function parseDeployCheckOutput(stdout, { port, isCrossPlatform } = {}) {
1037
1046
  // Strip ANSI escape sequences (CSI color/cursor codes and OSC shell-integration markers)
1038
1047
  // and split on any line-ending style (\r\n, \r, or \n) to handle all terminal outputs.
1039
1048
  // Use new RegExp to avoid ESLint no-control-regex on literal control chars in regex.
1040
1049
  const ESC = '\x1b';
1041
1050
  const csiRe = new RegExp(ESC + '\\[[0-9;]*[a-zA-Z]', 'g');
1042
1051
  const oscRe = new RegExp(ESC + '\\][^' + ESC + '\x07]*(?:\x07|' + ESC + '\\\\)', 'g');
1043
- const cleanStdout = stdout.replace(csiRe, '').replace(oscRe, '');
1052
+ const cleanStdout = String(stdout || '')
1053
+ .replace(csiRe, '')
1054
+ .replace(oscRe, '');
1044
1055
  const checks = {};
1045
1056
  const lines = cleanStdout.split(new RegExp('\\r\\n|\\r|\\n'));
1046
1057
  for (const line of lines) {
@@ -1086,7 +1097,7 @@ fi
1086
1097
  publicUrl: tunnelMatch ? tunnelMatch[1] : undefined,
1087
1098
  missingSteps: missing.length > 0 ? missing.join(', ') : undefined,
1088
1099
  parseWarning,
1089
- rawOutput: parseWarning ? stdout.trim() : undefined,
1100
+ rawOutput: parseWarning ? String(stdout || '').trim() : undefined,
1090
1101
  nextStep: nextStepValue,
1091
1102
  remediation: nextStepValue === 'expose_via_devbridge' ? buildExposeRemediation(port) : undefined,
1092
1103
  };
@@ -1104,6 +1115,166 @@ export async function closeSession(workspaceId, username) {
1104
1115
  return true;
1105
1116
  }
1106
1117
 
1118
+ // Build a shell snippet that authenticates devbridge by probing the binary build:
1119
+ // image builds (2026-09+) keep `--access-key/--secret-key`, release builds accept
1120
+ // only `--api-key`. Uses injected files written by huaweicloud_sandbox_credentials
1121
+ // (/tmp/hw_creds.sh for AK/SK, /tmp/hw_api_key for the long-lived API Key).
1122
+ export function buildDevbridgeAuthProbe() {
1123
+ return [
1124
+ `export PATH="$HOME/.huawei/bin:$PATH"`,
1125
+ `source /tmp/hw_creds.sh 2>/dev/null`,
1126
+ `if devbridge auth login --help 2>&1 | grep -q -- '--access-key'; then`,
1127
+ ` echo "DB_AUTH_MODE=AKSK_SUPPORTED"`,
1128
+ ` devbridge auth login --access-key "$HW_ACCESS_KEY" --secret-key "$HW_SECRET_KEY" > /tmp/db_auth.log 2>&1`,
1129
+ `else`,
1130
+ ` echo "DB_AUTH_MODE=API_KEY_ONLY"`,
1131
+ ` source /tmp/hw_api_key 2>/dev/null`,
1132
+ ` if [ -n "$HW_API_KEY" ]; then`,
1133
+ ` devbridge auth login --api-key "$HW_API_KEY" > /tmp/db_auth.log 2>&1`,
1134
+ ` else`,
1135
+ ` echo "DB_AUTH_MODE=NO_API_KEY"`,
1136
+ ` fi`,
1137
+ `fi`,
1138
+ `devbridge auth status > /tmp/db_status.log 2>&1 || true`,
1139
+ ].join('\n');
1140
+ }
1141
+
1142
+ // Start (or replace) a devbridge host bound to a single port, extract the public
1143
+ // URL, and health-check it. Mirrors the huaweicloud-sandbox skill's Expose flow:
1144
+ // pre-clean stale tunnels (quota 10006), start host in background, read
1145
+ // "Tunnel URL: ..." from /tmp/host.log, curl-check, retry once on unreachable.
1146
+ export function buildDevbridgeExposeScript(port) {
1147
+ const hostLog = '/tmp/host.log';
1148
+ return [
1149
+ `pkill -f "devbridge host" 2>/dev/null || true`,
1150
+ `sleep 2`,
1151
+ `devbridge delete-all > /dev/null 2>&1 || true`,
1152
+ `nohup devbridge host -p ${port} -e 8 > ${hostLog} 2>&1 &`,
1153
+ `sleep 12`,
1154
+ `TUNNEL_URL=$(sed -n 's/.*Tunnel URL: *//p' ${hostLog} | tail -1)`,
1155
+ `HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
1156
+ `echo "DB_HOST_LOG="`,
1157
+ `tail -n 5 ${hostLog}`,
1158
+ `echo "DB_TUNNEL_URL=$TUNNEL_URL"`,
1159
+ `echo "DB_HTTP_CODE=$HTTP_CODE"`,
1160
+ `echo "DB_QUOTA_ERROR=$(grep -c '10006' ${hostLog} 2>/dev/null || echo 0)"`,
1161
+ ].join('\n');
1162
+ }
1163
+
1164
+ // Parse the raw stdout of buildDevbridgeExposeScript into structured fields.
1165
+ export function parseDevbridgeExposeOutput(stdout) {
1166
+ const text = String(stdout || '');
1167
+ const getField = (key) => {
1168
+ const m = text.match(new RegExp(`^${key}=(.*)$`, 'm'));
1169
+ return m ? m[1].trim() : '';
1170
+ };
1171
+ const tunnelUrl = getField('DB_TUNNEL_URL') || (text.match(/TUNNEL_URL:(https:\/\/[^\s]+)/) || [])[1] || '';
1172
+ const httpCode = getField('DB_HTTP_CODE');
1173
+ const quotaError = getField('DB_QUOTA_ERROR') === '1' || /10006/.test(text);
1174
+ const tunnelId = (tunnelUrl.match(/^https:\/\/([A-Za-z0-9_-]+)-\d+\./) || [])[1] || '';
1175
+ return { tunnelUrl, httpCode, quotaError, tunnelId };
1176
+ }
1177
+
1178
+ // Parse and validate a TCP port number destined for shell interpolation. Returns
1179
+ // the integer when 1..65535; throws otherwise. Guards the `-p ${port}` shell
1180
+ // splice in buildDevbridgeExposeScript against non-numeric / out-of-range input.
1181
+ export function validateTunnelPort(port) {
1182
+ const n = Number(port);
1183
+ if (!Number.isSafeInteger(n) || n < 1 || n > 65535) {
1184
+ throw new Error(
1185
+ `sandbox expose tunnel: invalid port ${JSON.stringify(port)}. Expected an integer between 1 and 65535 (use the "port" value returned by sandbox_deploy_nginx).`,
1186
+ );
1187
+ }
1188
+ return n;
1189
+ }
1190
+
1191
+ // Expose a sandbox port to a public DevBridge tunnel URL. `port` MUST be the
1192
+ // ACTUAL port reported by deployNginx (deploy_nginx auto-increments on conflict),
1193
+ // otherwise the tunnel is bound to a dead port. Returns ok, publicUrl, port,
1194
+ // tunnelId, auth mode, and warnings.
1195
+ export async function exposeTunnel(workspaceId, { port }, username = 'root', timeoutMs = 90000) {
1196
+ if (!workspaceId) {
1197
+ throw new Error('sandbox expose tunnel: workspace_id is required.');
1198
+ }
1199
+ const validatedPort = validateTunnelPort(port);
1200
+
1201
+ const authScript = buildDevbridgeAuthProbe();
1202
+ const authResult = await execOneShot(workspaceId, authScript, username, 30000);
1203
+ const authModes = String(authResult.stdout || '').match(/DB_AUTH_MODE=([A-Z_]+)/g) || [];
1204
+ const authMode = authModes.length > 0 ? authModes[authModes.length - 1].split('=')[1] : '';
1205
+
1206
+ if (authMode === 'NO_API_KEY' || (!authMode && !String(authResult.stdout || '').includes('AKSK_SUPPORTED'))) {
1207
+ const loginOutput = String(authResult.stdout || '') + String(authResult.stderr || '');
1208
+ return {
1209
+ ok: false,
1210
+ error: 'DevBridge authentication failed: the release build requires an API Key that has not been injected.',
1211
+ authMode: authMode || 'UNKNOWN',
1212
+ hint:
1213
+ 'Create an API Key at https://devstation.connect.huaweicloud.com/space/devbridge/apikey, set it locally via ' +
1214
+ '`export HW_API_KEY=<key>`, then re-run huaweicloud_sandbox_credentials so it is injected into the sandbox. ' +
1215
+ 'Then call huaweicloud_sandbox_expose_tunnel again.',
1216
+ loginOutput,
1217
+ };
1218
+ }
1219
+
1220
+ let result = null;
1221
+ let attempt = 0;
1222
+ const maxAttempts = 2;
1223
+ while (attempt < maxAttempts) {
1224
+ attempt += 1;
1225
+ const exposeScript = buildDevbridgeExposeScript(validatedPort);
1226
+ result = await execOneShot(workspaceId, exposeScript, username, timeoutMs);
1227
+ const parsed = parseDevbridgeExposeOutput(result.stdout);
1228
+
1229
+ // 10006 quota exceeded: clean up stale tunnels and retry.
1230
+ if (parsed.quotaError && attempt < maxAttempts) {
1231
+ continue;
1232
+ }
1233
+ // Unreachable (000) or a migrated-gateway placeholder: one retry.
1234
+ const unreachable = parsed.httpCode === '000' || parsed.httpCode === '';
1235
+ const migration = /服务已迁移/.test(String(result.stdout || ''));
1236
+ if (unreachable || migration) {
1237
+ if (attempt === 1) {
1238
+ try {
1239
+ await execOneShot(
1240
+ workspaceId,
1241
+ `pkill -f "devbridge host" 2>/dev/null || true; sleep 2; devbridge delete-all > /dev/null 2>&1 || true`,
1242
+ username,
1243
+ 20000,
1244
+ );
1245
+ } catch {}
1246
+ continue;
1247
+ }
1248
+ }
1249
+
1250
+ const warnings = [];
1251
+ if (parsed.quotaError)
1252
+ warnings.push('DevBridge tunnel quota (10006) hit — stale tunnels were cleaned up and the host was retried.');
1253
+ if (parsed.httpCode !== '200' && parsed.httpCode !== '304') {
1254
+ warnings.push(
1255
+ `Tunnel reachable check returned HTTP ${parsed.httpCode} (expected 200/304). The URL may not be serving yet.`,
1256
+ );
1257
+ }
1258
+ return {
1259
+ ok: true,
1260
+ publicUrl: parsed.tunnelUrl || undefined,
1261
+ port: validatedPort,
1262
+ tunnelId: parsed.tunnelId || undefined,
1263
+ authMode,
1264
+ httpCode: parsed.httpCode,
1265
+ warnings: warnings.length > 0 ? warnings.join(' ') : undefined,
1266
+ hostLog: String(result.stdout || '').slice(0, 400),
1267
+ };
1268
+ }
1269
+
1270
+ return {
1271
+ ok: false,
1272
+ error: 'Failed to expose a working DevBridge tunnel after retries.',
1273
+ authMode,
1274
+ hostLog: String(result?.stdout || '').slice(0, 400),
1275
+ };
1276
+ }
1277
+
1107
1278
  export async function closeAllSessions() {
1108
1279
  for (const [key, session] of sessions) {
1109
1280
  sessions.delete(key);
@@ -152,6 +152,44 @@ function truncate(desc, limit = 150) {
152
152
  return desc.length > limit ? desc.slice(0, limit) + '...' : desc;
153
153
  }
154
154
 
155
+ // Build a git-free install command: download the skills repo ZIP over HTTP,
156
+ // extract it, locate the skill folder by name, and copy it into the caller's
157
+ // agent skills directory. `npx skills add ...` requires git on PATH, which is
158
+ // absent on stripped Windows Server / sandbox environments (#F). When even HTTP
159
+ // download fails the returned fallback notes guide a manual download instead of
160
+ // trying to install git.
161
+ export function buildSkillInstallCommand(skill, skillsDirHint = '<your-agent-skills-dir>') {
162
+ const esc = (s) =>
163
+ String(s || '')
164
+ .replace(/\./g, '-')
165
+ .replace(/[^A-Za-z0-9_-]/g, '-');
166
+ const name = esc(skill.name);
167
+ const rel = skill.category && skill.service ? `skills/${esc(skill.category)}/${esc(skill.service)}/${name}` : null;
168
+ const findSrc = rel
169
+ ? `SKILL_SRC="/tmp/hw-skills-net/huaweicloud-skills-master/${rel}"`
170
+ : `SKILL_SRC=$(find /tmp/hw-skills-net/huaweicloud-skills-master/skills -type d -name "${name}" 2>/dev/null | head -1)`;
171
+ // P4: the skills-dir hint is caller-supplied and spliced into shell — quote it
172
+ // and strip shell metacharacters so a hostile/broken hint cannot inject args.
173
+ const dirEsc = String(skillsDirHint || '').replace(/[^A-Za-z0-9_./~-]/g, '-');
174
+ const targetDir = dirEsc === '' ? '<your-agent-skills-dir>' : dirEsc;
175
+ return [
176
+ '(curl -fsSL https://gitcode.com/huaweicloud/huaweicloud-skills/-/archive/master/huaweicloud-skills-master.zip -o /tmp/hw-skills.zip && unzip -oq /tmp/hw-skills.zip -d /tmp/hw-skills-net) || (curl -fsSL https://github.com/huaweicloud/huaweicloud-skills/archive/refs/heads/master.zip -o /tmp/hw-skills.zip && unzip -oq /tmp/hw-skills.zip -d /tmp/hw-skills-net)',
177
+ `&& ${findSrc}`,
178
+ // P5: short-circuit the && chain on a missing / non-skill folder — `echo`
179
+ // exits 0, so the branch must end with `false` or cp -r would still run.
180
+ `&& (test -d "$SKILL_SRC" || { echo "SKILL_SRC_NOT_FOUND"; false; })`,
181
+ `&& (test -f "$SKILL_SRC/SKILL.md" || { echo "SKILL_SRC_NOT_FOUND"; false; })`,
182
+ `&& mkdir -p "${targetDir}" && cp -r "$SKILL_SRC" "${targetDir}/${name}"`,
183
+ ].join(' ');
184
+ }
185
+
186
+ const INSTALL_DOWNLOAD_HINT =
187
+ 'No git required — installs by downloading the repo ZIP over HTTP and copying the skill folder. ' +
188
+ 'If the command reports SKILL_SRC_NOT_FOUND, replace <your-agent-skills-dir> with this agent\u2019s skills directory ' +
189
+ '(Claude Code ~/.claude/skills, OpenCode ~/.config/opencode/skills, Cursor ~/.cursor/skills) and re-run. ' +
190
+ 'If HTTP download itself fails, download https://github.com/huaweicloud/huaweicloud-skills/archive/refs/heads/master.zip ' +
191
+ 'manually, extract it, copy the skill folder, and tell the user how the skill was installed.';
192
+
155
193
  export async function searchMarketplace(query = '', category = '') {
156
194
  const idx = await loadIndex();
157
195
  const cnEnMap = await loadCnEnMap();
@@ -171,7 +209,8 @@ export async function searchMarketplace(query = '', category = '') {
171
209
  description: truncate(skill.description),
172
210
  triggers: (skill.triggers || []).slice(0, 5),
173
211
  matched,
174
- installCommand: `npx skills add huaweicloud/huaweicloud-skills --skill ${skill.name}`,
212
+ installCommand: buildSkillInstallCommand(skill),
213
+ installHint: INSTALL_DOWNLOAD_HINT,
175
214
  });
176
215
  }
177
216
  results.sort((a, b) => b.score - a.score);