huaweicloud-devkit 1.1.10 → 1.1.11

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.
@@ -22,6 +22,8 @@ import {
22
22
  deployNginx,
23
23
  deployCheck,
24
24
  exposeTunnel,
25
+ renewTunnel,
26
+ installKeepaliveWatchdog,
25
27
  getCurrentWorkspaceId,
26
28
  setWorkspaceId,
27
29
  } from './sandbox/session-manager.mjs';
@@ -764,7 +766,7 @@ export const TOOL_DEFINITIONS = [
764
766
  {
765
767
  name: 'huaweicloud_sandbox_expose_tunnel',
766
768
  description:
767
- 'Expose a port on the sandbox to a public DevBridge tunnel URL (devbridge 0.2.x). Authenticates automatically (probing AK/SK vs API-Key builds, using /tmp/hw_creds.sh and /tmp/hw_api_key as injected by huaweicloud_sandbox_credentials), pre-cleans stale tunnels to avoid quota errors, starts the host, extracts the public URL (https://<id>-<port>.devbridge-s2.hwtunnel.com), and health-checks it before returning. Use the ACTUAL port reported by huaweicloud_sandbox_deploy_nginx (its "port" field auto-increments on conflict) — never the originally requested port. This automates the manual "Expose via DevBridge" 5-step flow. Returns ok, publicUrl, port, tunnelId, and warnings. If the API Key is missing on a release build it returns ok:false with instructions to create one.',
769
+ 'Expose a port on the sandbox to a public DevBridge tunnel URL (devbridge 0.2.x). Authenticates automatically (probing AK/SK vs API-Key builds, using /tmp/hw_creds.sh and /tmp/hw_api_key as injected by huaweicloud_sandbox_credentials), pre-cleans stale tunnels to avoid quota errors, starts the host, extracts the public URL (https://<id>-<port>.devbridge-s2.hwtunnel.com), and health-checks it before returning. AUTO-ENROLLS the tunnel into the keepalive watchdog (host re-attach + daily 720h renewal + health check) by default, merged into existing coverage — pass keepalive:false to opt out. Use the ACTUAL port reported by huaweicloud_sandbox_deploy_nginx (its "port" field auto-increments on conflict) — never the originally requested port. Returns ok, publicUrl, port, tunnelId, keepalive.enrolled, and warnings. If the API Key is missing on a release build it returns ok:false with instructions to create one.',
768
770
  inputSchema: {
769
771
  type: 'object',
770
772
  required: ['port'],
@@ -774,6 +776,16 @@ export const TOOL_DEFINITIONS = [
774
776
  description:
775
777
  'Actual port nginx is listening on — use the "port" value returned by huaweicloud_sandbox_deploy_nginx, not the originally requested port.',
776
778
  },
779
+ expiry_hours: {
780
+ type: 'number',
781
+ description:
782
+ 'Tunnel lifetime in hours via devbridge -e (1-720; 720 = the 30-day maximum, default 72). The expiry is a rolling window — the keepalive watchdog renews daily to keep the URL alive indefinitely.',
783
+ },
784
+ keepalive: {
785
+ type: 'boolean',
786
+ description:
787
+ 'Auto-enroll the tunnel into the keepalive watchdog (default true — every tunnel deployed to the sandbox must be keepalive-protected). Pass false only when the caller explicitly manages keepalive itself.',
788
+ },
777
789
  workspace_id: {
778
790
  type: 'string',
779
791
  description:
@@ -784,6 +796,65 @@ export const TOOL_DEFINITIONS = [
784
796
  },
785
797
  },
786
798
  },
799
+ {
800
+ name: 'huaweicloud_sandbox_renew_tunnel',
801
+ description:
802
+ 'Renew a DevBridge tunnel lifetime via devbridge update <tunnelId> -e <hours> (1-720; 720 = the 30-day maximum). The expiry is a rolling window: renewal sets expiration = now + hours regardless of remaining time, so renewing before expiry keeps the SAME tunnel URL alive indefinitely. A renewal counts as ok only when the update succeeds AND show -v reports the absolute tunnelExpiration — the management plane can lose a tunnel record (10002 "tunnel not found") while the data plane keeps serving. Returns ok, tunnelId, expiresAt (ISO), renewedForHours. Pair with huaweicloud_sandbox_keepalive_watchdog for unattended daily renewal.',
803
+ inputSchema: {
804
+ type: 'object',
805
+ required: ['tunnel_id'],
806
+ properties: {
807
+ tunnel_id: {
808
+ type: 'string',
809
+ description:
810
+ 'Short tunnel ID from huaweicloud_sandbox_expose_tunnel (e.g. nv2dvv6w) — extractable from the public URL as https://<tunnel_id>-<port>.devbridge-s2.hwtunnel.com.',
811
+ },
812
+ expiry_hours: {
813
+ type: 'number',
814
+ description:
815
+ 'Renewal horizon in hours (1-720, default 720). The expiration becomes now + expiry_hours (rolling window).',
816
+ },
817
+ workspace_id: {
818
+ type: 'string',
819
+ description:
820
+ 'Workspace ID from huaweicloud_sandbox_connect return value. Required - must be passed explicitly when HW_WORKSPACE_ID is not set.',
821
+ },
822
+ username: { type: 'string', description: 'Login username (default: root)' },
823
+ timeout_ms: { type: 'number', description: 'Execution timeout in milliseconds (default: 60000)' },
824
+ },
825
+ },
826
+ },
827
+ {
828
+ name: 'huaweicloud_sandbox_keepalive_watchdog',
829
+ description:
830
+ 'Install a keepalive watchdog in the sandbox that keeps DevBridge tunnel URLs alive indefinitely (three-layer pattern, proven in the 2026-10 tunnel-persistence test): (1) re-attaches the host with devbridge host <tunnelId> if the process dies — the URL is bound to the tunnel ID so it never changes; (2) renews every tunnel daily with devbridge update <tunnelId> -e 720 (29-day failure tolerance); (3) health-checks each URL every 5 minutes, logging warnings to /tmp/tunnel-keepalive.log. Installs as a setsid-detached loop at /tmp/tunnel-keepalive.sh; re-running replaces the old loop. URLs are hardcoded per tunnel because devbridge port list output is wrapped in OSC escapes that break anchored greps. Call AFTER huaweicloud_sandbox_expose_tunnel; requires DevBridge credentials (auth) in the sandbox.',
831
+ inputSchema: {
832
+ type: 'object',
833
+ required: ['tunnels'],
834
+ properties: {
835
+ tunnels: {
836
+ type: 'array',
837
+ description:
838
+ 'Tunnels to watch. Get tunnelId + url from huaweicloud_sandbox_expose_tunnel (publicUrl contains the tunnelId as https://<tunnel_id>-<port>.devbridge-s2.hwtunnel.com).',
839
+ items: {
840
+ type: 'object',
841
+ required: ['tunnel_id', 'url'],
842
+ properties: {
843
+ tunnel_id: { type: 'string', description: 'Short tunnel ID (e.g. kag7rozs).' },
844
+ url: { type: 'string', description: 'Full public https URL of the tunnel (used for the health check).' },
845
+ },
846
+ },
847
+ },
848
+ workspace_id: {
849
+ type: 'string',
850
+ description:
851
+ 'Workspace ID from huaweicloud_sandbox_connect return value. Required - must be passed explicitly when HW_WORKSPACE_ID is not set.',
852
+ },
853
+ username: { type: 'string', description: 'Login username (default: root)' },
854
+ timeout_ms: { type: 'number', description: 'Execution timeout in milliseconds (default: 60000)' },
855
+ },
856
+ },
857
+ },
787
858
  {
788
859
  name: 'huaweicloud_sandbox_check_user',
789
860
  description:
@@ -951,6 +1022,47 @@ export const TOOL_DEFINITIONS = [
951
1022
  required: ['action', 'bucket', 'region'],
952
1023
  },
953
1024
  },
1025
+ {
1026
+ name: 'huaweicloud_obs_set_cors',
1027
+ description:
1028
+ '配置 OBS 桶的 CORS 规则(浏览器直传前置条件)。KooCLI OBS 不支持 cors 子命令,此工具内部实现 AWS4 签名调用 OBS REST API(PUT/GET/DELETE /?cors),屏蔽签名细节。支持 set(配置)、get(查询)、delete(删除)三种操作。allowedMethod 必须含 HEAD(BrowserJS SDK 每次操作前发 HEAD ?apiversion 探测,缺 HEAD 时浏览器吞掉探测请求)。配置成功后用返回体中的 verify 命令做 OPTIONS 预检验证(200 + Access-Control-Allow-Origin 才算成功)。',
1029
+ inputSchema: {
1030
+ type: 'object',
1031
+ properties: {
1032
+ action: {
1033
+ type: 'string',
1034
+ enum: ['set', 'get', 'delete'],
1035
+ description: '操作类型:set=配置 CORS 规则,get=查询当前规则,delete=删除规则',
1036
+ },
1037
+ bucket: { type: 'string', description: 'OBS 桶名称' },
1038
+ region: { type: 'string', description: 'OBS 桶所在区域,如 cn-north-4' },
1039
+ rules: {
1040
+ type: 'array',
1041
+ description:
1042
+ 'CORS 规则数组(action=set 时必填)。每条规则:allowedOrigin(如 https://your-app)、allowedMethod 数组(GET/PUT/POST/DELETE/HEAD,必须含 HEAD)、allowedHeader 数组(默认 ["*"])、maxAgeSeconds(默认 3000)',
1043
+ items: {
1044
+ type: 'object',
1045
+ properties: {
1046
+ allowedOrigin: { type: 'string', description: '允许的来源,如 https://your-app.example.com 或 *' },
1047
+ allowedMethod: {
1048
+ type: 'array',
1049
+ items: { type: 'string' },
1050
+ description: '允许的 HTTP 方法,必须包含 HEAD',
1051
+ },
1052
+ allowedHeader: {
1053
+ type: 'array',
1054
+ items: { type: 'string' },
1055
+ description: '允许的请求头,默认 ["*"]',
1056
+ },
1057
+ maxAgeSeconds: { type: 'number', description: '预检结果缓存秒数,默认 3000' },
1058
+ },
1059
+ required: ['allowedOrigin', 'allowedMethod'],
1060
+ },
1061
+ },
1062
+ },
1063
+ required: ['action', 'bucket', 'region'],
1064
+ },
1065
+ },
954
1066
  ];
955
1067
 
956
1068
  function toolInvokeValue(name, args) {
@@ -1512,7 +1624,53 @@ export async function callTool(name, rawArgs = {}, opts = {}) {
1512
1624
  }
1513
1625
  const sandboxUser9 = args.username || 'root';
1514
1626
  const sandboxTimeout9 = args.timeout_ms || 90000;
1515
- return await exposeTunnel(sandboxWsId9, { port: args.port }, sandboxUser9, sandboxTimeout9);
1627
+ return await exposeTunnel(
1628
+ sandboxWsId9,
1629
+ { port: args.port, expiryHours: args.expiry_hours, keepalive: args.keepalive },
1630
+ sandboxUser9,
1631
+ sandboxTimeout9,
1632
+ );
1633
+ }
1634
+ case 'huaweicloud_sandbox_renew_tunnel': {
1635
+ if (!args.tunnel_id) {
1636
+ throw new Error(
1637
+ 'tunnel_id is required. Get it from huaweicloud_sandbox_expose_tunnel or extract it from the public URL (https://<tunnel_id>-<port>.devbridge-s2.hwtunnel.com).',
1638
+ );
1639
+ }
1640
+ const sandboxWsId10 = args.workspace_id || getCurrentWorkspaceId();
1641
+ if (!sandboxWsId10) {
1642
+ throw new Error(
1643
+ 'workspace_id is required. No sandbox connected — call huaweicloud_sandbox_connect first, ' +
1644
+ 'or set HW_WORKSPACE_ID environment variable before starting the agent.',
1645
+ );
1646
+ }
1647
+ return await renewTunnel(
1648
+ sandboxWsId10,
1649
+ { tunnelId: args.tunnel_id, expiryHours: args.expiry_hours },
1650
+ args.username || 'root',
1651
+ args.timeout_ms || 60000,
1652
+ );
1653
+ }
1654
+ case 'huaweicloud_sandbox_keepalive_watchdog': {
1655
+ if (!Array.isArray(args.tunnels) || args.tunnels.length === 0) {
1656
+ throw new Error(
1657
+ 'tunnels is required — a non-empty array of {tunnel_id, url} from huaweicloud_sandbox_expose_tunnel results.',
1658
+ );
1659
+ }
1660
+ const sandboxWsId11 = args.workspace_id || getCurrentWorkspaceId();
1661
+ if (!sandboxWsId11) {
1662
+ throw new Error(
1663
+ 'workspace_id is required. No sandbox connected — call huaweicloud_sandbox_connect first, ' +
1664
+ 'or set HW_WORKSPACE_ID environment variable before starting the agent.',
1665
+ );
1666
+ }
1667
+ const tunnels11 = args.tunnels.map((t) => ({ tunnelId: t.tunnel_id || t.tunnelId, url: t.url }));
1668
+ return await installKeepaliveWatchdog(
1669
+ sandboxWsId11,
1670
+ { tunnels: tunnels11 },
1671
+ args.username || 'root',
1672
+ args.timeout_ms || 60000,
1673
+ );
1516
1674
  }
1517
1675
  case 'huaweicloud_sandbox_check_user':
1518
1676
  return await hdkitCheckUser();
@@ -1527,7 +1685,12 @@ export async function callTool(name, rawArgs = {}, opts = {}) {
1527
1685
  await execOneShot(devStageId, 'devbridge delete-all 2>/dev/null || true', 'root', 15000);
1528
1686
  } catch {}
1529
1687
  try {
1530
- await execWithSession(devStageId, 'export PATH=$HOME/.huawei/bin${PATH:+:$PATH}', 'root', 10000);
1688
+ await execWithSession(
1689
+ devStageId,
1690
+ 'export PATH=/workspace/.local/bin:$HOME/.huawei/bin${PATH:+:$PATH}',
1691
+ 'root',
1692
+ 10000,
1693
+ );
1531
1694
  } catch {}
1532
1695
  await transferGitRepo(args, devStageId, connectResult);
1533
1696
  }
