huaweicloud-devkit 1.1.4 → 1.1.6-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
5
5
  [![CI](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml/badge.svg)](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/huaweicloud-devkit)](https://www.npmjs.com/package/huaweicloud-devkit)
7
- [![Beta](https://img.shields.io/badge/beta-v1.1.4-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
7
+ [![Beta](https://img.shields.io/badge/beta-v1.1.6-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
8
8
 
9
9
  **[中文](README.zh-CN.md) | English**
10
10
 
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
5
5
  [![CI](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml/badge.svg)](https://github.com/huaweicloud/huaweicloud-devkit/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/huaweicloud-devkit)](https://www.npmjs.com/package/huaweicloud-devkit)
7
- [![Beta](https://img.shields.io/badge/beta-v1.1.4-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
7
+ [![Beta](https://img.shields.io/badge/beta-v1.1.6-orange)](https://github.com/huaweicloud/huaweicloud-devkit)
8
8
 
9
9
  **中文 | [English](README.md)**
10
10
 
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.4",
4
+ "version": "1.1.6-next.0",
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.4",
20
+ "version": "1.1.6-next.0",
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.4",
23
+ "version": "1.1.6-next.0",
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.4",
20
+ "version": "1.1.6-next.0",
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.4",
3
+ "version": "1.1.6-next.0",
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.4",
3
+ "version": "1.1.6-next.0",
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",
@@ -2,7 +2,7 @@
2
2
  "name": "huaweicloud-devkit",
3
3
  "id": "huaweicloud-devkit",
4
4
  "displayName": "HuaweiCloud DevKit",
5
- "version": "1.1.4",
5
+ "version": "1.1.6-next.0",
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.",
@@ -8,6 +8,24 @@ version: 1
8
8
 
9
9
  **STOP - Do not answer from general knowledge.** Follow the procedure below.
10
10
 
11
+ ## Target-Selection Gate (MANDATORY — do not skip)
12
+
13
+ When the developer asks to **deploy / host / publish / 上线 / 部署 / 发布 / 搭建** a website, web app, or static site WITHOUT naming a specific target, you MUST present the target options and obtain an explicit choice BEFORE calling any sandbox lifecycle tool (`huaweicloud_sandbox_check_user`, `huaweicloud_sandbox_connect`, `huaweicloud_sandbox_upload_project`, `huaweicloud_sandbox_deploy_nginx`, etc.).
14
+
15
+ Use the `question` tool and present, in this exact order:
16
+
17
+ 1. **沙箱 (Sandbox)** — temporary runtime, instant preview URL (recommended for quick preview)
18
+ 2. **OBS 静态托管** — long-term static hosting + CDN + custom domain
19
+ 3. **ECS** — server-based hosting
20
+ 4. **CCE** — container-based hosting
21
+
22
+ Hard rules:
23
+
24
+ - **NEVER infer or default the target** when it is unstated. Calling `huaweicloud_sandbox_connect` before this choice is made is a violation.
25
+ - Only skip the question when: (a) the developer already named a target (e.g. "部署到 ECS", "用 OBS"), or (b) the developer explicitly asked for a temporary / sandbox / preview runtime.
26
+ - "部署到华为云" / "deploy to Huawei Cloud" / "上线" alone is NOT a named target — still run the gate.
27
+ - After the choice: sandbox → continue with this skill; OBS → `huawei-obs`; ECS → `huawei-ecs` / `huawei-iac`; CCE → `huawei-cce`.
28
+
11
29
  ## Overview
12
30
 
13
31
  Domain expertise for Huawei Cloud Sandbox (DevStation) instances and workspace terminal execution. Covers sandbox lifecycle via hdkitservice API and remote terminal command execution via hwlink protocol.
@@ -15,7 +33,7 @@ Domain expertise for Huawei Cloud Sandbox (DevStation) instances and workspace t
15
33
  ## Activation
16
34
 
17
35
  - **Proactive offering**: when the developer's task needs a temporary runtime (e.g. "deploy this web app", "run this app and preview it"), offer the sandbox proactively — the developer never has to say "use sandbox". Prompt: "This task can be satisfied by a sandbox — use it?"
18
- - **Static website hosting — present options, sandbox first**: when the developer asks to deploy/host/publish a static website or web app without naming a target, do NOT silently pick a service. Present the target options in this order: ① 沙箱 (recommended — temporary runtime, instant preview URL) ② OBS 静态托管 (long-term static hosting/CDN) ③ ECS ④ CCE, then follow the developer's choice. Recommended prompt: "建议优先部署到沙箱(临时运行环境,可预览访问),也可选择 OBS 静态托管、ECS 或 CCE,你想部署到哪里?" If the developer has no preference, proceed with the sandbox.
36
+ - **Static website hosting — present options, sandbox first (MANDATORY)**: when the developer asks to deploy/host/publish a static website or web app without naming a target, do NOT silently pick a service and do NOT default to the sandbox. Run the [Target-Selection Gate](#target-selection-gate-mandatory--do-not-skip) via the `question` tool first. Present, in order: ① 沙箱 (recommended — temporary runtime, instant preview URL) ② OBS 静态托管 (long-term static hosting/CDN) ③ ECS ④ CCE. Recommended prompt: "建议优先部署到沙箱(临时运行环境,可预览访问),也可选择 OBS 静态托管、ECS 或 CCE,你想部署到哪里?" Only if the developer, after being asked, expresses no preference may you proceed with the sandbox.
19
37
  - **Detect web apps in deploy-a-repo prompts**: prompts like "部署 GitHub - <owner>/<repo>" or "deploy <github repo>" do not say "web app". Clone or inspect the repository first (package.json with dev/serve/build scripts, index.html, frontend framework dependencies such as vue/react/angular/vite, static sites, Dockerfile running a web server). If it is a web application, offer the sandbox first: "检测到该项目是 Web 应用,建议优先使用沙箱部署(临时运行环境,可预览访问),是否使用?"
20
38
  - **Clone into the sandbox workspace directory**: always put project code under `/workspace/<repo-name>` (create the directory if missing) — `/workspace` is the sandbox's dedicated workspace mount at the filesystem root, not `$HOME/workspace`. Never use `/tmp` or other ephemeral locations. This keeps the project with the sandbox session, is easy to reference for serving/exposing, and survives session-level restarts of the shell.
21
39
  - **Deployment must end with a public URL**: after deploying and exposing the app with DevBridge, always return the tunnel URL to the developer as the final result — a deployment without an accessible link is incomplete.
@@ -86,6 +104,8 @@ Domain expertise for Huawei Cloud Sandbox (DevStation) instances and workspace t
86
104
 
87
105
  ## Workflow
88
106
 
107
+ **Step 0 — Target-Selection Gate (MANDATORY)**: before any sandbox lifecycle tool, confirm the target. If the developer asked to deploy/host/publish a site without naming a target, STOP and run the [Target-Selection Gate](#target-selection-gate-mandatory--do-not-skip) first; do NOT call `huaweicloud_sandbox_connect` until it is resolved. Proceed with this workflow only if the target is already confirmed as sandbox (or a temporary runtime).
108
+
89
109
  Setup is a **plugin-side preflight** — the developer should be asked a question only once, when the agreement actually needs signing:
90
110
 
91
111
  1. **Check user** (transparent): `huaweicloud_sandbox_check_user` — returns `realnameVerified`/`agreementSigned` (200) when all good, OR throws a 403 error with one of these codes:
@@ -836,6 +856,7 @@ Returns `complete: true/false`, `score`, and `nextStep` to fix missing items.
836
856
 
837
857
  | Trap | Why |
838
858
  | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
859
+ | Target not confirmed | "部署到华为云" without a named target is NOT a go signal. You MUST run the Target-Selection Gate and get an explicit choice before calling any sandbox lifecycle tool. Skipping it and defaulting to the sandbox is a violation. |
839
860
  | Agreement required first | `sandbox_connect` fails if the agreement isn't signed; the `sandbox_check_user` preflight detects this, so surface it to the developer only when signing is needed |
840
861
  | Real-name required | `sandbox_connect` fails if `realnameVerified=false`; tell the developer once and stop, don't loop on connect |
841
862
  | Never expose tunnel details | Do not mention "DevBridge"/"tunnel"/"devbridge" to the developer — say "正在生成访问地址..." and hand over only the URL |
@@ -1,25 +1,9 @@
1
- import { readFileSync } from 'node:fs';
2
- import { resolve, dirname, join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
-
5
1
  import { TOOL_DEFINITIONS, callTool } from './tools.mjs';
6
- import { peekCachedUpdateInfo, applyUpdateHint } from './update-check.mjs';
2
+ import { peekCachedUpdateInfo, applyUpdateHint, readInstalledVersion } from './update-check.mjs';
7
3
  import { initTelemetry } from './telemetry/telemetry.mjs';
8
4
  import { detectAgent } from './telemetry/agent-detect.mjs';
9
5
 
10
- const __dirname = dirname(fileURLToPath(import.meta.url));
11
- const pluginRoot = resolve(__dirname, '..');
12
- const packageRoot = resolve(pluginRoot, '..', '..');
13
- let pkgVersion = '0.0.0';
14
- for (const base of [pluginRoot, packageRoot]) {
15
- try {
16
- const version = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8')).version;
17
- if (version) {
18
- pkgVersion = version;
19
- break;
20
- }
21
- } catch {}
22
- }
6
+ const pkgVersion = readInstalledVersion() || '0.0.0';
23
7
 
24
8
  // 会话内首个非 check/upgrade 工具调用附加 _updateInfo,只消费一次。
25
9
  // 按会话隔离:同进程内不同会话(A/B)各自首次提示;stdio 用固定 'stdin'。
@@ -92,5 +76,9 @@ export async function dispatch(method, params, opts = {}) {
92
76
  return { resources: [] };
93
77
  }
94
78
 
95
- throw new Error(`Unsupported method: ${method}`);
79
+ // JSON-RPC 2.0: unknown methods must surface as -32601 (Method not found),
80
+ // not the generic -32603 internal error (#650 D9-2).
81
+ const methodError = new Error(`Method not found: ${method}`);
82
+ methodError.code = -32601;
83
+ throw methodError;
96
84
  }
@@ -60,7 +60,7 @@ export async function startRemoteServer({ port = DEFAULT_PORT, host = DEFAULT_HO
60
60
  response = {
61
61
  jsonrpc: '2.0',
62
62
  id: message.id,
63
- error: { code: -32603, message: error.message },
63
+ error: { code: Number.isSafeInteger(error.code) ? error.code : -32603, message: error.message },
64
64
  };
65
65
  }
66
66
 
@@ -166,7 +166,7 @@ function runStdioServer() {
166
166
  jsonrpc: '2.0',
167
167
  id: message.id,
168
168
  error: {
169
- code: -32603,
169
+ code: Number.isSafeInteger(error.code) ? error.code : -32603,
170
170
  message: error.message,
171
171
  },
172
172
  });
@@ -0,0 +1,51 @@
1
+ // Shared OfficeAce config-root resolution used by both the installer
2
+ // (setup-cli.mjs) and the MCP runtime (tools.mjs), so skills are installed
3
+ // to and read from the same location (#559).
4
+ //
5
+ // OfficeAce exposes no API for the plugin to query the current user or
6
+ // install directory, so the root is located by probing. The probe result is
7
+ // persisted in a marker file so a non-standard directory (entered via prompt,
8
+ // whose OFFICE_CLAW_CONFIG_ROOT env does not survive across processes) is
9
+ // still found by later uninstall/update runs.
10
+
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
12
+ import { join, resolve, dirname } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ function markerPath() {
16
+ return join(process.env.HUAWEICLOUD_HOME || homedir(), '.config', 'huaweicloud', 'devkit-officeace-root.json');
17
+ }
18
+
19
+ function sqliteSibling(dir) {
20
+ return join(resolve(dir, '..'), 'data', 'mcp-connectors.sqlite');
21
+ }
22
+
23
+ // A directory is a usable OfficeAce config root when it exists and carries
24
+ // OfficeAce evidence — capabilities.json, the connector DB, or DevKit's own
25
+ // installed artifacts. Deliberately more lenient than "capabilities.json must
26
+ // exist" so uninstall/update still find the root after it was removed.
27
+ export function isUsableOfficeaceRoot(dir) {
28
+ if (!dir || !existsSync(dir)) return false;
29
+ return (
30
+ existsSync(join(dir, 'capabilities.json')) ||
31
+ existsSync(sqliteSibling(dir)) ||
32
+ existsSync(join(dir, 'skills')) ||
33
+ existsSync(join(dir, 'huaweicloud-plugins'))
34
+ );
35
+ }
36
+
37
+ export function readOfficeaceRootMarker() {
38
+ try {
39
+ const marker = JSON.parse(readFileSync(markerPath(), 'utf8'));
40
+ return isUsableOfficeaceRoot(marker.root) ? marker.root : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function writeOfficeaceRootMarker(root) {
47
+ try {
48
+ mkdirSync(dirname(markerPath()), { recursive: true });
49
+ writeFileSync(markerPath(), JSON.stringify({ root, savedAt: new Date().toISOString() }, null, 2));
50
+ } catch {}
51
+ }
@@ -108,15 +108,57 @@ function evaluate(stage, inputs, options = {}) {
108
108
  };
109
109
  }
110
110
 
111
+ function isNonEmptyString(value) {
112
+ return typeof value === 'string' && value.trim().length > 0;
113
+ }
114
+
115
+ function isNonEmptyContainer(value) {
116
+ if (Array.isArray(value)) return value.length > 0;
117
+ if (value && typeof value === 'object') return Object.keys(value).length > 0;
118
+ return false;
119
+ }
120
+
121
+ // A risk check that could not inspect its input must never report "allow"
122
+ // (fail-open) — it reports deny with an `invalid` risk and a synthetic finding
123
+ // so the reason is visible through the tool response (#564).
124
+ function invalidRiskResult(stage, reason) {
125
+ return {
126
+ decision: 'deny',
127
+ risk: 'invalid',
128
+ findings: [
129
+ {
130
+ ruleId: 'invalid-input',
131
+ title: 'Invalid input for risk check',
132
+ category: 'invalid',
133
+ severity: 'deny',
134
+ message: reason,
135
+ remediation: 'Provide a valid, non-empty input before re-running the check.',
136
+ source: stage,
137
+ evidence: '',
138
+ },
139
+ ],
140
+ };
141
+ }
142
+
111
143
  export function evaluateCommandRisk(command, options = {}) {
144
+ if (!isNonEmptyString(command)) {
145
+ return invalidRiskResult('command', 'command must be a non-empty string.');
146
+ }
112
147
  return evaluate('command', { command }, options);
113
148
  }
114
149
 
115
150
  export function evaluateArtifacts(artifacts, options = {}) {
116
- return evaluate('artifact', Array.isArray(artifacts) ? artifacts : [], options);
151
+ if (!Array.isArray(artifacts) || artifacts.length === 0) {
152
+ return invalidRiskResult('artifact', 'artifacts must be a non-empty array.');
153
+ }
154
+ return evaluate('artifact', artifacts, options);
117
155
  }
118
156
 
119
157
  export function evaluateDeployPlan(plan, options = {}) {
158
+ const valid = isNonEmptyString(plan) || (plan != null && typeof plan === 'object' && isNonEmptyContainer(plan));
159
+ if (!valid) {
160
+ return invalidRiskResult('deploy_plan', 'plan must be a non-empty object, array, or string.');
161
+ }
120
162
  return evaluate('deploy_plan', { plan }, options);
121
163
  }
122
164
 
@@ -66,11 +66,38 @@ export function redactSecrets(value, policy = DEFAULT_POLICY) {
66
66
 
67
67
  function stripExecutable(args) {
68
68
  if (!args.length) return [];
69
- const first = String(args[0]).toLowerCase();
69
+ let current = args;
70
+ // Unwrap shell wrappers (bash -c / sh -c 'hcloud ...', sudo hcloud ...) so
71
+ // wrapped write commands keep their deny classification (#650 D4-16).
72
+ for (let depth = 0; depth < 5; depth++) {
73
+ const first = String(current[0]).toLowerCase();
74
+ const isShell = [
75
+ 'bash',
76
+ 'sh',
77
+ 'zsh',
78
+ 'dash',
79
+ 'bash.exe',
80
+ 'sh.exe',
81
+ '/bin/bash',
82
+ '/bin/sh',
83
+ '/bin/zsh',
84
+ '/bin/dash',
85
+ ].includes(first);
86
+ if (isShell && String(current[1]).toLowerCase() === '-c' && current[2]) {
87
+ current = splitSimpleCommand(current[2]);
88
+ continue;
89
+ }
90
+ if (first === 'sudo' && current.length > 1) {
91
+ current = current.slice(1);
92
+ continue;
93
+ }
94
+ break;
95
+ }
96
+ const first = String(current[0]).toLowerCase();
70
97
  if (first === 'hcloud' || first.endsWith('/hcloud') || first.endsWith('\\hcloud') || first === 'hcloud.exe') {
71
- return args.slice(1);
98
+ return current.slice(1);
72
99
  }
73
- return args;
100
+ return current;
74
101
  }
75
102
 
76
103
  function commandOperation(args) {
@@ -123,8 +150,44 @@ function applyRawCommandRiskRules(base, command, options = {}) {
123
150
  return mergeRiskDecision(base, risk);
124
151
  }
125
152
 
153
+ // Find hcloud command segments split by shell operators (; && || |) so a write
154
+ // command in the middle of a concatenated string keeps its deny classification
155
+ // (#650 review edge 1).
156
+ function findHcloudCommandSegments(text) {
157
+ return String(text)
158
+ .split(/(?:\|\||&&|;|\|)/)
159
+ .map((segment) => segment.trim())
160
+ .filter(Boolean)
161
+ .map((segment) => splitSimpleCommand(segment))
162
+ .filter((tokens) => {
163
+ const first = String(tokens[0] || '').toLowerCase();
164
+ return first === 'hcloud' || first.endsWith('/hcloud') || first.endsWith('\\hcloud') || first === 'hcloud.exe';
165
+ });
166
+ }
167
+
126
168
  export function classifyHcloudArgs(args, options = {}) {
127
169
  const policy = options.policy || DEFAULT_POLICY;
170
+ // Second pass for shell-wrapped input (#650 D4-16 review edge 1): when the
171
+ // leading command is not hcloud but a concatenated segment contains one,
172
+ // classify every hcloud segment and merge to the most severe decision.
173
+ const unwrappedTokens = stripExecutable(Array.isArray(args) ? args.map(String) : []);
174
+ const unwrappedFirst = String(unwrappedTokens[0] || '').toLowerCase();
175
+ const unwrappedIsHcloud =
176
+ unwrappedFirst === 'hcloud' ||
177
+ unwrappedFirst.endsWith('/hcloud') ||
178
+ unwrappedFirst.endsWith('\\hcloud') ||
179
+ unwrappedFirst === 'hcloud.exe';
180
+ if (!unwrappedIsHcloud && !options._segmentDepth) {
181
+ const hcloudSegments = findHcloudCommandSegments(unwrappedTokens.join(' '));
182
+ if (hcloudSegments.length > 0) {
183
+ const results = hcloudSegments.map((segment) => classifyHcloudArgs(segment, { ...options, _segmentDepth: 1 }));
184
+ return (
185
+ results.find((result) => result.decision === 'deny') ||
186
+ results.find((result) => ['write', 'execution', 'secret', 'credential'].includes(result.risk)) ||
187
+ results[0]
188
+ );
189
+ }
190
+ }
128
191
  const { service, operation, args: normalizedArgs } = commandOperation(args);
129
192
  const joined = normalizedArgs.join(' ');
130
193
 
@@ -342,6 +405,26 @@ export function classifyTextCommand(command, options = {}) {
342
405
  };
343
406
  }
344
407
 
408
+ // Credential variable references bypass the env-command gate above: HW_ is
409
+ // the plugin's own documented credential prefix (HW_ACCESS_KEY/HW_SECRET_KEY/
410
+ // HW_SECURITY_TOKEN), and `echo $HW_SECRET_KEY` / `printenv HW_ACCESS_KEY`
411
+ // previously fell through to allow (#650 D4-2).
412
+ //
413
+ // The negative lookbehind exempts literal-NAME references — backslash-escaped
414
+ // (`\$HW_*`) or single-quoted (`'$HW_*'`, which the shell never expands) —
415
+ // while unescaped `$HW_*` is a potential expansion/dump regardless of the
416
+ // command. No command-name whitelist, so no false negative (#650 review).
417
+ if (
418
+ /(?<!['\\])\$\{?(?:HUAWEICLOUD|HWC|HW|OS)_(?:ACCESS_KEY|SECRET_KEY|SECURITY_TOKEN)/i.test(text) ||
419
+ /(?:^|\s)printenv\s+(?:HUAWEICLOUD|HWC|HW|OS)_(?:ACCESS_KEY|SECRET_KEY|SECURITY_TOKEN)/i.test(text)
420
+ ) {
421
+ return {
422
+ decision: 'deny',
423
+ risk: 'credential',
424
+ reason: 'Printing cloud credential environment variables is blocked.',
425
+ };
426
+ }
427
+
345
428
  if (/(^|\s)hcloud(\.exe)?\s+/i.test(text)) {
346
429
  return classifyHcloudArgs(splitSimpleCommand(text), { ...options, rawCommand: text });
347
430
  }
@@ -37,7 +37,8 @@ import {
37
37
  } from './proxy/proxy-config.mjs';
38
38
  import { removeKooCli, removeObsConfig } from './sandbox/uninstall-cleanup.mjs';
39
39
  import { mergeCommandStyle, mergeArgsStyle, extractUserDelta, applyUserDelta } from './mcp-config-merge.mjs';
40
- import { saveAgentDelta, takeAgentDelta, purgeBackup } from './mcp-config-backup.mjs';
40
+ import { readAgentDelta, saveAgentDelta, takeAgentDelta, purgeBackup } from './mcp-config-backup.mjs';
41
+ import { isUsableOfficeaceRoot, readOfficeaceRootMarker, writeOfficeaceRootMarker } from './officeace-paths.mjs';
41
42
  import { queryDistTagsFetch, determineTarget, semverCompare } from './update-check.mjs';
42
43
  import { getKooCliVersion, compareVersion, kooCliDownloadBase, KOO_CLI_BASE } from './koocli-version.mjs';
43
44
  import { findHcloudBin, hcloudProbeNextStep, probeHcloud } from './hcloud-probe.mjs';
@@ -185,11 +186,13 @@ function readOfficeaceRegistryInstallDir() {
185
186
 
186
187
  function officeaceCapabilitiesDir() {
187
188
  const configRoot = process.env.OFFICE_CLAW_CONFIG_ROOT;
188
- if (configRoot && existsSync(join(configRoot, 'capabilities.json'))) return configRoot;
189
+ if (isUsableOfficeaceRoot(configRoot)) return configRoot;
190
+ const markerRoot = readOfficeaceRootMarker();
191
+ if (markerRoot) return markerRoot;
189
192
  const regDir = readOfficeaceRegistryInstallDir();
190
193
  if (regDir) {
191
194
  const dir = join(regDir, '.office-claw');
192
- if (existsSync(join(dir, 'capabilities.json'))) return dir;
195
+ if (isUsableOfficeaceRoot(dir)) return dir;
193
196
  }
194
197
  if (platform() === 'win32') {
195
198
  const bases = [process.env.ProgramFiles, 'C:\\Program Files', 'D:\\Program Files'];
@@ -197,7 +200,7 @@ function officeaceCapabilitiesDir() {
197
200
  for (const base of bases) {
198
201
  if (!base) continue;
199
202
  const dir = join(base, 'OfficeAce', '.office-claw');
200
- if (existsSync(join(dir, 'capabilities.json'))) return dir;
203
+ if (isUsableOfficeaceRoot(dir)) return dir;
201
204
  }
202
205
  }
203
206
  return null;
@@ -242,7 +245,7 @@ function ensureOfficeaceMcpInSqlite() {
242
245
  if (!existsSync(dbPath)) {
243
246
  console.log(` \x1b[31mOfficeAce database not found: ${dbPath}\x1b[0m`);
244
247
  console.log(` \x1b[33mPlease ensure OfficeAce is installed and has been launched at least once.\x1b[0m`);
245
- return false;
248
+ return 'db-missing';
246
249
  }
247
250
 
248
251
  const mcpPath = join(officeacePluginsDir(), 'src', 'mcp-server.mjs').replace(/\\/g, '/');
@@ -281,21 +284,27 @@ function ensureOfficeaceMcpInSqlite() {
281
284
  if (existing.command === 'node' && existing.args_json === nextArgsJson && existing.env_json === nextEnvJson) {
282
285
  console.log(` MCP config unchanged: ${dbPath}`);
283
286
  db.close();
284
- return true;
287
+ return 'ok';
285
288
  }
286
289
  db.prepare(
287
290
  'UPDATE mcp_connectors SET command = ?, args_json = ?, env_json = ?, updated_at = ?, status = ?, enabled = 1 WHERE id = ?',
288
291
  ).run('node', nextArgsJson, nextEnvJson, now, 'disconnected', existing.id);
289
292
  console.log(` MCP config merged (user fields preserved): ${dbPath}`);
290
293
  } else {
291
- const ownerUserId = officeaceGetOwnerUserId();
294
+ // Restore user identity + args saved by a previous uninstall (#559).
295
+ // owner_user_id has no queryable source: we copy it from an existing
296
+ // row, and fall back to the value backed up before a prior uninstall so
297
+ // an emptied table does not make registration permanently impossible.
298
+ const officeaceDelta = readAgentDelta('officeace');
299
+ const ownerUserId = officeaceGetOwnerUserId() || (officeaceDelta && officeaceDelta.ownerUserId);
292
300
  if (!ownerUserId) {
293
- console.log(` \x1b[31mCannot determine owner_user_id from database\x1b[0m`);
301
+ console.log(` \x1b[31mCannot determine owner_user_id for the huaweicloud-devkit connector.\x1b[0m`);
302
+ console.log(
303
+ ` \x1b[33mThe OfficeAce connector database has no rows to copy the user id from and no prior registration is backed up. Open OfficeAce, add/enable any connector once, then re-run the install.\x1b[0m`,
304
+ );
294
305
  db.close();
295
- return false;
306
+ return 'owner-missing';
296
307
  }
297
- // Restore user args saved by a previous uninstall (issue #615).
298
- const officeaceDelta = takeAgentDelta('officeace');
299
308
  const insertArgsJson =
300
309
  officeaceDelta && Array.isArray(officeaceDelta.argsExtra)
301
310
  ? JSON.stringify([mcpPath, ...officeaceDelta.argsExtra])
@@ -304,10 +313,12 @@ function ensureOfficeaceMcpInSqlite() {
304
313
  `INSERT INTO mcp_connectors (id, owner_user_id, type, name, normalized_name, transport, timeout_ms, command, args_json, env_json, enabled, status, created_at, updated_at, version, seeded)
305
314
  VALUES (?, ?, 'custom', 'huaweicloud-devkit', 'huaweicloud-devkit', 'stdio', 60000, 'node', ?, ?, 1, 'disconnected', ?, ?, 1, 0)`,
306
315
  ).run(randomUUID(), ownerUserId, insertArgsJson, envJson, now, now);
316
+ // Consume the backup only after a successful insert so a DB failure keeps it for the next retry.
317
+ takeAgentDelta('officeace');
307
318
  console.log(` MCP config created: ${dbPath}`);
308
319
  }
309
320
  db.close();
310
- return true;
321
+ return 'ok';
311
322
  } catch (error) {
312
323
  if (db) {
313
324
  try {
@@ -315,7 +326,7 @@ function ensureOfficeaceMcpInSqlite() {
315
326
  } catch {}
316
327
  }
317
328
  console.log(` \x1b[31mFailed to write MCP config: ${error.message}\x1b[0m`);
318
- return false;
329
+ return 'error';
319
330
  }
320
331
  }
321
332
 
@@ -325,17 +336,28 @@ function removeOfficeaceMcpFromSqlite() {
325
336
  let db;
326
337
  try {
327
338
  db = openOfficeaceDb();
328
- // Back up user args before deleting the connector row (issue #615).
339
+ // Back up user identity + args before deleting the connector row:
340
+ // owner_user_id is copied from existing rows on reinstall, so an empty
341
+ // table after this delete would make registration impossible (#559).
329
342
  try {
330
- const row = db.prepare("SELECT args_json FROM mcp_connectors WHERE name = 'huaweicloud-devkit'").get();
331
- const parsed = row ? JSON.parse(row.args_json || '[]') : [];
332
- if (Array.isArray(parsed) && parsed.length > 1) {
333
- saveAgentDelta('officeace', { argsExtra: parsed.slice(1) });
334
- }
343
+ const row = db
344
+ .prepare("SELECT owner_user_id, args_json FROM mcp_connectors WHERE name = 'huaweicloud-devkit'")
345
+ .get();
346
+ const delta = {};
347
+ if (row?.owner_user_id) delta.ownerUserId = row.owner_user_id;
348
+ try {
349
+ const parsed = row ? JSON.parse(row.args_json || '[]') : [];
350
+ if (Array.isArray(parsed) && parsed.length > 1) delta.argsExtra = parsed.slice(1);
351
+ } catch {}
352
+ if (Object.keys(delta).length > 0) saveAgentDelta('officeace', delta);
353
+ } catch {}
354
+ // mcp_connector_tools may not exist on minimal installs — deleting it must
355
+ // not abort deleting the connector row itself.
356
+ try {
357
+ db.prepare(
358
+ "DELETE FROM mcp_connector_tools WHERE connector_id IN (SELECT id FROM mcp_connectors WHERE name = 'huaweicloud-devkit')",
359
+ ).run();
335
360
  } catch {}
336
- db.prepare(
337
- "DELETE FROM mcp_connector_tools WHERE connector_id IN (SELECT id FROM mcp_connectors WHERE name = 'huaweicloud-devkit')",
338
- ).run();
339
361
  const result2 = db.prepare("DELETE FROM mcp_connectors WHERE name = 'huaweicloud-devkit'").run();
340
362
  if (result2.changes > 0) {
341
363
  console.log(` MCP config removed: ${dbPath}`);
@@ -2377,8 +2399,17 @@ async function installOfficeAce() {
2377
2399
  console.log(` Safety Policy -> ${join(pluginDest, 'safety')}`);
2378
2400
 
2379
2401
  installRuntimeDeps(pluginDest);
2380
- ensureOfficeaceMcpInSqlite();
2402
+ if (ensureOfficeaceMcpInSqlite() === 'owner-missing') {
2403
+ throw new Error(
2404
+ 'OfficeAce MCP connector registration failed (owner_user_id unavailable). ' +
2405
+ 'Open OfficeAce, add/enable any connector once, then re-run install.',
2406
+ );
2407
+ }
2381
2408
  registerOfficeaceSkillEntries();
2409
+ // Persist the resolved root so a later uninstall/update (new process, no
2410
+ // OFFICE_CLAW_CONFIG_ROOT env) still targets the same directory (#559).
2411
+ const resolvedRoot = officeaceCapabilitiesDir();
2412
+ if (resolvedRoot) writeOfficeaceRootMarker(resolvedRoot);
2382
2413
  }
2383
2414
 
2384
2415
  async function updateOfficeAce() {
@@ -2395,8 +2426,15 @@ async function updateOfficeAce() {
2395
2426
  copyDir(safetyDir, join(pluginDest, 'safety'));
2396
2427
  console.log(` Safety Policy updated -> ${join(pluginDest, 'safety')}`);
2397
2428
  installRuntimeDeps(pluginDest);
2398
- ensureOfficeaceMcpInSqlite();
2429
+ if (ensureOfficeaceMcpInSqlite() === 'owner-missing') {
2430
+ console.log(
2431
+ ` \x1b[31m[WARN]\x1b[0m OfficeAce MCP connector registration failed (owner_user_id unavailable). ` +
2432
+ `The connector may stay disconnected until you add/enable any connector once in OfficeAce and re-run update.`,
2433
+ );
2434
+ }
2399
2435
  registerOfficeaceSkillEntries();
2436
+ const resolvedRoot = officeaceCapabilitiesDir();
2437
+ if (resolvedRoot) writeOfficeaceRootMarker(resolvedRoot);
2400
2438
  mkdirSync(pluginDest, { recursive: true });
2401
2439
  writeFileSync(join(pluginDest, '.installed'), new Date().toISOString());
2402
2440
  }
@@ -63,6 +63,7 @@ import {
63
63
  upgradePackage,
64
64
  } from './update-check.mjs';
65
65
  import { hcloudProbeNextStep, probeHcloud } from './hcloud-probe.mjs';
66
+ import { isUsableOfficeaceRoot, readOfficeaceRootMarker } from './officeace-paths.mjs';
66
67
 
67
68
  const __dirname = dirname(fileURLToPath(import.meta.url));
68
69
  const SKILLS_ROOT_DEV = join(__dirname, '..', 'skills');
@@ -104,7 +105,14 @@ function readOfficeaceRegistryInstallDir() {
104
105
 
105
106
  function officeaceSkillsRoot() {
106
107
  const configRoot = process.env.OFFICE_CLAW_CONFIG_ROOT;
107
- if (configRoot && existsSync(join(configRoot, 'capabilities.json'))) return join(configRoot, 'skills');
108
+ // Same lenient probe as the installer (isUsableOfficeaceRoot) so env pointing
109
+ // at "skills present but capabilities.json absent" resolves identically at
110
+ // install and at runtime (#559 review).
111
+ if (configRoot && isUsableOfficeaceRoot(configRoot)) return join(configRoot, 'skills');
112
+ // Same persisted root used by the installer keeps install-time and
113
+ // runtime skill discovery consistent (#559).
114
+ const markerRoot = readOfficeaceRootMarker();
115
+ if (markerRoot) return join(markerRoot, 'skills');
108
116
  const regDir = readOfficeaceRegistryInstallDir();
109
117
  if (regDir) {
110
118
  const dir = join(regDir, '.office-claw', 'skills');
@@ -2015,8 +2023,15 @@ function explainError({ service = 'unknown', errorCode = '', message = '', reque
2015
2023
  }
2016
2024
  if (!suggestions.length) {
2017
2025
  suggestions.push(
2018
- 'Collect service name, operation, region, project_id, request_id, and the full redacted error message.',
2026
+ 'No known error-code pattern matched. Re-run the failing command with --debug to capture ' +
2027
+ 'X-Request-Id from the response headers, then re-call explain_error with service, operation, ' +
2028
+ 'region, the full redacted message, and requestId.',
2019
2029
  );
2030
+ if (errorCode) {
2031
+ suggestions.push(
2032
+ `Search the error code "${errorCode}" in the Huawei Cloud error center (support.huaweicloud.com) for the authoritative cause.`,
2033
+ );
2034
+ }
2020
2035
  }
2021
2036
 
2022
2037
  if (requestId) {
@@ -135,6 +135,19 @@ function selfDir() {
135
135
  return dirname(fileURLToPath(import.meta.url));
136
136
  }
137
137
 
138
+ // Plugin manifests also carry the version and are kept in sync with package.json
139
+ // by `npm run validate`. Codex marketplace installs (`.codex/plugins/cache/...`)
140
+ // contain only the plugin directory — no package.json — so fall back to these
141
+ // manifests when neither candidate package.json exists (#576).
142
+ const VERSION_MANIFEST_RELS = [
143
+ '.codex-plugin/plugin.json',
144
+ '.claude-plugin/plugin.json',
145
+ '.cursor-plugin/plugin.json',
146
+ '.workbuddy-plugin/plugin.json',
147
+ '.hermes-plugin/plugin.json',
148
+ 'openclaw.plugin.json',
149
+ ];
150
+
138
151
  export function readInstalledVersion() {
139
152
  const pluginRoot = resolve(selfDir(), '..');
140
153
  const packageRoot = resolve(pluginRoot, '..', '..');
@@ -144,6 +157,12 @@ export function readInstalledVersion() {
144
157
  if (typeof version === 'string' && version) return version;
145
158
  } catch {}
146
159
  }
160
+ for (const rel of VERSION_MANIFEST_RELS) {
161
+ try {
162
+ const version = JSON.parse(readFileSync(join(pluginRoot, rel), 'utf8')).version;
163
+ if (typeof version === 'string' && version) return version;
164
+ } catch {}
165
+ }
147
166
  return null;
148
167
  }
149
168