huaweicloud-devkit 1.1.2-next.3 → 1.1.2-next.5
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 +29 -1
- package/README.zh-CN.md +29 -1
- package/integrations/dsh/hook-plugin.mjs +11 -6
- package/integrations/opencode/hooks/skill-tracker.js +10 -9
- package/package.json +1 -1
- package/plugins/huaweicloud-core/.claude-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.codex-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/hooks/hooks.json +2 -2
- package/plugins/huaweicloud-core/hooks/huaweicloud-safety.mjs +56 -0
- package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
- package/plugins/huaweicloud-core/skills/huaweicloud-safety/SKILL.md +1 -1
- package/plugins/huaweicloud-core/src/auth/credential-validator.mjs +163 -0
- package/plugins/huaweicloud-core/src/auth/credentials.mjs +6 -2
- package/plugins/huaweicloud-core/src/auth/project-id.mjs +68 -0
- package/plugins/huaweicloud-core/src/auth/reconcile.mjs +30 -6
- package/plugins/huaweicloud-core/src/auth/service.mjs +22 -23
- package/plugins/huaweicloud-core/src/hcloud-cli.mjs +5 -10
- package/plugins/huaweicloud-core/src/hcloud-probe.mjs +154 -0
- package/plugins/huaweicloud-core/src/proxy/proxy-agent.mjs +7 -0
- package/plugins/huaweicloud-core/src/safety-policy.mjs +17 -5
- package/plugins/huaweicloud-core/src/setup-cli.mjs +192 -134
- package/plugins/huaweicloud-core/src/telemetry/agent-detect.mjs +2 -2
- package/plugins/huaweicloud-core/src/telemetry/agent-registry.mjs +9 -1
- package/plugins/huaweicloud-core/src/telemetry/telemetry.mjs +41 -5
- package/plugins/huaweicloud-core/src/tools.mjs +86 -38
package/README.md
CHANGED
|
@@ -22,13 +22,19 @@ Supports OpenCode, Codex, CodeArts Agent, WorkBuddy, DeepSeek Harness (DSH), Off
|
|
|
22
22
|
> ```
|
|
23
23
|
>
|
|
24
24
|
> Restore the default registry: `npm config delete registry`
|
|
25
|
+
>
|
|
26
|
+
> **Mirror lag**: npm mirrors (npmmirror, mirrors.huaweicloud.com) may lag behind the official registry for hours after a new release. If install fails with `ETARGET` or you get an older version, install via the official registry instead:
|
|
27
|
+
>
|
|
28
|
+
> ```bash
|
|
29
|
+
> npx --yes --registry=https://registry.npmjs.org huaweicloud-devkit install --target <target>
|
|
30
|
+
> ```
|
|
25
31
|
|
|
26
32
|
## Quick Start
|
|
27
33
|
|
|
28
34
|
> If `--target` is omitted, the installer auto-detects agents on your machine. When multiple agents are detected, **all of them** will be installed. Specify `--target` to control which agent receives the install.
|
|
29
35
|
|
|
30
36
|
```bash
|
|
31
|
-
npx --yes huaweicloud-devkit version # print
|
|
37
|
+
npx --yes huaweicloud-devkit version # print CLI version and installed plugin versions per agent
|
|
32
38
|
npx --yes huaweicloud-devkit uninstall --target all --clean-global # also remove KooCLI + OBS config
|
|
33
39
|
```
|
|
34
40
|
|
|
@@ -57,14 +63,36 @@ npx --yes huaweicloud-devkit install --target codex
|
|
|
57
63
|
**Restart the Codex session** after installation.
|
|
58
64
|
|
|
59
65
|
```bash
|
|
66
|
+
codex plugin list # verify huaweicloud-devkit@huaweicloud-devkit is installed and enabled
|
|
60
67
|
npx --yes huaweicloud-devkit doctor --target codex
|
|
61
68
|
npx --yes huaweicloud-devkit status --target codex
|
|
62
69
|
npx --yes huaweicloud-devkit update --target codex
|
|
63
70
|
npx --yes huaweicloud-devkit uninstall --target codex
|
|
64
71
|
```
|
|
65
72
|
|
|
73
|
+
Then mention `@huaweicloud-devkit` in Codex or describe your Huawei Cloud task directly.
|
|
74
|
+
|
|
66
75
|
> **Requires Codex CLI** — the `codex` command must be in PATH. If Codex is installed via WindowsApps (Microsoft Store), use `--target codex-desktop` instead. Run `codex --version` to verify CLI availability.
|
|
67
76
|
|
|
77
|
+
### Codex Desktop
|
|
78
|
+
|
|
79
|
+
Use this target when the Codex CLI is unavailable or when Codex is installed through WindowsApps on Windows.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx --yes huaweicloud-devkit install --target codex-desktop
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Restart the Codex Desktop session** after installation.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx --yes huaweicloud-devkit doctor --target codex-desktop
|
|
89
|
+
npx --yes huaweicloud-devkit status --target codex-desktop
|
|
90
|
+
npx --yes huaweicloud-devkit update --target codex-desktop
|
|
91
|
+
npx --yes huaweicloud-devkit uninstall --target codex-desktop
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Then mention `@huaweicloud-devkit` in a new Codex Desktop task or describe your Huawei Cloud task directly.
|
|
95
|
+
|
|
68
96
|
### CodeArts Agent
|
|
69
97
|
|
|
70
98
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -22,13 +22,19 @@
|
|
|
22
22
|
> ```
|
|
23
23
|
>
|
|
24
24
|
> 恢复默认镜像:`npm config delete registry`
|
|
25
|
+
>
|
|
26
|
+
> **镜像滞后**:npm 镜像(npmmirror、mirrors.huaweicloud.com)在新版本发布后可能滞后官方源数小时。若安装报 `ETARGET` 或拿到旧版本,改用官方源安装:
|
|
27
|
+
>
|
|
28
|
+
> ```bash
|
|
29
|
+
> npx --yes --registry=https://registry.npmjs.org huaweicloud-devkit install --target <target>
|
|
30
|
+
> ```
|
|
25
31
|
|
|
26
32
|
## 快速开始
|
|
27
33
|
|
|
28
34
|
> 省略 `--target` 时,安装器会自动检测机器上的 agent,检测到多个时**全部安装**。建议始终指定 `--target` 以明确安装目标。
|
|
29
35
|
|
|
30
36
|
```bash
|
|
31
|
-
npx --yes huaweicloud-devkit version #
|
|
37
|
+
npx --yes huaweicloud-devkit version # 查看 CLI 版本和各 agent 已安装的插件版本
|
|
32
38
|
npx --yes huaweicloud-devkit uninstall --target all --clean-global # 一并删除 KooCLI 与 OBS 配置
|
|
33
39
|
```
|
|
34
40
|
|
|
@@ -57,14 +63,36 @@ npx --yes huaweicloud-devkit install --target codex
|
|
|
57
63
|
安装后**重启 Codex 会话**。
|
|
58
64
|
|
|
59
65
|
```bash
|
|
66
|
+
codex plugin list # 验证 huaweicloud-devkit@huaweicloud-devkit 已安装并启用
|
|
60
67
|
npx --yes huaweicloud-devkit doctor --target codex
|
|
61
68
|
npx --yes huaweicloud-devkit status --target codex
|
|
62
69
|
npx --yes huaweicloud-devkit update --target codex
|
|
63
70
|
npx --yes huaweicloud-devkit uninstall --target codex
|
|
64
71
|
```
|
|
65
72
|
|
|
73
|
+
随后在 Codex 中提及 `@huaweicloud-devkit`,或直接描述华为云任务。
|
|
74
|
+
|
|
66
75
|
> **需要 Codex CLI** — `codex` 命令必须在 PATH 中。若 Codex 通过 WindowsApps(Microsoft Store)安装,请使用 `--target codex-desktop` 替代。运行 `codex --version` 验证 CLI 可用性。
|
|
67
76
|
|
|
77
|
+
### Codex Desktop
|
|
78
|
+
|
|
79
|
+
当 Codex CLI 不可用,或 Windows 上通过 WindowsApps 安装 Codex 时,使用此目标。
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx --yes huaweicloud-devkit install --target codex-desktop
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
安装后**重启 Codex Desktop 会话**。
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx --yes huaweicloud-devkit doctor --target codex-desktop
|
|
89
|
+
npx --yes huaweicloud-devkit status --target codex-desktop
|
|
90
|
+
npx --yes huaweicloud-devkit update --target codex-desktop
|
|
91
|
+
npx --yes huaweicloud-devkit uninstall --target codex-desktop
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
随后在新的 Codex Desktop 任务中提及 `@huaweicloud-devkit`,或直接描述华为云任务。
|
|
95
|
+
|
|
68
96
|
### CodeArts Agent(码道)
|
|
69
97
|
|
|
70
98
|
```bash
|
|
@@ -65,18 +65,23 @@ function isHuaweiCloudSkill(name) {
|
|
|
65
65
|
// hcloud command classification (mirrors skill-tracker.js)
|
|
66
66
|
// ══════════════════════════════════════════════════════════════════
|
|
67
67
|
|
|
68
|
-
const HCLOUD_RE = /(?:^|[;&|]\s*)hcloud(?:\.exe)?\s+(
|
|
68
|
+
const HCLOUD_RE = /(?:^|[;&|]\s*)hcloud(?:\.exe)?\s+([^\s;&|"<>]+(?:\s+[^\s;&|"<>]+){0,3})/i;
|
|
69
69
|
const READ_VERBS = /\b(List|Show|Get|Describe|NovaList|NovaShow)\w*/i;
|
|
70
70
|
const WRITE_VERBS =
|
|
71
71
|
/\b(Create|Delete|Update|Modify|Remove|Revoke|Grant|Attach|Detach|Enable|Disable|Set|Add|Bind|Unbind|Reset|Change|Activate|Deactivate|Register|Unregister|Import|Export|Download|Upload|Copy|Move|Convert|Migrate|Run|Execute|Invoke|Trigger|Deploy|Push|Start|Stop|Restart|Reboot|Suspend|Resume|Terminate|Release|Allocate)\w*/i;
|
|
72
72
|
|
|
73
|
-
function classifyHcloud(text) {
|
|
73
|
+
export function classifyHcloud(text) {
|
|
74
74
|
const m = HCLOUD_RE.exec(text);
|
|
75
75
|
if (!m) return null;
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
76
|
+
const raw = m[1].trim();
|
|
77
|
+
if (!raw) return null;
|
|
78
|
+
const cmdTokens = [];
|
|
79
|
+
for (const t of raw.split(/\s+/)) {
|
|
80
|
+
if (t.startsWith('--')) break;
|
|
81
|
+
cmdTokens.push(t);
|
|
82
|
+
}
|
|
83
|
+
if (cmdTokens.length === 0) return null;
|
|
84
|
+
const cmd = cmdTokens.join(' ');
|
|
80
85
|
if (READ_VERBS.test(cmd)) return { key: 'cli:read', value: `hcloud ${cmd}`, capability: 'cli' };
|
|
81
86
|
if (WRITE_VERBS.test(cmd)) return { key: 'cli:write', value: `hcloud ${cmd}`, capability: 'cli' };
|
|
82
87
|
return { key: 'cli:invoke', value: `hcloud ${cmd}`, capability: 'cli' };
|
|
@@ -28,7 +28,7 @@ function writeEvent(key, value, extra = {}) {
|
|
|
28
28
|
|
|
29
29
|
// ── CLI command classification ────────────────────────────────
|
|
30
30
|
|
|
31
|
-
const HCLOUD_RE = /(?:^|[;&|]\s*)hcloud(?:\.exe)?\s+(
|
|
31
|
+
const HCLOUD_RE = /(?:^|[;&|]\s*)hcloud(?:\.exe)?\s+([^\s;&|"<>]+(?:\s+[^\s;&|"<>]+){0,3})/i;
|
|
32
32
|
const READ_VERBS = /\b(List|Show|Get|Describe|NovaList|NovaShow)\w*/i;
|
|
33
33
|
const WRITE_VERBS = new RegExp(
|
|
34
34
|
'\\b(Create|Delete|Update|Modify|Remove|Revoke|Grant|Attach|Detach|' +
|
|
@@ -42,14 +42,15 @@ const WRITE_VERBS = new RegExp(
|
|
|
42
42
|
function classifyHcloud(text) {
|
|
43
43
|
const m = HCLOUD_RE.exec(text);
|
|
44
44
|
if (!m) return null;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
.
|
|
50
|
-
.
|
|
51
|
-
|
|
52
|
-
if (
|
|
45
|
+
const raw = m[1].trim();
|
|
46
|
+
if (!raw) return null;
|
|
47
|
+
const cmdTokens = [];
|
|
48
|
+
for (const t of raw.split(/\s+/)) {
|
|
49
|
+
if (t.startsWith('--')) break;
|
|
50
|
+
cmdTokens.push(t);
|
|
51
|
+
}
|
|
52
|
+
if (cmdTokens.length === 0) return null;
|
|
53
|
+
const cmd = cmdTokens.join(' ');
|
|
53
54
|
if (READ_VERBS.test(cmd)) return { key: 'cli:read', value: `hcloud ${cmd}` };
|
|
54
55
|
if (WRITE_VERBS.test(cmd)) return { key: 'cli:write', value: `hcloud ${cmd}` };
|
|
55
56
|
return { key: 'cli:invoke', value: `hcloud ${cmd}` };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "huaweicloud-devkit",
|
|
3
3
|
"mcpName": "io.github.huaweicloud/huaweicloud-devkit",
|
|
4
|
-
"version": "1.1.2-next.
|
|
4
|
+
"version": "1.1.2-next.5",
|
|
5
5
|
"kooCliVersion": "7.2.12",
|
|
6
6
|
"description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
|
|
7
7
|
"type": "module",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"mcpServers": "./.mcp.json",
|
|
18
18
|
"description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
|
|
19
19
|
"skills": "./skills/",
|
|
20
|
-
"version": "1.1.2-next.
|
|
20
|
+
"version": "1.1.2-next.5",
|
|
21
21
|
"author": {
|
|
22
22
|
"name": "HuaweiCloud Mate",
|
|
23
23
|
"url": "https://github.com/huaweicloud"
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"mcpServers": "./.mcp.json",
|
|
21
21
|
"description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
|
|
22
22
|
"skills": "./skills/",
|
|
23
|
-
"version": "1.1.2-next.
|
|
23
|
+
"version": "1.1.2-next.5",
|
|
24
24
|
"author": {
|
|
25
25
|
"name": "HuaweiCloud Mate",
|
|
26
26
|
"url": "https://github.com/huaweicloud"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"mcpServers": "./.mcp.json",
|
|
18
18
|
"description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
|
|
19
19
|
"skills": "./skills/",
|
|
20
|
-
"version": "1.1.2-next.
|
|
20
|
+
"version": "1.1.2-next.5",
|
|
21
21
|
"author": {
|
|
22
22
|
"name": "HuaweiCloud Mate",
|
|
23
23
|
"url": "https://github.com/huaweicloud"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "huaweicloud-devkit",
|
|
3
|
-
"version": "1.1.2-next.
|
|
3
|
+
"version": "1.1.2-next.5",
|
|
4
4
|
"description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "HuaweiCloud Mate",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "huaweicloud-devkit",
|
|
3
|
-
"version": "1.1.2-next.
|
|
3
|
+
"version": "1.1.2-next.5",
|
|
4
4
|
"description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "HuaweiCloud Mate",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "
|
|
9
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/huaweicloud-safety.mjs\"",
|
|
10
10
|
"timeout": 5
|
|
11
11
|
}
|
|
12
12
|
]
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"hooks": [
|
|
17
17
|
{
|
|
18
18
|
"type": "command",
|
|
19
|
-
"command": "
|
|
19
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/huaweicloud-safety.mjs\"",
|
|
20
20
|
"timeout": 5
|
|
21
21
|
}
|
|
22
22
|
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { classifyTextCommand } from '../src/safety-policy.mjs';
|
|
4
|
+
|
|
5
|
+
const DENY_PREFIX = 'Huawei Cloud safety hook blocked this action: ';
|
|
6
|
+
|
|
7
|
+
function commandText(toolInput) {
|
|
8
|
+
if (typeof toolInput === 'string') return toolInput;
|
|
9
|
+
if (toolInput && typeof toolInput === 'object') {
|
|
10
|
+
const values = [];
|
|
11
|
+
for (const key of ['command', 'cmd', 'script', 'args', 'arguments']) {
|
|
12
|
+
const value = toolInput[key];
|
|
13
|
+
if (Array.isArray(value)) values.push(value.map(String).join(' '));
|
|
14
|
+
else if (value !== undefined && value !== null) values.push(String(value));
|
|
15
|
+
}
|
|
16
|
+
if (values.length > 0) return values.join('\n');
|
|
17
|
+
return JSON.stringify(toolInput);
|
|
18
|
+
}
|
|
19
|
+
return JSON.stringify(toolInput);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function deny(reason) {
|
|
23
|
+
process.stdout.write(
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
hookSpecificOutput: {
|
|
26
|
+
hookEventName: 'PreToolUse',
|
|
27
|
+
permissionDecision: 'deny',
|
|
28
|
+
permissionDecisionReason: DENY_PREFIX + reason,
|
|
29
|
+
},
|
|
30
|
+
}) + '\n',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readStdin() {
|
|
35
|
+
try {
|
|
36
|
+
return readFileSync(0, 'utf8');
|
|
37
|
+
} catch {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function main() {
|
|
43
|
+
const input = readStdin();
|
|
44
|
+
let data;
|
|
45
|
+
try {
|
|
46
|
+
data = JSON.parse(input);
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const text = commandText(data.tool_input ?? {});
|
|
52
|
+
const result = classifyTextCommand(text);
|
|
53
|
+
if (result.decision === 'deny') deny(result.reason);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
main();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "huaweicloud-devkit",
|
|
3
3
|
"id": "huaweicloud-devkit",
|
|
4
4
|
"displayName": "HuaweiCloud DevKit",
|
|
5
|
-
"version": "1.1.2-next.
|
|
5
|
+
"version": "1.1.2-next.5",
|
|
6
6
|
"family": "bundle-plugin",
|
|
7
7
|
"bundleFormat": "codex",
|
|
8
8
|
"description": "Guide coding agents to use Huawei Cloud safely — KooCLI, APIs, SDKs, 28 MCP tools, skills, and safety guardrails.",
|
|
@@ -27,7 +27,7 @@ Use this skill before any Huawei Cloud action that may expose secrets, change re
|
|
|
27
27
|
|
|
28
28
|
## Enforcement Layers
|
|
29
29
|
|
|
30
|
-
- Hooks: `hooks/huaweicloud-safety.
|
|
30
|
+
- Hooks: `hooks/huaweicloud-safety.mjs` blocks risky Codex native hook calls without requiring Python; `hooks/huaweicloud-safety.py` remains for Hermes/Python hook compatibility.
|
|
31
31
|
- MCP wrapper: the Node MCP server applies the same safety policy for Codex/OpenCode paths that do not enforce hooks.
|
|
32
32
|
- Skills: this document teaches agents the rule before they act.
|
|
33
33
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { getProxyDispatcher } from '../proxy/proxy-agent.mjs';
|
|
4
|
+
|
|
5
|
+
function iamBaseUrl() {
|
|
6
|
+
return process.env.HW_IAM_ENDPOINT || 'https://iam.myhuaweicloud.com';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function sha256Hex(data) {
|
|
10
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hmacSha256(key, data) {
|
|
14
|
+
return crypto.createHmac('sha256', key).update(data).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function urlEncode(str) {
|
|
18
|
+
const hex = (c) => '%' + (c < 16 ? '0' : '') + c.toString(16).toUpperCase();
|
|
19
|
+
const noEscape = new Set('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~'.split(''));
|
|
20
|
+
let out = '';
|
|
21
|
+
for (const ch of str) {
|
|
22
|
+
const c = ch.codePointAt(0);
|
|
23
|
+
out += noEscape.has(ch) && c < 0x80 ? ch : c < 0x80 ? hex(c) : encodeURIComponent(ch);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function timestamp() {
|
|
29
|
+
return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '') + 'Z';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function signIamRequest(path, query, ak, sk, securitytoken) {
|
|
33
|
+
const ts = timestamp();
|
|
34
|
+
const host = new URL(iamBaseUrl()).host;
|
|
35
|
+
|
|
36
|
+
const cqs = Object.entries(query)
|
|
37
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
38
|
+
.map(([k, v]) => `${urlEncode(k)}=${urlEncode(v)}`)
|
|
39
|
+
.join('&');
|
|
40
|
+
|
|
41
|
+
const curi =
|
|
42
|
+
'/' +
|
|
43
|
+
path
|
|
44
|
+
.split('/')
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.map((s) => urlEncode(s))
|
|
47
|
+
.join('/') +
|
|
48
|
+
'/';
|
|
49
|
+
|
|
50
|
+
const signedHeaders = securitytoken ? 'host;x-sdk-date;x-security-token' : 'host;x-sdk-date';
|
|
51
|
+
const canonicalHeaders = securitytoken
|
|
52
|
+
? `host:${host}\nx-sdk-date:${ts}\nx-security-token:${securitytoken}\n`
|
|
53
|
+
: `host:${host}\nx-sdk-date:${ts}\n`;
|
|
54
|
+
|
|
55
|
+
const payloadHash = sha256Hex('');
|
|
56
|
+
const canonicalRequest = ['GET', curi, cqs, canonicalHeaders, signedHeaders, payloadHash].join('\n');
|
|
57
|
+
const stringToSign = `SDK-HMAC-SHA256\n${ts}\n${sha256Hex(canonicalRequest)}`;
|
|
58
|
+
const signature = hmacSha256(sk, stringToSign);
|
|
59
|
+
|
|
60
|
+
const headers = {
|
|
61
|
+
host,
|
|
62
|
+
'x-sdk-date': ts,
|
|
63
|
+
Authorization: `SDK-HMAC-SHA256 Access=${ak}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
64
|
+
};
|
|
65
|
+
if (securitytoken) {
|
|
66
|
+
headers['x-security-token'] = securitytoken;
|
|
67
|
+
}
|
|
68
|
+
return headers;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function projectForRegion(projects, region) {
|
|
72
|
+
if (region) {
|
|
73
|
+
const match = projects.find((p) => p && p.name === region && p.id);
|
|
74
|
+
if (match) return match.id;
|
|
75
|
+
}
|
|
76
|
+
return projects.find((p) => p && p.id)?.id || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate AK/SK by calling IAM KeystoneListProjects (read-only) with
|
|
81
|
+
* SDK-HMAC-SHA256 request signing. A wrong SK produces an invalid signature,
|
|
82
|
+
* which IAM rejects with HTTP 401 before any project data is returned.
|
|
83
|
+
*
|
|
84
|
+
* Returns { valid, projectId, error, warning }:
|
|
85
|
+
* - valid: true when IAM verified the signature (or the rejection is not
|
|
86
|
+
* authentication-related), false when the credentials are unusable.
|
|
87
|
+
* - projectId: first project matching `region`, else the first visible project.
|
|
88
|
+
* - warning: set when credentials passed but project discovery was denied.
|
|
89
|
+
*/
|
|
90
|
+
export async function validateIamCredentials({ ak, sk, securityToken, region, timeoutMs = 15000 } = {}) {
|
|
91
|
+
if (!ak || !sk) {
|
|
92
|
+
return { valid: false, projectId: null, error: 'AK and SK are both required for credential validation.' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const base = iamBaseUrl();
|
|
96
|
+
const path = '/v3/projects';
|
|
97
|
+
const query = region ? { name: region } : {};
|
|
98
|
+
const qs = Object.entries(query)
|
|
99
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
100
|
+
.join('&');
|
|
101
|
+
const url = `${base}${path}${qs ? `?${qs}` : ''}`;
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
104
|
+
|
|
105
|
+
let resp;
|
|
106
|
+
try {
|
|
107
|
+
const headers = signIamRequest(path, query, ak, sk, securityToken);
|
|
108
|
+
const fetchOpts = { headers, signal: controller.signal };
|
|
109
|
+
const dispatcher = await getProxyDispatcher(url);
|
|
110
|
+
if (dispatcher) {
|
|
111
|
+
fetchOpts.dispatcher = dispatcher;
|
|
112
|
+
const { fetch: undiciFetch } = await import('undici');
|
|
113
|
+
resp = await undiciFetch(url, fetchOpts);
|
|
114
|
+
} else {
|
|
115
|
+
resp = await fetch(url, fetchOpts);
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
valid: false,
|
|
120
|
+
projectId: null,
|
|
121
|
+
skipped: true,
|
|
122
|
+
error: `IAM validation request failed (treating credentials as unverified): ${error.message}`,
|
|
123
|
+
};
|
|
124
|
+
} finally {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const text = await resp.text();
|
|
129
|
+
let data = null;
|
|
130
|
+
try {
|
|
131
|
+
data = JSON.parse(text);
|
|
132
|
+
} catch {}
|
|
133
|
+
|
|
134
|
+
if (resp.status === 200) {
|
|
135
|
+
const projects = Array.isArray(data?.projects) ? data.projects : [];
|
|
136
|
+
return { valid: true, projectId: projectForRegion(projects, region), error: null, warning: null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (resp.status === 401) {
|
|
140
|
+
const msg = data?.error?.message || text.slice(0, 200);
|
|
141
|
+
return {
|
|
142
|
+
valid: false,
|
|
143
|
+
projectId: null,
|
|
144
|
+
error: `IAM rejected the credentials (HTTP 401: ${msg}). The AK/SK is invalid - check the SK for typos or expired security tokens.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (resp.status === 403) {
|
|
149
|
+
return {
|
|
150
|
+
valid: true,
|
|
151
|
+
projectId: null,
|
|
152
|
+
error: null,
|
|
153
|
+
warning: `Credentials signed successfully but project listing was denied (HTTP 403). Continuing without project_id.`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
valid: false,
|
|
159
|
+
projectId: null,
|
|
160
|
+
skipped: true,
|
|
161
|
+
error: `Unexpected IAM response (HTTP ${resp.status}): ${text.slice(0, 200)}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -272,10 +272,14 @@ export function readLastSync() {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
export function writeLastSync() {
|
|
275
|
+
export function writeLastSync(metadata = {}) {
|
|
276
276
|
const path = lastSyncPath();
|
|
277
277
|
mkdirSync(dirname(path), { recursive: true });
|
|
278
|
-
const payload = {
|
|
278
|
+
const payload = {
|
|
279
|
+
ts: Date.now(),
|
|
280
|
+
...(metadata.kooCliProfile ? { kooCliProfile: String(metadata.kooCliProfile) } : {}),
|
|
281
|
+
...(metadata.s1Fingerprint ? { s1Fingerprint: String(metadata.s1Fingerprint) } : {}),
|
|
282
|
+
};
|
|
279
283
|
writeFileSync(path, JSON.stringify(payload), { encoding: 'utf8', mode: 0o600 });
|
|
280
284
|
ensurePrivateMode(path);
|
|
281
285
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
function hcloudCommand(args) {
|
|
5
|
+
const bin = process.env.HCLOUD_BIN || 'hcloud';
|
|
6
|
+
// Test doubles (and exotic setups) may point HCLOUD_BIN at a Node script;
|
|
7
|
+
// those must be launched through node instead of the shell.
|
|
8
|
+
if (/\.(mjs|cjs|js)$/i.test(bin) && existsSync(bin)) {
|
|
9
|
+
return { file: process.execPath, args: [bin, ...args] };
|
|
10
|
+
}
|
|
11
|
+
return { file: bin, args };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function runHcloud(args, timeoutMs = 20000) {
|
|
15
|
+
const { file, args: spawnArgs } = hcloudCommand(args);
|
|
16
|
+
return spawnSync(file, spawnArgs, {
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
stdio: 'pipe',
|
|
19
|
+
timeout: timeoutMs,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the project_id for `region` via IAM KeystoneListProjects using the
|
|
25
|
+
* credentials already stored in the KooCLI profile, then write it back with
|
|
26
|
+
* `hcloud configure set --cli-project-id=<id>`. Read-only discovery + local
|
|
27
|
+
* config write only - no secrets appear in process arguments because the
|
|
28
|
+
* profile carries the credentials.
|
|
29
|
+
*
|
|
30
|
+
* Best-effort by design: any failure returns { ok: false, reason } and never
|
|
31
|
+
* throws, so callers can stay non-fatal.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveAndApplyProjectId({ region, profile } = {}) {
|
|
34
|
+
if (!region) return { ok: false, reason: 'region is required' };
|
|
35
|
+
const profileArgs = profile ? [`--cli-profile=${profile}`] : [];
|
|
36
|
+
try {
|
|
37
|
+
const list = runHcloud([
|
|
38
|
+
'IAM',
|
|
39
|
+
'KeystoneListProjects',
|
|
40
|
+
...profileArgs,
|
|
41
|
+
`--cli-region=${region}`,
|
|
42
|
+
`--name=${region}`,
|
|
43
|
+
]);
|
|
44
|
+
if (list.status !== 0) {
|
|
45
|
+
return { ok: false, reason: `KeystoneListProjects failed (exit ${list.status})` };
|
|
46
|
+
}
|
|
47
|
+
let projects = null;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(String(list.stdout || ''));
|
|
50
|
+
projects = Array.isArray(parsed?.projects) ? parsed.projects : Array.isArray(parsed) ? parsed : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return { ok: false, reason: 'could not parse KeystoneListProjects output' };
|
|
53
|
+
}
|
|
54
|
+
if (!projects || projects.length === 0) {
|
|
55
|
+
return { ok: false, reason: `no projects found for region ${region}` };
|
|
56
|
+
}
|
|
57
|
+
const match = projects.find((p) => p && p.name === region && p.id) || projects.find((p) => p && p.id);
|
|
58
|
+
if (!match) return { ok: false, reason: 'project list contained no usable ids' };
|
|
59
|
+
|
|
60
|
+
const set = runHcloud(['configure', 'set', ...profileArgs, `--cli-project-id=${match.id}`]);
|
|
61
|
+
if (set.status !== 0) {
|
|
62
|
+
return { ok: false, reason: `configure set --cli-project-id failed (exit ${set.status})` };
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, projectId: match.id };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { ok: false, reason: error.message };
|
|
67
|
+
}
|
|
68
|
+
}
|