@@ -1632,6 +1795,8 @@ export async function callTool(name, rawArgs = {}, opts = {}) {
1632
1795
  return await handleUpgrade(args, { sessionId: opts?.sessionId });
1633
1796
  case 'huaweicloud_obs_set_website_config':
1634
1797
  return await handleObsWebsiteConfig(args);
1798
+ case 'huaweicloud_obs_set_cors':
1799
+ return await handleObsCorsConfig(args);
1635
1800
  default:
1636
1801
  throw new Error(`Unknown tool: ${name}`);
1637
1802
  }
@@ -2768,7 +2933,95 @@ async function handleObsWebsiteConfig(args) {
2768
2933
  throw new Error(`Unknown action: ${action}. Use set, get, or delete.`);
2769
2934
  }
2770
2935
 
2771
- async function obsSignedRequest(method, endpoint, pathAndQuery, body, creds, region) {
2936
+ // #1003: same-structure extension of handleObsWebsiteConfig — KooCLI OBS does
2937
+ // not expose a cors subcommand and there is no SetBucketCors MCP path, so
2938
+ // agents fell back to installing a Python SDK in the sandbox just to configure
2939
+ // CORS. This reuses obsSignedRequest (AWS4, s3 service) against /?cors.
2940
+ async function handleObsCorsConfig(args = {}) {
2941
+ const { action, bucket, region, rules } = args;
2942
+ if (!bucket || !region) {
2943
+ throw new Error('bucket and region are required');
2944
+ }
2945
+ const creds = resolveCredentialsWithRuntime({});
2946
+ if (!creds?.ak || !creds?.sk) {
2947
+ throw new Error('OBS CORS config requires AK/SK credentials. Run huaweicloud_auth_init first.');
2948
+ }
2949
+
2950
+ const host = `${bucket}.obs.${region}.myhuaweicloud.com`;
2951
+ const endpoint = `https://${host}`;
2952
+ const preflightHint =
2953
+ 'Verify with an OPTIONS preflight before declaring success: ' +
2954
+ `curl -i -X OPTIONS --noproxy '*' 'https://${host}/<object-key>' -H 'Origin: <your-app-origin>' -H 'Access-Control-Request-Method: HEAD' ` +
2955
+ 'must return HTTP 200 with an Access-Control-Allow-Origin header (200 without CORS headers = config not effective; ' +
2956
+ '403 = rule missing the requested method). See the huawei-obs skill common-pitfalls.md p05.';
2957
+
2958
+ if (action === 'get') {
2959
+ const res = await obsSignedRequest('GET', endpoint, '/?cors', '', creds, region, 'cors');
2960
+ return { ok: res.status === 200, status: res.status, rules: res.body, verify: preflightHint };
2961
+ }
2962
+
2963
+ if (action === 'delete') {
2964
+ const res = await obsSignedRequest('DELETE', endpoint, '/?cors', '', creds, region, 'cors');
2965
+ return { ok: res.status === 204, status: res.status };
2966
+ }
2967
+
2968
+ if (action === 'set') {
2969
+ if (!Array.isArray(rules) || rules.length === 0) {
2970
+ throw new Error('rules array is required for action=set (allowedOrigin + allowedMethod, HEAD included)');
2971
+ }
2972
+ for (const rule of rules) {
2973
+ if (!rule?.allowedOrigin || !Array.isArray(rule?.allowedMethod) || rule.allowedMethod.length === 0) {
2974
+ throw new Error('each CORS rule needs allowedOrigin and a non-empty allowedMethod array');
2975
+ }
2976
+ if (!rule.allowedMethod.includes('HEAD')) {
2977
+ // p01: the BrowserJS SDK's HEAD ?apiversion probe gets swallowed by the
2978
+ // browser when HEAD is missing — the exact failure this tool exists to
2979
+ // prevent, so reject it at the input boundary instead of at runtime.
2980
+ throw new Error(
2981
+ "allowedMethod must include 'HEAD' — the BrowserJS SDK sends a HEAD ?apiversion probe before every " +
2982
+ 'operation, and a CORS rule without HEAD makes the browser swallow it as Network Error (p01).',
2983
+ );
2984
+ }
2985
+ }
2986
+ const escapeXml = (s) =>
2987
+ String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2988
+ const xmlParts = ['<CORSConfiguration>'];
2989
+ for (const rule of rules) {
2990
+ xmlParts.push(' <CORSRule>');
2991
+ xmlParts.push(` <AllowedOrigin>${escapeXml(rule.allowedOrigin)}</AllowedOrigin>`);
2992
+ for (const method of rule.allowedMethod) {
2993
+ xmlParts.push(` <AllowedMethod>${escapeXml(method)}</AllowedMethod>`);
2994
+ }
2995
+ for (const header of rule.allowedHeader || ['*']) {
2996
+ xmlParts.push(` <AllowedHeader>${escapeXml(header)}</AllowedHeader>`);
2997
+ }
2998
+ if (rule.maxAgeSeconds != null) {
2999
+ xmlParts.push(` <MaxAgeSeconds>${Number(rule.maxAgeSeconds)}</MaxAgeSeconds>`);
3000
+ }
3001
+ xmlParts.push(' </CORSRule>');
3002
+ }
3003
+ xmlParts.push('</CORSConfiguration>');
3004
+ const body = xmlParts.join('\n');
3005
+ const res = await obsSignedRequest('PUT', endpoint, '/?cors', body, creds, region, 'cors');
3006
+ return {
3007
+ ok: res.status === 200,
3008
+ status: res.status,
3009
+ message:
3010
+ res.status === 200
3011
+ ? `CORS configured for ${bucket} (${rules.length} rule(s)).`
3012
+ : `Failed to configure CORS: HTTP ${res.status}`,
3013
+ verify: preflightHint,
3014
+ };
3015
+ }
3016
+
3017
+ throw new Error(`Unknown action: ${action}. Use set, get, or delete.`);
3018
+ }
3019
+
3020
+ // Single AWS4-signed OBS REST API caller shared by the website/CORS tools
3021
+ // (#1003): subresource is the bucket subresource being addressed — 'website'
3022
+ // for PUT/GET/DELETE /?website, 'cors' for /?cors. Credential scope uses the
3023
+ // s3 service name (OBS is S3-compatible).
3024
+ async function obsSignedRequest(method, endpoint, pathAndQuery, body, creds, region, subresource = 'website') {
2772
3025
  const now = new Date();
2773
3026
  const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '');
2774
3027
  const amzDate = dateStamp + 'T' + now.toISOString().slice(11, 19).replace(/:/g, '') + 'Z';
@@ -2776,7 +3029,7 @@ async function obsSignedRequest(method, endpoint, pathAndQuery, body, creds, reg
2776
3029
 
2777
3030
  const url = new URL(endpoint + pathAndQuery);
2778
3031
  const canonicalUri = '/';
2779
- const canonicalQueryString = 'website=';
3032
+ const canonicalQueryString = `${subresource}=`;
2780
3033
  const canonicalHeaders = `host:${url.host}\n` + `x-amz-content-sha256:${payloadHash}\n` + `x-amz-date:${amzDate}\n`;
2781
3034
  const signedHeaders = 'host;x-amz-content-sha256;x-amz-date';
2782
3035
  const canonicalRequest = [