huaweicloud-devkit 1.1.8 → 1.1.9-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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/integrations/opencode/package-lock.json +24 -0
  4. package/integrations/opencode/package.json +10 -0
  5. package/integrations/opencode/plugins/huaweicloud-devkit.js +194 -0
  6. package/package.json +2 -2
  7. package/plugins/huaweicloud-core/.claude-plugin/plugin.json +1 -1
  8. package/plugins/huaweicloud-core/.codex-plugin/plugin.json +1 -1
  9. package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +1 -1
  10. package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +1 -1
  11. package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +1 -1
  12. package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
  13. package/plugins/huaweicloud-core/skills/huawei-ecs/SKILL.md +5 -0
  14. package/plugins/huaweicloud-core/skills/huawei-ecs/references/create-instance.md +16 -2
  15. package/plugins/huaweicloud-core/skills/huawei-ecs/references/flavors.md +22 -0
  16. package/plugins/huaweicloud-core/skills/huawei-ecs/references/windows-ssh.md +118 -0
  17. package/plugins/huaweicloud-core/skills/huawei-getting-started/SKILL.md +3 -3
  18. package/plugins/huaweicloud-core/skills/huawei-iac/SKILL.md +36 -36
  19. package/plugins/huaweicloud-core/skills/huawei-obs/SKILL.md +2 -2
  20. package/plugins/huaweicloud-core/skills/huawei-obs/references/common-pitfalls.md +30 -3
  21. package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +35 -6
  22. package/plugins/huaweicloud-core/skills/huawei-voucher/SKILL.md +1 -0
  23. package/plugins/huaweicloud-core/skills/huaweicloud-capability-discovery/SKILL.md +2 -2
  24. package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +6 -6
  25. package/plugins/huaweicloud-core/skills/huaweicloud-core/SKILL.md +16 -9
  26. package/plugins/huaweicloud-core/src/auth/credential-validator.mjs +37 -2
  27. package/plugins/huaweicloud-core/src/auth/credentials.mjs +14 -27
  28. package/plugins/huaweicloud-core/src/auth/guidance.mjs +112 -0
  29. package/plugins/huaweicloud-core/src/auth/reconcile.mjs +36 -2
  30. package/plugins/huaweicloud-core/src/auth/service.mjs +54 -46
  31. package/plugins/huaweicloud-core/src/hcloud-cli.mjs +32 -4
  32. package/plugins/huaweicloud-core/src/hcloud-probe.mjs +26 -6
  33. package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +120 -4
  34. package/plugins/huaweicloud-core/src/setup-cli.mjs +111 -8
  35. package/plugins/huaweicloud-core/src/tools.mjs +23 -3
@@ -1251,17 +1251,33 @@ export function parseDevbridgeAuthOutput(stdout) {
1251
1251
  return { authMode, attempts };
1252
1252
  }
1253
1253
 
1254
+ // Tunnel lifetime for expose-created hosts, in hours. The report that motivated
1255
+ // #919 uses -e 72 so the shared link survives long after the deploying session
1256
+ // ends; the value is surfaced to callers via exposeTunnel's expiresAt field.
1257
+ export const DEVBRIDGE_HOST_EXPIRY_HOURS = 72;
1258
+
1254
1259
  // Start (or replace) a devbridge host bound to a single port, extract the public
1255
1260
  // URL, and health-check it. Mirrors the huaweicloud-sandbox skill's Expose flow:
1256
1261
  // pre-clean stale tunnels (quota 10006), start host in background, read
1257
1262
  // "Tunnel URL: ..." from /tmp/host.log, curl-check, retry once on unreachable.
