huaweicloud-devkit 1.1.7 → 1.1.8-next.1
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 +2 -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/hooks/huaweicloud-safety.py +19 -1
- package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
- package/plugins/huaweicloud-core/safety/policy.json +1 -1
- package/plugins/huaweicloud-core/safety/rules/cloud-risk-rules.json +57 -2
- package/plugins/huaweicloud-core/skills/huawei-billing/SKILL.md +19 -16
- package/plugins/huaweicloud-core/skills/huawei-modelarts/SKILL.md +1 -1
- package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +9 -3
- package/plugins/huaweicloud-core/skills/huaweicloud-capability-discovery/SKILL.md +1 -1
- package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +15 -2
- package/plugins/huaweicloud-core/src/auth/service.mjs +36 -0
- package/plugins/huaweicloud-core/src/data/icons-manifest.v1.json +23 -5
- package/plugins/huaweicloud-core/src/detect-framework.mjs +41 -5
- package/plugins/huaweicloud-core/src/hcloud-cli.mjs +78 -8
- package/plugins/huaweicloud-core/src/hcloud-probe.mjs +10 -2
- package/plugins/huaweicloud-core/src/mcp-server.mjs +9 -0
- package/plugins/huaweicloud-core/src/preflight.mjs +125 -0
- package/plugins/huaweicloud-core/src/proxy/proxy-config.mjs +94 -1
- package/plugins/huaweicloud-core/src/risk-rule-engine.mjs +1 -1
- package/plugins/huaweicloud-core/src/safety-policy.mjs +165 -33
- package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +214 -27
- package/plugins/huaweicloud-core/src/search-market.mjs +40 -1
- package/plugins/huaweicloud-core/src/setup-cli.mjs +57 -17
- package/plugins/huaweicloud-core/src/tools.mjs +307 -20
- package/rules/huawei-agent-rules.mdc +78 -0
|
@@ -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
|
|
71
|
-
//
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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),
|
|
411
|
-
//
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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',
|
|
@@ -198,9 +198,17 @@ export function formatPortDriftWarning(basePort, targetPort) {
|
|
|
198
198
|
return `Port ${basePort} was occupied — nginx now listens on port ${targetPort}. Any DevBridge tunnel bound to port ${basePort} is detached: run "devbridge port create <tunnelId> -p ${targetPort} --protocol http -a" and restart "devbridge host" for the new port.`;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
export function
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
export async function resolveProxyNodePort(nodePort, nginxListenPort, isPortInUse) {
|
|
202
|
+
const baseCandidate = nodePort && nodePort !== nginxListenPort ? nodePort : nginxListenPort + 1;
|
|
203
|
+
let candidate = baseCandidate;
|
|
204
|
+
for (
|
|
205
|
+
let attempt = 0;
|
|
206
|
+
attempt < 10 && (candidate === nginxListenPort || (await isPortInUse(candidate)));
|
|
207
|
+
attempt += 1
|
|
208
|
+
) {
|
|
209
|
+
candidate += 1;
|
|
210
|
+
}
|
|
211
|
+
return candidate;
|
|
204
212
|
}
|
|
205
213
|
|
|
206
214
|
export function buildExposeRemediation(port) {
|
|
@@ -775,7 +783,17 @@ export async function deployNginx(
|
|
|
775
783
|
}
|
|
776
784
|
|
|
777
785
|
const effectiveNodePort =
|
|
778
|
-
nginxType === 'proxy'
|
|
786
|
+
nginxType === 'proxy'
|
|
787
|
+
? await resolveProxyNodePort(nodePort, targetPort, async (candidate) => {
|
|
788
|
+
const portCheck = await execOneShot(
|
|
789
|
+
workspaceId,
|
|
790
|
+
`ss -tlnp 2>/dev/null | grep -q ":${candidate} " && echo "IN_USE" || echo "FREE"`,
|
|
791
|
+
username,
|
|
792
|
+
10000,
|
|
793
|
+
);
|
|
794
|
+
return String(portCheck.stdout || '').includes('IN_USE');
|
|
795
|
+
})
|
|
796
|
+
: undefined;
|
|
779
797
|
|
|
780
798
|
const projectPath = `/workspace/${project}`;
|
|
781
799
|
const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
|
|
@@ -803,7 +821,7 @@ fi`;
|
|
|
803
821
|
}
|
|
804
822
|
}`,
|
|
805
823
|
proxy: `server {
|
|
806
|
-
listen ${
|
|
824
|
+
listen ${targetPort};
|
|
807
825
|
server_name _;
|
|
808
826
|
large_client_header_buffers 4 32k;
|
|
809
827
|
|
|
@@ -874,6 +892,15 @@ fi`;
|
|
|
874
892
|
tunnelActive = String(tunnelCheck.stdout || '').includes('ACTIVE');
|
|
875
893
|
} catch {}
|
|
876
894
|
|
|
895
|
+
const warnings = [];
|
|
896
|
+
if (!tunnelActive) {
|
|
897
|
+
warnings.push('No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.');
|
|
898
|
+
}
|
|
899
|
+
const conflictWarning = formatPortConflictWarning(basePort, targetPort);
|
|
900
|
+
if (conflictWarning) warnings.push(conflictWarning);
|
|
901
|
+
const driftWarning = tunnelActive ? formatPortDriftWarning(basePort, targetPort) : undefined;
|
|
902
|
+
if (driftWarning) warnings.push(driftWarning);
|
|
903
|
+
|
|
877
904
|
return {
|
|
878
905
|
ok: result.exitCode === 0,
|
|
879
906
|
nginxType,
|
|
@@ -884,18 +911,7 @@ fi`;
|
|
|
884
911
|
exitCode: result.exitCode,
|
|
885
912
|
stdout: result.stdout,
|
|
886
913
|
nextStep: 'expose_via_devbridge',
|
|
887
|
-
warning:
|
|
888
|
-
[
|
|
889
|
-
!tunnelActive
|
|
890
|
-
? 'No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.'
|
|
891
|
-
: undefined,
|
|
892
|
-
nginxType === 'proxy'
|
|
893
|
-
? formatProxyPortWarning(basePort, targetPort)
|
|
894
|
-
: formatPortConflictWarning(basePort, targetPort),
|
|
895
|
-
tunnelActive && nginxType !== 'proxy' ? formatPortDriftWarning(basePort, targetPort) : undefined,
|
|
896
|
-
]
|
|
897
|
-
.filter(Boolean)
|
|
898
|
-
.join(' ') || undefined,
|
|
914
|
+
warning: warnings.length > 0 ? warnings.join(' | ') : undefined,
|
|
899
915
|
};
|
|
900
916
|
}
|
|
901
917
|
|
|
@@ -913,17 +929,25 @@ export async function deployCheck(
|
|
|
913
929
|
const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
|
|
914
930
|
const isCrossPlatform = frameworkType === 'cross-platform';
|
|
915
931
|
|
|
916
|
-
const checkScript =
|
|
932
|
+
const checkScript = buildDeployCheckScript({ port, project, outputPath, isCrossPlatform });
|
|
933
|
+
const result = await execOneShot(workspaceId, checkScript, username, timeoutMs);
|
|
934
|
+
const stdout = String(result.stdout || '');
|
|
935
|
+
return parseDeployCheckOutput(stdout, { port, isCrossPlatform });
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export function buildDeployCheckScript({ port, project, outputPath, isCrossPlatform: _isCrossPlatform }) {
|
|
939
|
+
return [
|
|
917
940
|
`echo "=== DEPLOY CHECK ==="`,
|
|
918
941
|
`PASS=0`,
|
|
919
942
|
`TOTAL=0`,
|
|
920
943
|
``,
|
|
921
944
|
`TOTAL=$((TOTAL+1))`,
|
|
922
|
-
`
|
|
923
|
-
`
|
|
945
|
+
`HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:${port} 2>/dev/null)`,
|
|
946
|
+
`if [ -n "$HTTP_CODE" ] && [ "$HTTP_CODE" != "000" ]; then`,
|
|
947
|
+
` echo "nginx_serving:PASS (port ${port}, HTTP \${HTTP_CODE})"`,
|
|
924
948
|
` PASS=$((PASS+1))`,
|
|
925
949
|
`else`,
|
|
926
|
-
` echo "nginx_serving:FAIL"`,
|
|
950
|
+
` echo "nginx_serving:FAIL (port ${port}, no HTTP response)"`,
|
|
927
951
|
`fi`,
|
|
928
952
|
``,
|
|
929
953
|
`TOTAL=$((TOTAL+1))`,
|
|
@@ -972,12 +996,13 @@ export async function deployCheck(
|
|
|
972
996
|
`TUNNEL_URL="https://\${TUNNEL_ID}-${port}.${DEVBRIDGE_TUNNEL_DOMAIN}"`,
|
|
973
997
|
`probe_tunnel() {`,
|
|
974
998
|
` local url="$1" code`,
|
|
975
|
-
` code=$(curl -s -o /tmp/.dc_tunnel_body -w "%{http_code}" --max-time 10 "$url" 2>/dev/null
|
|
999
|
+
` code=$(curl -s -o /tmp/.dc_tunnel_body -w "%{http_code}" --max-time 10 "$url" 2>/dev/null)`,
|
|
976
1000
|
` [ "$code" = "000" ] && return 1`,
|
|
977
1001
|
` # A migrated gateway serves a placeholder page with HTTP 200 — treat it as unreachable.`,
|
|
978
1002
|
` if grep -q "${DEVBRIDGE_MIGRATION_MARKER}" /tmp/.dc_tunnel_body 2>/dev/null; then return 1; fi`,
|
|
1003
|
+
` # Any non-000 HTTP code means the tunnel is connected and nginx is serving`,
|
|
1004
|
+
` # (404/502 = content issue, not a connectivity issue — consistent with nginx_serving).`,
|
|
979
1005
|
` rm -f /tmp/.dc_tunnel_body`,
|
|
980
|
-
` [ "$code" = "200" ] || [ "$code" = "304" ] || return 1`,
|
|
981
1006
|
` return 0`,
|
|
982
1007
|
`}`,
|
|
983
1008
|
`if [ -n "$TUNNEL_ID" ] && probe_tunnel "$TUNNEL_URL"; then`,
|
|
@@ -1031,16 +1056,18 @@ fi
|
|
|
1031
1056
|
]
|
|
1032
1057
|
.filter(Boolean)
|
|
1033
1058
|
.join('\n');
|
|
1059
|
+
}
|
|
1034
1060
|
|
|
1035
|
-
|
|
1036
|
-
const stdout = String(result.stdout || '');
|
|
1061
|
+
export function parseDeployCheckOutput(stdout, { port, isCrossPlatform } = {}) {
|
|
1037
1062
|
// Strip ANSI escape sequences (CSI color/cursor codes and OSC shell-integration markers)
|
|
1038
1063
|
// and split on any line-ending style (\r\n, \r, or \n) to handle all terminal outputs.
|
|
1039
1064
|
// Use new RegExp to avoid ESLint no-control-regex on literal control chars in regex.
|
|
1040
1065
|
const ESC = '\x1b';
|
|
1041
1066
|
const csiRe = new RegExp(ESC + '\\[[0-9;]*[a-zA-Z]', 'g');
|
|
1042
1067
|
const oscRe = new RegExp(ESC + '\\][^' + ESC + '\x07]*(?:\x07|' + ESC + '\\\\)', 'g');
|
|
1043
|
-
const cleanStdout = stdout
|
|
1068
|
+
const cleanStdout = String(stdout || '')
|
|
1069
|
+
.replace(csiRe, '')
|
|
1070
|
+
.replace(oscRe, '');
|
|
1044
1071
|
const checks = {};
|
|
1045
1072
|
const lines = cleanStdout.split(new RegExp('\\r\\n|\\r|\\n'));
|
|
1046
1073
|
for (const line of lines) {
|
|
@@ -1086,7 +1113,7 @@ fi
|
|
|
1086
1113
|
publicUrl: tunnelMatch ? tunnelMatch[1] : undefined,
|
|
1087
1114
|
missingSteps: missing.length > 0 ? missing.join(', ') : undefined,
|
|
1088
1115
|
parseWarning,
|
|
1089
|
-
rawOutput: parseWarning ? stdout.trim() : undefined,
|
|
1116
|
+
rawOutput: parseWarning ? String(stdout || '').trim() : undefined,
|
|
1090
1117
|
nextStep: nextStepValue,
|
|
1091
1118
|
remediation: nextStepValue === 'expose_via_devbridge' ? buildExposeRemediation(port) : undefined,
|
|
1092
1119
|
};
|
|
@@ -1104,6 +1131,166 @@ export async function closeSession(workspaceId, username) {
|
|
|
1104
1131
|
return true;
|
|
1105
1132
|
}
|
|
1106
1133
|
|
|
1134
|
+
// Build a shell snippet that authenticates devbridge by probing the binary build:
|
|
1135
|
+
// image builds (2026-09+) keep `--access-key/--secret-key`, release builds accept
|
|
1136
|
+
// only `--api-key`. Uses injected files written by huaweicloud_sandbox_credentials
|
|
1137
|
+
// (/tmp/hw_creds.sh for AK/SK, /tmp/hw_api_key for the long-lived API Key).
|
|
1138
|
+
export function buildDevbridgeAuthProbe() {
|
|
1139
|
+
return [
|
|
1140
|
+
`export PATH="$HOME/.huawei/bin:$PATH"`,
|
|
1141
|
+
`source /tmp/hw_creds.sh 2>/dev/null`,
|
|
1142
|
+
`if devbridge auth login --help 2>&1 | grep -q -- '--access-key'; then`,
|
|
1143
|
+
` echo "DB_AUTH_MODE=AKSK_SUPPORTED"`,
|
|
1144
|
+
` devbridge auth login --access-key "$HW_ACCESS_KEY" --secret-key "$HW_SECRET_KEY" > /tmp/db_auth.log 2>&1`,
|
|
1145
|
+
`else`,
|
|
1146
|
+
` echo "DB_AUTH_MODE=API_KEY_ONLY"`,
|
|
1147
|
+
` source /tmp/hw_api_key 2>/dev/null`,
|
|
1148
|
+
` if [ -n "$HW_API_KEY" ]; then`,
|
|
1149
|
+
` devbridge auth login --api-key "$HW_API_KEY" > /tmp/db_auth.log 2>&1`,
|
|
1150
|
+
` else`,
|
|
1151
|
+
` echo "DB_AUTH_MODE=NO_API_KEY"`,
|
|
1152
|
+
` fi`,
|
|
1153
|
+
`fi`,
|
|
1154
|
+
`devbridge auth status > /tmp/db_status.log 2>&1 || true`,
|
|
1155
|
+
].join('\n');
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Start (or replace) a devbridge host bound to a single port, extract the public
|
|
1159
|
+
// URL, and health-check it. Mirrors the huaweicloud-sandbox skill's Expose flow:
|
|
1160
|
+
// pre-clean stale tunnels (quota 10006), start host in background, read
|
|
1161
|
+
// "Tunnel URL: ..." from /tmp/host.log, curl-check, retry once on unreachable.
|
|
1162
|
+
export function buildDevbridgeExposeScript(port) {
|
|
1163
|
+
const hostLog = '/tmp/host.log';
|
|
1164
|
+
return [
|
|
1165
|
+
`pkill -f "devbridge host" 2>/dev/null || true`,
|
|
1166
|
+
`sleep 2`,
|
|
1167
|
+
`devbridge delete-all > /dev/null 2>&1 || true`,
|
|
1168
|
+
`nohup devbridge host -p ${port} -e 8 > ${hostLog} 2>&1 &`,
|
|
1169
|
+
`sleep 12`,
|
|
1170
|
+
`TUNNEL_URL=$(sed -n 's/.*Tunnel URL: *//p' ${hostLog} | tail -1)`,
|
|
1171
|
+
`HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
|
|
1172
|
+
`echo "DB_HOST_LOG="`,
|
|
1173
|
+
`tail -n 5 ${hostLog}`,
|
|
1174
|
+
`echo "DB_TUNNEL_URL=$TUNNEL_URL"`,
|
|
1175
|
+
`echo "DB_HTTP_CODE=$HTTP_CODE"`,
|
|
1176
|
+
`echo "DB_QUOTA_ERROR=$(grep -c '10006' ${hostLog} 2>/dev/null || echo 0)"`,
|
|
1177
|
+
].join('\n');
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Parse the raw stdout of buildDevbridgeExposeScript into structured fields.
|
|
1181
|
+
export function parseDevbridgeExposeOutput(stdout) {
|
|
1182
|
+
const text = String(stdout || '');
|
|
1183
|
+
const getField = (key) => {
|
|
1184
|
+
const m = text.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
1185
|
+
return m ? m[1].trim() : '';
|
|
1186
|
+
};
|
|
1187
|
+
const tunnelUrl = getField('DB_TUNNEL_URL') || (text.match(/TUNNEL_URL:(https:\/\/[^\s]+)/) || [])[1] || '';
|
|
1188
|
+
const httpCode = getField('DB_HTTP_CODE');
|
|
1189
|
+
const quotaError = getField('DB_QUOTA_ERROR') === '1' || /10006/.test(text);
|
|
1190
|
+
const tunnelId = (tunnelUrl.match(/^https:\/\/([A-Za-z0-9_-]+)-\d+\./) || [])[1] || '';
|
|
1191
|
+
return { tunnelUrl, httpCode, quotaError, tunnelId };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Parse and validate a TCP port number destined for shell interpolation. Returns
|
|
1195
|
+
// the integer when 1..65535; throws otherwise. Guards the `-p ${port}` shell
|
|
1196
|
+
// splice in buildDevbridgeExposeScript against non-numeric / out-of-range input.
|
|
1197
|
+
export function validateTunnelPort(port) {
|
|
1198
|
+
const n = Number(port);
|
|
1199
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > 65535) {
|
|
1200
|
+
throw new Error(
|
|
1201
|
+
`sandbox expose tunnel: invalid port ${JSON.stringify(port)}. Expected an integer between 1 and 65535 (use the "port" value returned by sandbox_deploy_nginx).`,
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
return n;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Expose a sandbox port to a public DevBridge tunnel URL. `port` MUST be the
|
|
1208
|
+
// ACTUAL port reported by deployNginx (deploy_nginx auto-increments on conflict),
|
|
1209
|
+
// otherwise the tunnel is bound to a dead port. Returns ok, publicUrl, port,
|
|
1210
|
+
// tunnelId, auth mode, and warnings.
|
|
1211
|
+
export async function exposeTunnel(workspaceId, { port }, username = 'root', timeoutMs = 90000) {
|
|
1212
|
+
if (!workspaceId) {
|
|
1213
|
+
throw new Error('sandbox expose tunnel: workspace_id is required.');
|
|
1214
|
+
}
|
|
1215
|
+
const validatedPort = validateTunnelPort(port);
|
|
1216
|
+
|
|
1217
|
+
const authScript = buildDevbridgeAuthProbe();
|
|
1218
|
+
const authResult = await execOneShot(workspaceId, authScript, username, 30000);
|
|
1219
|
+
const authModes = String(authResult.stdout || '').match(/DB_AUTH_MODE=([A-Z_]+)/g) || [];
|
|
1220
|
+
const authMode = authModes.length > 0 ? authModes[authModes.length - 1].split('=')[1] : '';
|
|
1221
|
+
|
|
1222
|
+
if (authMode === 'NO_API_KEY' || (!authMode && !String(authResult.stdout || '').includes('AKSK_SUPPORTED'))) {
|
|
1223
|
+
const loginOutput = String(authResult.stdout || '') + String(authResult.stderr || '');
|
|
1224
|
+
return {
|
|
1225
|
+
ok: false,
|
|
1226
|
+
error: 'DevBridge authentication failed: the release build requires an API Key that has not been injected.',
|
|
1227
|
+
authMode: authMode || 'UNKNOWN',
|
|
1228
|
+
hint:
|
|
1229
|
+
'Create an API Key at https://devstation.connect.huaweicloud.com/space/devbridge/apikey, set it locally via ' +
|
|
1230
|
+
'`export HW_API_KEY=<key>`, then re-run huaweicloud_sandbox_credentials so it is injected into the sandbox. ' +
|
|
1231
|
+
'Then call huaweicloud_sandbox_expose_tunnel again.',
|
|
1232
|
+
loginOutput,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
let result = null;
|
|
1237
|
+
let attempt = 0;
|
|
1238
|
+
const maxAttempts = 2;
|
|
1239
|
+
while (attempt < maxAttempts) {
|
|
1240
|
+
attempt += 1;
|
|
1241
|
+
const exposeScript = buildDevbridgeExposeScript(validatedPort);
|
|
1242
|
+
result = await execOneShot(workspaceId, exposeScript, username, timeoutMs);
|
|
1243
|
+
const parsed = parseDevbridgeExposeOutput(result.stdout);
|
|
1244
|
+
|
|
1245
|
+
// 10006 quota exceeded: clean up stale tunnels and retry.
|
|
1246
|
+
if (parsed.quotaError && attempt < maxAttempts) {
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
// Unreachable (000) or a migrated-gateway placeholder: one retry.
|
|
1250
|
+
const unreachable = parsed.httpCode === '000' || parsed.httpCode === '';
|
|
1251
|
+
const migration = /服务已迁移/.test(String(result.stdout || ''));
|
|
1252
|
+
if (unreachable || migration) {
|
|
1253
|
+
if (attempt === 1) {
|
|
1254
|
+
try {
|
|
1255
|
+
await execOneShot(
|
|
1256
|
+
workspaceId,
|
|
1257
|
+
`pkill -f "devbridge host" 2>/dev/null || true; sleep 2; devbridge delete-all > /dev/null 2>&1 || true`,
|
|
1258
|
+
username,
|
|
1259
|
+
20000,
|
|
1260
|
+
);
|
|
1261
|
+
} catch {}
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const warnings = [];
|
|
1267
|
+
if (parsed.quotaError)
|
|
1268
|
+
warnings.push('DevBridge tunnel quota (10006) hit — stale tunnels were cleaned up and the host was retried.');
|
|
1269
|
+
if (parsed.httpCode !== '200' && parsed.httpCode !== '304') {
|
|
1270
|
+
warnings.push(
|
|
1271
|
+
`Tunnel reachable check returned HTTP ${parsed.httpCode} (expected 200/304). The URL may not be serving yet.`,
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
ok: true,
|
|
1276
|
+
publicUrl: parsed.tunnelUrl || undefined,
|
|
1277
|
+
port: validatedPort,
|
|
1278
|
+
tunnelId: parsed.tunnelId || undefined,
|
|
1279
|
+
authMode,
|
|
1280
|
+
httpCode: parsed.httpCode,
|
|
1281
|
+
warnings: warnings.length > 0 ? warnings.join(' ') : undefined,
|
|
1282
|
+
hostLog: String(result.stdout || '').slice(0, 400),
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
error: 'Failed to expose a working DevBridge tunnel after retries.',
|
|
1289
|
+
authMode,
|
|
1290
|
+
hostLog: String(result?.stdout || '').slice(0, 400),
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1107
1294
|
export async function closeAllSessions() {
|
|
1108
1295
|
for (const [key, session] of sessions) {
|
|
1109
1296
|
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:
|
|
212
|
+
installCommand: buildSkillInstallCommand(skill),
|
|
213
|
+
installHint: INSTALL_DOWNLOAD_HINT,
|
|
175
214
|
});
|
|
176
215
|
}
|
|
177
216
|
results.sort((a, b) => b.score - a.score);
|