1258
- export function buildDevbridgeExposeScript(port) {
1263
+ // The host MUST survive the exec session that started it (#919): setsid detaches
1264
+ // it from the session's process group, `< /dev/null` drops the controlling tty
1265
+ // stdin, and `disown` removes it from the shell's job table — without all three
1266
+ // the host is reaped when the one-shot session ends and the tunnel dies
1267
+ // immediately after a green in-session health check (the "假成功" bug).
1268
+ export function buildDevbridgeExposeScript(port, expiryHours = DEVBRIDGE_HOST_EXPIRY_HOURS) {
1259
1269
  const hostLog = '/tmp/host.log';
1260
1270
  return [
1261
1271
  `pkill -f "devbridge host" 2>/dev/null || true`,
1262
1272
  `sleep 2`,
1263
1273
  `devbridge delete-all > /dev/null 2>&1 || true`,
1264
- `nohup devbridge host -p ${port} -e 8 > ${hostLog} 2>&1 &`,
1274
+ // #922 (S8): setsid is unavailable on some sandbox images — fall back to a
1275
+ // bare nohup launch so the host still starts; persistence is then judged by
1276
+ // the independent re-verify below, which emits the 请勿分享 warning in the
1277
+ // non-persistent case. disown is guarded: shells without the builtin must
1278
+ // not abort the script.
1279
+ `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`,
1280
+ `disown 2>/dev/null || true`,
1265
1281
  `sleep 12`,
1266
1282
  `TUNNEL_URL=$(sed -n 's/.*Tunnel URL: *//p' ${hostLog} | tail -1)`,
1267
1283
  `HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
@@ -1269,10 +1285,37 @@ export function buildDevbridgeExposeScript(port) {
1269
1285
  `tail -n 5 ${hostLog}`,
1270
1286
  `echo "DB_TUNNEL_URL=$TUNNEL_URL"`,
1271
1287
  `echo "DB_HTTP_CODE=$HTTP_CODE"`,
1288
+ `echo "DB_EXPIRY_HOURS=${expiryHours}"`,
1272
1289
  `echo "DB_QUOTA_ERROR=$(grep -c '10006' ${hostLog} 2>/dev/null || echo 0)"`,
1273
1290
  ].join('\n');
1274
1291
  }
1275
1292
 
1293
+ // Build the script for the SECOND, independent exec session that re-verifies the
1294
+ // tunnel with a real GET (#919): the in-session health check inside
1295
+ // buildDevbridgeExposeScript cannot prove the host survived session teardown —
1296
+ // a GET from a fresh session can. #922 (B2): the response body is kept and
1297
+ // checked for the native false-success form — a 200 whose body is "tunnel not
1298
+ // found" — and the verdict is emitted as DB_VERIFY_NOT_FOUND so
1299
+ // parseDevbridgeVerifyOutput can catch it. The URL is validated (https,
1300
+ // URL-safe chars only) before being spliced into the shell command.
1301
+ export function buildDevbridgeVerifyScript(tunnelUrl) {
1302
+ const url = String(tunnelUrl || '');
1303
+ if (!/^https:\/\/[A-Za-z0-9:._/-]+$/.test(url)) {
1304
+ throw new Error(
1305
+ `sandbox expose tunnel: invalid tunnel URL ${JSON.stringify(url.slice(0, 80))}. Expected an https:// URL with URL-safe characters (from the expose script's DB_TUNNEL_URL output).`,
1306
+ );
1307
+ }
1308
+ const bodyFile = '/tmp/devbridge_verify_body';
1309
+ return [
1310
+ `VERIFY_CODE=$(curl -s -o ${bodyFile} -w "%{http_code}" --max-time 10 "${url}" 2>/dev/null || echo "000")`,
1311
+ `VERIFY_NOT_FOUND=$(grep -ci 'tunnel not found' ${bodyFile} 2>/dev/null || true)`,
1312
+ `if [ -z "$VERIFY_NOT_FOUND" ]; then VERIFY_NOT_FOUND=0; fi`,
1313
+ `rm -f ${bodyFile} 2>/dev/null || true`,
1314
+ `echo "DB_VERIFY_HTTP_CODE=$VERIFY_CODE"`,
1315
+ `echo "DB_VERIFY_NOT_FOUND=$VERIFY_NOT_FOUND"`,
1316
+ ].join('\n');
1317
+ }
1318
+
1276
1319
  // Parse the raw stdout of buildDevbridgeExposeScript into structured fields.
1277
1320
  export function parseDevbridgeExposeOutput(stdout) {
1278
1321
  const text = String(stdout || '');
@@ -1284,7 +1327,25 @@ export function parseDevbridgeExposeOutput(stdout) {
1284
1327
  const httpCode = getField('DB_HTTP_CODE');
1285
1328
  const quotaError = getField('DB_QUOTA_ERROR') === '1' || /10006/.test(text);
1286
1329
  const tunnelId = (tunnelUrl.match(/^https:\/\/([A-Za-z0-9_-]+)-\d+\./) || [])[1] || '';
1287
- return { tunnelUrl, httpCode, quotaError, tunnelId };
1330
+ const expiryHours = Number(getField('DB_EXPIRY_HOURS')) || undefined;
1331
+ return { tunnelUrl, httpCode, quotaError, tunnelId, expiryHours };
1332
+ }
1333
+
1334
+ // Parse the raw stdout of buildDevbridgeVerifyScript into structured fields.
1335
+ // Dedicated parser (#922 B1): the verify script emits DB_VERIFY_HTTP_CODE /
1336
+ // DB_VERIFY_NOT_FOUND keys — the anchored ^DB_HTTP_CODE= lookup in
1337
+ // parseDevbridgeExposeOutput never matches them, which used to leave
1338
+ // verifyHttpCode empty and persistent always false.
1339
+ export function parseDevbridgeVerifyOutput(stdout) {
1340
+ const text = String(stdout || '');
1341
+ const getField = (key) => {
1342
+ const m = text.match(new RegExp(`^${key}=(.*)$`, 'm'));
1343
+ return m ? m[1].trim() : '';
1344
+ };
1345
+ const httpCode = getField('DB_VERIFY_HTTP_CODE');
1346
+ const notFoundCount = Number(getField('DB_VERIFY_NOT_FOUND')) || 0;
1347
+ const notFound = notFoundCount > 0;
1348
+ return { httpCode, notFound };
1288
1349
  }
1289
1350
 
1290
1351
  // Parse and validate a TCP port number destined for shell interpolation. Returns
@@ -1303,7 +1364,10 @@ export function validateTunnelPort(port) {
1303
1364
  // Expose a sandbox port to a public DevBridge tunnel URL. `port` MUST be the
1304
1365
  // ACTUAL port reported by deployNginx (deploy_nginx auto-increments on conflict),
1305
1366
  // otherwise the tunnel is bound to a dead port. Returns ok, publicUrl, port,
1306
- // tunnelId, auth mode, and warnings.
1367
+ // tunnelId, auth mode, expiresAt (tunnel lifetime from -e), and warnings; the
1368
+ // tunnel is re-verified from a second independent exec session (#919) and the
1369
+ // result carries verifyHttpCode + persistent so callers can tell a
1370
+ // session-surviving URL from one that dies with the deploying session.
1307
1371
  export async function exposeTunnel(workspaceId, { port }, username = 'root', timeoutMs = 90000) {
1308
1372
  if (!workspaceId) {
1309
1373
  throw new Error('sandbox expose tunnel: workspace_id is required.');
@@ -1367,11 +1431,58 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
1367
1431
  const warnings = [];
1368
1432
  if (parsed.quotaError)
1369
1433
  warnings.push('DevBridge tunnel quota (10006) hit — stale tunnels were cleaned up and the host was retried.');
1434
+
1435
+ // #919: re-verify with a real GET from a SECOND, independent exec session.
1436
+ // The in-session health check above cannot prove the host survived session
1437
+ // teardown (setsid/nohup/disown make that likely, not certain). When the
1438
+ // fresh-session GET fails, the URL will die with the deploying session —
1439
+ // say so explicitly instead of returning a link the visitor cannot open.
1440
+ let verifyHttpCode = '';
1441
+ let verifyNotFound = false;
1442
+ let persistent = undefined;
1443
+ if (parsed.tunnelUrl) {
1444
+ try {
1445
+ const verifyResult = await execOneShot(
1446
+ workspaceId,
1447
+ buildDevbridgeVerifyScript(parsed.tunnelUrl),
1448
+ username,
1449
+ 30000,
1450
+ );
1451
+ // #922 (B1): parse with the dedicated verify parser — the
1452
+ // DB_VERIFY_HTTP_CODE key is not visible to parseDevbridgeExposeOutput.
1453
+ const verify = parseDevbridgeVerifyOutput(verifyResult.stdout);
1454
+ verifyHttpCode = verify.httpCode || '';
1455
+ // #922 (B2): a native false-success form answers HTTP 200 with a
1456
+ // "tunnel not found" body — treat it as non-persistent.
1457
+ verifyNotFound = verify.notFound;
1458
+ } catch {}
1459
+ // 000 = the fresh session could not reach the tunnel (host already dead
1460
+ // or gateway not serving); 200 with a "tunnel not found" body = the
1461
+ // gateway answered but the tunnel is gone; anything non-200/304 is
1462
+ // surfaced as-is below.
1463
+ persistent = verifyHttpCode !== '' && verifyHttpCode !== '000' && !verifyNotFound;
1464
+ if (!persistent) {
1465
+ if (verifyNotFound) {
1466
+ warnings.push(
1467
+ '复测返回 HTTP 200 但 body 为 "tunnel not found"(原生假成功形态),隧道实际未生效,请勿分享 (the recheck answered HTTP 200 with a "tunnel not found" body — the tunnel is NOT serving; do NOT share this URL).',
1468
+ );
1469
+ } else {
1470
+ warnings.push(
1471
+ '隧道将在会话结束后失效,请勿分享 (the tunnel did NOT survive an independent exec-session recheck — do NOT share this URL; the host process is not persistent).',
1472
+ );
1473
+ }
1474
+ }
1475
+ }
1476
+
1370
1477
  if (parsed.httpCode !== '200' && parsed.httpCode !== '304') {
1371
1478
  warnings.push(
1372
1479
  `Tunnel reachable check returned HTTP ${parsed.httpCode} (expected 200/304). The URL may not be serving yet.`,
1373
1480
  );
1374
1481
  }
1482
+ const expiresAt =
1483
+ parsed.expiryHours !== undefined
1484
+ ? new Date(Date.now() + parsed.expiryHours * 3_600_000).toISOString()
1485
+ : undefined;
1375
1486
  return {
1376
1487
  ok: true,
1377
1488
  publicUrl: parsed.tunnelUrl || undefined,
@@ -1379,6 +1490,11 @@ export async function exposeTunnel(workspaceId, { port }, username = 'root', tim
1379
1490
  tunnelId: parsed.tunnelId || undefined,
1380
1491
  authMode,
1381
1492
  httpCode: parsed.httpCode,
1493
+ verifyHttpCode: verifyHttpCode || undefined,
1494
+ verifyNotFound: verifyNotFound || undefined,
1495
+ persistent,
1496
+ expiresAt,
1497
+ expiresInHours: parsed.expiryHours,
1382
1498
  warnings: warnings.length > 0 ? warnings.join(' ') : undefined,
1383
1499
  hostLog: String(result.stdout || '').slice(0, 400),
1384
1500
  };
@@ -961,6 +961,21 @@ async function installOpenCode() {
961
961
  mkdirSync(opcPlugins, { recursive: true });
962
962
  copyFileSync(pluginSrc, join(opcPlugins, 'skill-tracker.js'));
963
963
  console.log(` Plugin -> ${opcPlugins}`);
964
+ // OpenCode native plugin (#905 OpenCode performance): bridges all 42 MCP tools
965
+ // into OpenCode in-process custom tools (zod schemas + callTool), plus safety
966
+ // gate and telemetry. Local plugins are auto-loaded from this directory.
967
+ const nativePluginSrc = join(PACKAGE_ROOT, 'integrations', 'opencode', 'plugins', 'huaweicloud-devkit.js');
968
+ if (existsSync(nativePluginSrc)) {
969
+ copyFileSync(nativePluginSrc, join(opcPlugins, 'huaweicloud-devkit.js'));
970
+ // Deps for the native plugin (zod) live next to the OpenCode config so the
971
+ // plugin can require them at load time.
972
+ const nativePkgSrc = join(PACKAGE_ROOT, 'integrations', 'opencode', 'package.json');
973
+ const nativePkgDst = join(configRoot('opencode'), 'package.json');
974
+ if (existsSync(nativePkgSrc) && !existsSync(nativePkgDst)) {
975
+ copyFileSync(nativePkgSrc, nativePkgDst);
976
+ }
977
+ console.log(` Native plugin -> ${join(opcPlugins, 'huaweicloud-devkit.js')}`);
978
+ }
964
979
  updateOpenCodeConfig(pluginDest);
965
980
  installRuntimeDeps(pluginDest);
966
981
  }
@@ -994,6 +1009,11 @@ function uninstallOpenCode() {
994
1009
  removeIfExists(pluginFile);
995
1010
  console.log(' Removed plugin');
996
1011
  }
1012
+ const nativePluginFile = join(configRoot('opencode'), 'plugins', 'huaweicloud-devkit.js');
1013
+ if (existsSync(nativePluginFile)) {
1014
+ removeIfExists(nativePluginFile);
1015
+ console.log(' Removed native plugin');
1016
+ }
997
1017
 
998
1018
  if (removeIfExists(opencodePluginsDir())) {
999
1019
  console.log(' Removed MCP server and safety policy');
@@ -1041,6 +1061,17 @@ async function updateOpenCode() {
1041
1061
  mkdirSync(opcPlugins, { recursive: true });
1042
1062
  copyFileSync(pluginSrc, join(opcPlugins, 'skill-tracker.js'));
1043
1063
  console.log(` Plugin updated -> ${opcPlugins}`);
1064
+ // Native plugin refresh (#905 OpenCode performance) — overwrite in place.
1065
+ const nativePluginSrc = join(PACKAGE_ROOT, 'integrations', 'opencode', 'plugins', 'huaweicloud-devkit.js');
1066
+ if (existsSync(nativePluginSrc)) {
1067
+ copyFileSync(nativePluginSrc, join(opcPlugins, 'huaweicloud-devkit.js'));
1068
+ const nativePkgSrc = join(PACKAGE_ROOT, 'integrations', 'opencode', 'package.json');
1069
+ const nativePkgDst = join(configRoot('opencode'), 'package.json');
1070
+ if (existsSync(nativePkgSrc) && !existsSync(nativePkgDst)) {
1071
+ copyFileSync(nativePkgSrc, nativePkgDst);
1072
+ }
1073
+ console.log(` Native plugin updated -> ${join(opcPlugins, 'huaweicloud-devkit.js')}`);
1074
+ }
1044
1075
  updateOpenCodeConfig(pluginDest);
1045
1076
  mkdirSync(pluginDest, { recursive: true });
1046
1077
  writeFileSync(join(pluginDest, '.installed'), new Date().toISOString());
@@ -4873,6 +4904,53 @@ function configureHcloud(credentials) {
4873
4904
  };
4874
4905
  }
4875
4906
 
4907
+ function resetHcloudProfile() {
4908
+ const hcloudBin = findHcloudBin() || process.env.HCLOUD_BIN || 'hcloud';
4909
+ const r = spawnSync(hcloudBin, ['configure', 'delete', `--cli-profile=${configuredProfileName()}`], {
4910
+ shell: false,
4911
+ windowsHide: true,
4912
+ stdio: 'pipe',
4913
+ timeout: 30000,
4914
+ });
4915
+ return {
4916
+ ok: r.status === 0,
4917
+ error: redactSecrets(
4918
+ String(r.stderr || '')
4919
+ .trim()
4920
+ .slice(0, 240),
4921
+ ),
4922
+ };
4923
+ }
4924
+
4925
+ // Verify that the freshly written KooCLI profile can actually authenticate
4926
+ // (#920): a stale/expired security token in the profile survives
4927
+ // `configure set` (it refuses empty values) and poisons every hcloud call with
4928
+ // APIGW.0301 — while the setup output still claims success. Probe with a real
4929
+ // `hcloud STS GetCallerIdentity` and only report synchronized when it passes.
4930
+ function verifyHcloudIdentity(region) {
4931
+ const hcloudBin = findHcloudBin() || process.env.HCLOUD_BIN || 'hcloud';
4932
+ // #922 (S2): an empty region must be omitted entirely — `--cli-region=`
4933
+ // sends a literal empty value that KooCLI rejects as an invalid region.
4934
+ const args = ['STS', 'GetCallerIdentity'];
4935
+ if (region) args.push(`--cli-region=${region}`);
4936
+ const r = spawnSync(hcloudBin, args, {
4937
+ shell: false,
4938
+ windowsHide: true,
4939
+ stdio: 'pipe',
4940
+ timeout: 30000,
4941
+ });
4942
+ const output = `${r.stdout || ''}${r.stderr || ''}`;
4943
+ return {
4944
+ ok: r.status === 0 && !/APIGW\.0301|Incorrect IAM authentication/i.test(output),
4945
+ code: r.status,
4946
+ error: redactSecrets(
4947
+ String(r.stderr || r.stdout || '')
4948
+ .trim()
4949
+ .slice(0, 240),
4950
+ ),
4951
+ };
4952
+ }
4953
+
4876
4954
  function printAuthAgents(agents = {}) {
4877
4955
  for (const [agent, info] of Object.entries(agents)) {
4878
4956
  console.log(` ${agent}: ${info.configured ? '[OK]' : '[MISSING]'}`);
@@ -4951,6 +5029,12 @@ async function cmdAuthInit() {
4951
5029
  }
4952
5030
 
4953
5031
  if (findHcloudBin()) {
5032
+ // #920: `configure set` refuses empty --cli-security-token values, so a
5033
+ // stale token already in the profile survives the write. When auth init
5034
+ // runs WITHOUT a token, delete the profile first (documented manual
5035
+ // bypass) so the recreated profile cannot keep poisoning calls with
5036
+ // APIGW.0301. A missing profile ("nothing to clean") is not an error.
5037
+ if (!securityToken) resetHcloudProfile();
4954
5038
  const result = configureHcloud({ ak, sk, region });
4955
5039
  if (!result.ok) console.log(`KooCLI update failed: ${result.error || result.code}`);
4956
5040
  else {
@@ -4958,21 +5042,40 @@ async function cmdAuthInit() {
4958
5042
  if (proj.ok) {
4959
5043
  console.log(` Project ID auto-set: ${proj.projectId}`);
4960
5044
  } else {
4961
- console.log(
4962
- `\x1b[33m Project ID not auto-set (${proj.reason}). Sandbox exec may fail with APIGW.0301 until it is configured:\x1b[0m`,
5045
+ // #920: an unconfigured project_id is an ERROR, not a warning — most
5046
+ // hcloud calls fail with APIGW.0301 until it is set.
5047
+ console.error(
5048
+ `\x1b[31m ERROR: Project ID not auto-set (${proj.reason}). hcloud API calls (ECS/VPC/OBS API paths, sandbox exec) will fail with APIGW.0301 Incorrect IAM authentication information until it is configured. Recover with:\x1b[0m`,
4963
5049
  );
4964
- console.log(` hcloud IAM KeystoneListProjects --cli-region=${region} --name=${region}`);
4965
- console.log(` hcloud configure set --cli-project-id=<project_id>`);
5050
+ console.error(` hcloud IAM KeystoneListProjects --cli-region=${region} --name=${region}`);
5051
+ console.error(` hcloud configure set --cli-project-id=<project_id>`);
5052
+ console.error(` (or re-run "npx huaweicloud-devkit auth init", which auto-sets it)`);
5053
+ }
5054
+
5055
+ // #920: verify the profile can actually authenticate before claiming
5056
+ // success — a poisoned profile must never print "synchronized".
5057
+ const identity = verifyHcloudIdentity(region);
5058
+ if (identity.ok) {
5059
+ console.log('\nCredentials synchronized.');
5060
+ } else {
5061
+ console.error(
5062
+ `\x1b[31m ERROR: credentials were written but the KooCLI profile FAILED the hcloud STS GetCallerIdentity verification (exit ${identity.code}). hcloud calls will fail with APIGW.0301 — "Credentials synchronized" is NOT confirmed. Typical cause: a stale/expired security token in the profile or wrong AK/SK. Recover with:\x1b[0m`,
5063
+ );
5064
+ console.error(` hcloud configure delete --cli-profile=${configuredProfileName()}`);
5065
+ console.error(` then re-run "npx huaweicloud-devkit auth init" with AK/SK only (no token)`);
5066
+ console.error(` Last error: ${identity.error || 'unknown'}`);
5067
+ process.exitCode = 1;
4966
5068
  }
4967
5069
  }
4968
5070
  } else {
4969
5071
  console.log('KooCLI not found. Run "npx huaweicloud-devkit install-hcloud" and then "auth sync".');
4970
5072
  }
4971
5073
 
4972
- console.log('\nCredentials synchronized.');
4973
- console.log('\nNext steps:');
4974
- console.log(' npx huaweicloud-devkit install --target all');
4975
- console.log(' Restart your agent sessions.');
5074
+ if (process.exitCode !== 1) {
5075
+ console.log('\nNext steps:');
5076
+ console.log(' npx huaweicloud-devkit install --target all');
5077
+ console.log(' Restart your agent sessions.');
5078
+ }
4976
5079
  }
4977
5080
 
4978
5081
  async function cmdAuthReconcile() {
@@ -53,7 +53,7 @@ import {
53
53
  } from './auth/credentials.mjs';
54
54
  import { trackToolInvoke, trackSkillRetrieve, clearUserHash } from './telemetry/telemetry.mjs';
55
55
  import { fetchWithProxy } from './proxy/proxy-agent.mjs';
56
- import { fingerprint, runHcloudConfigure, resolveManagedProfile } from './auth/reconcile.mjs';
56
+ import { fingerprint, runHcloudConfigure, runHcloudProfileDelete, resolveManagedProfile } from './auth/reconcile.mjs';
57
57
  import {
58
58
  getCachedUpdateInfo,
59
59
  getUpdateDistTags,
@@ -65,7 +65,7 @@ import {
65
65
  resolveSkipFilePath,
66
66
  upgradePackage,
67
67
  } from './update-check.mjs';
68
- import { hcloudProbeNextStep, probeHcloud } from './hcloud-probe.mjs';
68
+ import { hcloudProbeNextStep, hcloudProbeRaw, probeHcloud } from './hcloud-probe.mjs';
69
69
  import { isUsableOfficeaceRoot, readOfficeaceRootMarker } from './officeace-paths.mjs';
70
70
 
71
71
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -1090,7 +1090,20 @@ function persistCredentials(ak, sk, securityToken, region) {
1090
1090
  if (!profile) {
1091
1091
  hcloud = { ok: false, reason: 'KooCLI current profile unresolved' };
1092
1092
  } else {
1093
+ // #920: `configure set` refuses an empty --cli-security-token, so a stale
1094
+ // token already in the profile survives the write and poisons every hcloud
1095
+ // call with APIGW.0301 — while the tool still reports "synchronized". When
1096
+ // persisting WITHOUT a token, walk the documented manual bypass
1097
+ // (delete the profile, then re-configure) so the on-disk profile truly
1098
+ // matches the incoming credentials. The delete is best-effort: a missing
1099
+ // profile ("nothing to clean") must not fail the persist.
1100
+ let profileReset = false;
1101
+ if (!String(securityToken || '')) {
1102
+ const deleted = runHcloudProfileDelete(profile);
1103
+ profileReset = deleted.ok || /not (exist|found)/i.test(deleted.error || '');
1104
+ }
1093
1105
  hcloud = runHcloudConfigure(profile, ak, sk, region);
1106
+ hcloud.profileReset = profileReset;
1094
1107
  }
1095
1108
  if (hcloud.ok) {
1096
1109
  writeLastSync({ kooCliProfile: profile, s1Fingerprint: fingerprint(ak, sk) });
@@ -1101,7 +1114,7 @@ function persistCredentials(ak, sk, securityToken, region) {
1101
1114
  backedUp: Boolean(before),
1102
1115
  obs: obs.ok ? { configured: true } : { configured: false, error: obs.error },
1103
1116
  hcloud,
1104
- note: 'S1 written with configuredBySession (R9), which now takes priority over env-injected credentials; S2(current profile) and S3 synced. Note: running `auth init` later clears the configuredBySession flag and env credentials regain priority.',
1117
+ note: 'S1 written with configuredBySession (R9), which now takes priority over env-injected credentials; S2(current profile) and S3 synced. Persisting without a security token resets the target profile first (delete + recreate) so a stale token cannot survive the write (#920). Note: running `auth init` later clears the configuredBySession flag and env credentials regain priority.',
1105
1118
  };
1106
1119
  }
1107
1120
 
@@ -1662,6 +1675,7 @@ export async function runVersionCheck(options = {}) {
1662
1675
  installedVersion: result.installedVersion || undefined,
1663
1676
  versionMismatch: Boolean(result.versionMismatch),
1664
1677
  nextStep: hcloudProbeNextStep(result),
1678
+ raw: hcloudProbeRaw(result),
1665
1679
  authHint:
1666
1680
  'If hcloud is installed but commands fail with "配置文件中不存在配置项", run `npx huaweicloud-devkit auth init` outside agent chat to configure credentials.',
1667
1681
  };
@@ -2263,6 +2277,8 @@ function explainError({ service = 'unknown', errorCode = '', message = '', reque
2263
2277
  const SERVICE_ALIASES = {
2264
2278
  functiongraph: 'FSS',
2265
2279
  fgs: 'FSS',
2280
+ ecs: 'Ecs',
2281
+ ECS: 'Ecs',
2266
2282
  };
2267
2283
  const patternKey = SERVICE_ALIASES[svc] || service;
2268
2284
 
@@ -2290,6 +2306,10 @@ function explainError({ service = 'unknown', errorCode = '', message = '', reque
2290
2306
  VPC: {
2291
2307
  'VPC.0301': 'Bandwidth name is required for PER type EIPs, even though --help marks it optional.',
2292
2308
  },
2309
+ Ecs: {
2310
+ 'Ecs.0707':
2311
+ 'The flavor + image-type combination does not exist ("the product does not exist") — the error does not say which side is wrong, so check BOTH (#922): (1) the flavor is abandoned/sellout in this region/AZ (check os_extra_specs.cond:operation:status and cond:operation:az; see the huawei-ecs skill references/flavors.md for the AZ-vs-global-status conflict rule); (2) the image TYPE is incompatible with the flavor — market-type images fail with KunPeng flavors (kc1/kc2 etc.). Fix: switch the image to a gold-type image or switch to a compatible general-purpose flavor (verified: kc2.small.2 + market Ubuntu → Ecs.0707; s6.medium.2 + gold Ubuntu works), re-listing with hcloud ECS ListFlavors / hcloud IMS ListImages --__imagetype=gold first.',
2312
+ },
2293
2313
  APIGW: {
2294
2314
  'APIGW.0301':
2295
2315
  'Incorrect IAM authentication information. The AK/SK is invalid (check the SK for typos), the security token is missing or expired, or the profile lacks project_id. Fix: re-run "npx huaweicloud-devkit auth init" (it auto-sets project_id), or set it manually: hcloud configure set --cli-project-id=<project_id> after finding it via hcloud IAM KeystoneListProjects --cli-region=<region> --name=<region>.',