huaweicloud-devkit 1.1.5 → 1.1.6-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.
@@ -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
 
@@ -22,9 +22,18 @@ import { trackSandboxConnect, trackSandboxDisconnect } from '../telemetry/teleme
22
22
 
23
23
  const execFileAsync = promisify(execFile);
24
24
 
25
+ // Public tunnel URL domain for the DevBridge s2 gateway. The pre-migration Huawei Cloud
26
+ // bridge domain was retired in Sep 2026 and now serves a 「服务已迁移」 placeholder page with
27
+ // HTTP 200 — never construct tunnel URLs from it.
28
+ const DEVBRIDGE_TUNNEL_DOMAIN = 'devbridge-s2.hwtunnel.com';
29
+ // Migration placeholder page marker — a tunnel URL returning this body must be treated as unreachable.
30
+ const DEVBRIDGE_MIGRATION_MARKER = '服务已迁移';
31
+
25
32
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
33
  export const WS_EXEC_INDEX_URL = pathToFileURL(join(__dirname, '..', 'ws-exec', 'index.js')).href;
27
34
 
35
+ export const TUNNEL_URL_PATTERN = /TUNNEL_URL:(https:\/\/[A-Za-z0-9_-]+-\d+\.devbridge-s2\.hwtunnel\.com)/;
36
+
28
37
  let currentWorkspaceId = process.env.HW_WORKSPACE_ID || null;
29
38
 
30
39
  function getCurrentWorkspaceId() {
@@ -180,6 +189,24 @@ export function splitBase64Chunks(base64, chunkSize = UPLOAD_CHUNK_SIZE) {
180
189
  return chunks;
181
190
  }
182
191
 
192
+ export function formatPortConflictWarning(basePort, targetPort) {
193
+ return targetPort !== basePort ? `Port ${basePort} is in use — auto-assigned port ${targetPort}` : undefined;
194
+ }
195
+
196
+ export function formatPortDriftWarning(basePort, targetPort) {
197
+ if (targetPort === basePort) return undefined;
198
+ return `Port ${basePort} was occupied — nginx now listens on port ${targetPort}. Any DevBridge tunnel bound to port ${basePort} is detached: run "devbridge port create <tunnelId> -p ${targetPort} --protocol http -a" and restart "devbridge host" for the new port.`;
199
+ }
200
+
201
+ export function formatProxyPortWarning(basePort, targetPort) {
202
+ if (targetPort === basePort) return undefined;
203
+ return `Port ${basePort} is in use — the proxy template still listens on port ${basePort}: auto-increment does not apply to proxy configs, so nginx may fail to bind. Free the port or deploy a static/spa build instead.`;
204
+ }
205
+
206
+ export function buildExposeRemediation(port) {
207
+ return `In the sandbox: source /tmp/hw_creds.sh; source /tmp/hw_api_key 2>/dev/null; devbridge delete-all; devbridge create <name>; devbridge port create <tunnelId> -p ${port} --protocol http -a; nohup devbridge host <tunnelId> -p ${port} > /tmp/host.log 2>&1 & If deploy_nginx reported a different (auto-incremented) port in its "port" field, use THAT port instead of the one shown here. Full procedure in huawei-sandbox skill, Step 7 (Expose via DevBridge).`;
208
+ }
209
+
183
210
  export async function uploadFileWithSession(workspaceId, localPath, remotePath, username = 'root', timeoutMs = 30000) {
184
211
  if (!existsSync(localPath)) {
185
212
  throw new Error(`sandbox upload: local file not found: ${localPath}`);
@@ -728,7 +755,6 @@ export async function deployNginx(
728
755
  const basePort = nginxType === 'proxy' ? listenPort : port;
729
756
 
730
757
  let targetPort = basePort;
731
- let portWarning;
732
758
  const maxPortAttempts = 10;
733
759
  for (let offset = 0; offset < maxPortAttempts; offset += 1) {
734
760
  targetPort = basePort + offset;
@@ -740,9 +766,6 @@ export async function deployNginx(
740
766
  10000,
741
767
  );
742
768
  if (!String(portCheck.stdout || '').includes('IN_USE')) break;
743
- if (offset === 0) {
744
- portWarning = `Port ${basePort} is in use — auto-assigned port ${targetPort}`;
745
- }
746
769
  } catch {}
747
770
  if (offset === maxPortAttempts - 1) {
748
771
  throw new Error(
@@ -840,9 +863,11 @@ fi`;
840
863
 
841
864
  let tunnelActive = false;
842
865
  try {
866
+ // Version-aware: devbridge 0.1.x exposes JSON via `list -j`; 0.2.x removed -j and
867
+ // prints a table whose data rows start with the 8-char base32 tunnel ID.
843
868
  const tunnelCheck = await execOneShot(
844
869
  workspaceId,
845
- 'devbridge list -j 2>/dev/null | grep -q \'"tunnelId"\' && echo "ACTIVE" || echo "INACTIVE"',
870
+ '(devbridge list -j 2>/dev/null | grep -q \'"tunnelId"\' || devbridge list 2>/dev/null | grep -Eq \'^[a-z2-7]{8}[[:space:]]\') && echo "ACTIVE" || echo "INACTIVE"',
846
871
  username,
847
872
  10000,
848
873
  );
@@ -860,9 +885,17 @@ fi`;
860
885
  stdout: result.stdout,
861
886
  nextStep: 'expose_via_devbridge',
862
887
  warning:
863
- (!tunnelActive
864
- ? 'No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.'
865
- : portWarning) || undefined,
888
+ [
889
+ !tunnelActive
890
+ ? 'No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.'
891
+ : undefined,
892
+ nginxType === 'proxy'
893
+ ? formatProxyPortWarning(basePort, targetPort)
894
+ : formatPortConflictWarning(basePort, targetPort),
895
+ tunnelActive && nginxType !== 'proxy' ? formatPortDriftWarning(basePort, targetPort) : undefined,
896
+ ]
897
+ .filter(Boolean)
898
+ .join(' ') || undefined,
866
899
  };
867
900
  }
868
901
 
@@ -918,7 +951,13 @@ export async function deployCheck(
918
951
  `fi`,
919
952
  ``,
920
953
  `TOTAL=$((TOTAL+1))`,
954
+ `DB_TUNNEL_ACTIVE=0`,
921
955
  `if devbridge list -j 2>/dev/null | grep -q '"tunnelId"'; then`,
956
+ ` DB_TUNNEL_ACTIVE=1`,
957
+ `elif devbridge list 2>/dev/null | grep -Eq '^[a-z2-7]{8}[[:space:]]'; then`,
958
+ ` DB_TUNNEL_ACTIVE=1`,
959
+ `fi`,
960
+ `if [ "$DB_TUNNEL_ACTIVE" = "1" ]; then`,
922
961
  ` echo "devbridge_tunnel:PASS"`,
923
962
  ` PASS=$((PASS+1))`,
924
963
  `else`,
@@ -927,18 +966,29 @@ export async function deployCheck(
927
966
  ``,
928
967
  `TOTAL=$((TOTAL+1))`,
929
968
  `TUNNEL_ID=$(devbridge list -j 2>/dev/null | grep -oP '"tunnelId":\\s*"\\K[^"]+' | head -1)`,
930
- `TUNNEL_URL="https://\${TUNNEL_ID}-${port}.cn-north-4-bridge.myhuaweicloud.com"`,
931
- `if [ -n "$TUNNEL_ID" ] && [ -n "$TUNNEL_URL" ]; then`,
932
- ` HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
933
- ` if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "304" ]; then`,
934
- ` echo "tunnel_url_accessible:PASS ($TUNNEL_URL -> $HTTP_CODE)"`,
935
- ` PASS=$((PASS+1))`,
936
- ` else`,
937
- ` echo "tunnel_url_accessible:FAIL ($TUNNEL_URL -> HTTP $HTTP_CODE)"`,
938
- ` fi`,
969
+ `if [ -z "$TUNNEL_ID" ]; then`,
970
+ ` TUNNEL_ID=$(devbridge list 2>/dev/null | grep -E '^[a-z2-7]{8}[[:space:]]' | awk '{print $1}' | head -1)`,
971
+ `fi`,
972
+ `TUNNEL_URL="https://\${TUNNEL_ID}-${port}.${DEVBRIDGE_TUNNEL_DOMAIN}"`,
973
+ `probe_tunnel() {`,
974
+ ` local url="$1" code`,
975
+ ` code=$(curl -s -o /tmp/.dc_tunnel_body -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || echo "000")`,
976
+ ` [ "$code" = "000" ] && return 1`,
977
+ ` # A migrated gateway serves a placeholder page with HTTP 200 — treat it as unreachable.`,
978
+ ` if grep -q "${DEVBRIDGE_MIGRATION_MARKER}" /tmp/.dc_tunnel_body 2>/dev/null; then return 1; fi`,
979
+ ` rm -f /tmp/.dc_tunnel_body`,
980
+ ` [ "$code" = "200" ] || [ "$code" = "304" ] || return 1`,
981
+ ` return 0`,
982
+ `}`,
983
+ `if [ -n "$TUNNEL_ID" ] && probe_tunnel "$TUNNEL_URL"; then`,
984
+ ` echo "tunnel_url_accessible:PASS ($TUNNEL_URL)"`,
985
+ ` PASS=$((PASS+1))`,
986
+ `elif [ -n "$TUNNEL_ID" ]; then`,
987
+ ` echo "tunnel_url_accessible:FAIL ($TUNNEL_URL -> unreachable, tunnel not found, or migration placeholder page)"`,
939
988
  `else`,
940
- ` echo "tunnel_url_accessible:FAIL (no tunnel URL)"`,
989
+ ` echo "tunnel_url_accessible:FAIL (no tunnel)"`,
941
990
  `fi`,
991
+ `rm -f /tmp/.dc_tunnel_body 2>/dev/null || true`,
942
992
  ``,
943
993
  `${`
944
994
  TOTAL=$((TOTAL+1))
@@ -999,7 +1049,7 @@ fi
999
1049
  if (m) checks[m[1]] = { status: m[2], detail: (m[3] || '').trim() };
1000
1050
  }
1001
1051
  const scoreMatch = cleanStdout.match(/SCORE:(\d+)\/(\d+)/);
1002
- const tunnelMatch = cleanStdout.match(/TUNNEL_URL:(https:\/\/[^\s]+)/);
1052
+ const tunnelMatch = cleanStdout.match(TUNNEL_URL_PATTERN);
1003
1053
  const complete = /VERDICT:COMPLETE/.test(cleanStdout);
1004
1054
 
1005
1055
  const missing = [];
@@ -1015,6 +1065,18 @@ fi
1015
1065
  ? 'Check output parsing failed — individual check results could not be extracted. See rawOutput for details.'
1016
1066
  : undefined;
1017
1067
 
1068
+ const nextStepValue = !complete
1069
+ ? missing.includes('devbridge_tunnel') || missing.includes('tunnel_url_accessible')
1070
+ ? 'expose_via_devbridge'
1071
+ : missing.includes('nginx_serving')
1072
+ ? 'configure_nginx'
1073
+ : missing.includes('qr_code')
1074
+ ? 'generate_qr_code'
1075
+ : parseWarning
1076
+ ? 'review_raw_output'
1077
+ : 'review_checks'
1078
+ : 'complete';
1079
+
1018
1080
  return {
1019
1081
  ok: true,
1020
1082
  complete,
@@ -1025,17 +1087,8 @@ fi
1025
1087
  missingSteps: missing.length > 0 ? missing.join(', ') : undefined,
1026
1088
  parseWarning,
1027
1089
  rawOutput: parseWarning ? stdout.trim() : undefined,
1028
- nextStep: !complete
1029
- ? missing.includes('devbridge_tunnel') || missing.includes('tunnel_url_accessible')
1030
- ? 'expose_via_devbridge'
1031
- : missing.includes('nginx_serving')
1032
- ? 'configure_nginx'
1033
- : missing.includes('qr_code')
1034
- ? 'generate_qr_code'
1035
- : parseWarning
1036
- ? 'review_raw_output'
1037
- : 'review_checks'
1038
- : 'complete',
1090
+ nextStep: nextStepValue,
1091
+ remediation: nextStepValue === 'expose_via_devbridge' ? buildExposeRemediation(port) : undefined,
1039
1092
  };
1040
1093
  }
1041
1094
 
@@ -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,17 @@ 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;
192
+ // Skip system probes (registry / Program Files) so automated environments can
193
+ // keep OfficeAce detection hermetic — those probes can leak a real OfficeAce
194
+ // install into a sandboxed/fake HOME (#654).
195
+ if (process.env.HUAWEICLOUD_DEVKIT_SKIP_OFFICEACE_DETECT === '1') return null;
189
196
  const regDir = readOfficeaceRegistryInstallDir();
190
197
  if (regDir) {
191
198
  const dir = join(regDir, '.office-claw');
192
- if (existsSync(join(dir, 'capabilities.json'))) return dir;
199
+ if (isUsableOfficeaceRoot(dir)) return dir;
193
200
  }
194
201
  if (platform() === 'win32') {
195
202
  const bases = [process.env.ProgramFiles, 'C:\\Program Files', 'D:\\Program Files'];
@@ -197,7 +204,7 @@ function officeaceCapabilitiesDir() {
197
204
  for (const base of bases) {
198
205
  if (!base) continue;
199
206
  const dir = join(base, 'OfficeAce', '.office-claw');
200
- if (existsSync(join(dir, 'capabilities.json'))) return dir;
207
+ if (isUsableOfficeaceRoot(dir)) return dir;
201
208
  }
202
209
  }
203
210
  return null;
@@ -242,7 +249,7 @@ function ensureOfficeaceMcpInSqlite() {
242
249
  if (!existsSync(dbPath)) {
243
250
  console.log(` \x1b[31mOfficeAce database not found: ${dbPath}\x1b[0m`);
244
251
  console.log(` \x1b[33mPlease ensure OfficeAce is installed and has been launched at least once.\x1b[0m`);
245
- return false;
252
+ return 'db-missing';
246
253
  }
247
254
 
248
255
  const mcpPath = join(officeacePluginsDir(), 'src', 'mcp-server.mjs').replace(/\\/g, '/');
@@ -281,21 +288,27 @@ function ensureOfficeaceMcpInSqlite() {
281
288
  if (existing.command === 'node' && existing.args_json === nextArgsJson && existing.env_json === nextEnvJson) {
282
289
  console.log(` MCP config unchanged: ${dbPath}`);
283
290
  db.close();
284
- return true;
291
+ return 'ok';
285
292
  }
286
293
  db.prepare(
287
294
  'UPDATE mcp_connectors SET command = ?, args_json = ?, env_json = ?, updated_at = ?, status = ?, enabled = 1 WHERE id = ?',
288
295
  ).run('node', nextArgsJson, nextEnvJson, now, 'disconnected', existing.id);
289
296
  console.log(` MCP config merged (user fields preserved): ${dbPath}`);
290
297
  } else {
291
- const ownerUserId = officeaceGetOwnerUserId();
298
+ // Restore user identity + args saved by a previous uninstall (#559).
299
+ // owner_user_id has no queryable source: we copy it from an existing
300
+ // row, and fall back to the value backed up before a prior uninstall so
301
+ // an emptied table does not make registration permanently impossible.
302
+ const officeaceDelta = readAgentDelta('officeace');
303
+ const ownerUserId = officeaceGetOwnerUserId() || (officeaceDelta && officeaceDelta.ownerUserId);
292
304
  if (!ownerUserId) {
293
- console.log(` \x1b[31mCannot determine owner_user_id from database\x1b[0m`);
305
+ console.log(` \x1b[31mCannot determine owner_user_id for the huaweicloud-devkit connector.\x1b[0m`);
306
+ console.log(
307
+ ` \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`,
308
+ );
294
309
  db.close();
295
- return false;
310
+ return 'owner-missing';
296
311
  }
297
- // Restore user args saved by a previous uninstall (issue #615).
298
- const officeaceDelta = takeAgentDelta('officeace');
299
312
  const insertArgsJson =
300
313
  officeaceDelta && Array.isArray(officeaceDelta.argsExtra)
301
314
  ? JSON.stringify([mcpPath, ...officeaceDelta.argsExtra])
@@ -304,10 +317,12 @@ function ensureOfficeaceMcpInSqlite() {
304
317
  `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
318
  VALUES (?, ?, 'custom', 'huaweicloud-devkit', 'huaweicloud-devkit', 'stdio', 60000, 'node', ?, ?, 1, 'disconnected', ?, ?, 1, 0)`,
306
319
  ).run(randomUUID(), ownerUserId, insertArgsJson, envJson, now, now);
320
+ // Consume the backup only after a successful insert so a DB failure keeps it for the next retry.
321
+ takeAgentDelta('officeace');
307
322
  console.log(` MCP config created: ${dbPath}`);
308
323
  }
309
324
  db.close();
310
- return true;
325
+ return 'ok';
311
326
  } catch (error) {
312
327
  if (db) {
313
328
  try {
@@ -315,7 +330,7 @@ function ensureOfficeaceMcpInSqlite() {
315
330
  } catch {}
316
331
  }
317
332
  console.log(` \x1b[31mFailed to write MCP config: ${error.message}\x1b[0m`);
318
- return false;
333
+ return 'error';
319
334
  }
320
335
  }
321
336
 
@@ -325,17 +340,28 @@ function removeOfficeaceMcpFromSqlite() {
325
340
  let db;
326
341
  try {
327
342
  db = openOfficeaceDb();
328
- // Back up user args before deleting the connector row (issue #615).
343
+ // Back up user identity + args before deleting the connector row:
344
+ // owner_user_id is copied from existing rows on reinstall, so an empty
345
+ // table after this delete would make registration impossible (#559).
329
346
  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
- }
347
+ const row = db
348
+ .prepare("SELECT owner_user_id, args_json FROM mcp_connectors WHERE name = 'huaweicloud-devkit'")
349
+ .get();
350
+ const delta = {};
351
+ if (row?.owner_user_id) delta.ownerUserId = row.owner_user_id;
352
+ try {
353
+ const parsed = row ? JSON.parse(row.args_json || '[]') : [];
354
+ if (Array.isArray(parsed) && parsed.length > 1) delta.argsExtra = parsed.slice(1);
355
+ } catch {}
356
+ if (Object.keys(delta).length > 0) saveAgentDelta('officeace', delta);
357
+ } catch {}
358
+ // mcp_connector_tools may not exist on minimal installs — deleting it must
359
+ // not abort deleting the connector row itself.
360
+ try {
361
+ db.prepare(
362
+ "DELETE FROM mcp_connector_tools WHERE connector_id IN (SELECT id FROM mcp_connectors WHERE name = 'huaweicloud-devkit')",
363
+ ).run();
335
364
  } 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
365
  const result2 = db.prepare("DELETE FROM mcp_connectors WHERE name = 'huaweicloud-devkit'").run();
340
366
  if (result2.changes > 0) {
341
367
  console.log(` MCP config removed: ${dbPath}`);
@@ -565,7 +591,10 @@ function installRuntimeDeps(pluginsDir) {
565
591
  function removeIfExists(p) {
566
592
  if (existsSync(p)) {
567
593
  try {
568
- rmSync(p, { recursive: true, force: true });
594
+ // Retry transient EBUSY/EPERM/ENOTEMPTY (locked files on Windows) before
595
+ // giving up — uninstall should not leave a skill behind just because a
596
+ // handle was momentarily open (#556).
597
+ rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
569
598
  return true;
570
599
  } catch (error) {
571
600
  console.log(` \x1b[33m[WARN]\x1b[0m Could not remove ${p}: ${error.message}`);
@@ -824,8 +853,7 @@ function uninstallOpenCode() {
824
853
  if (existsSync(skills)) {
825
854
  for (const entry of readdirSync(skills, { withFileTypes: true })) {
826
855
  if (entry.name.startsWith('huawei')) {
827
- removeIfExists(join(skills, entry.name));
828
- removed++;
856
+ if (removeIfExists(join(skills, entry.name))) removed++;
829
857
  }
830
858
  }
831
859
  console.log(` Removed ${removed} skills`);
@@ -836,8 +864,7 @@ function uninstallOpenCode() {
836
864
  if (existsSync(commands)) {
837
865
  for (const entry of readdirSync(commands, { withFileTypes: true })) {
838
866
  if (entry.name.startsWith('huawei')) {
839
- removeIfExists(join(commands, entry.name));
840
- cmdRemoved++;
867
+ if (removeIfExists(join(commands, entry.name))) cmdRemoved++;
841
868
  }
842
869
  }
843
870
  if (cmdRemoved > 0) console.log(` Removed ${cmdRemoved} commands`);
@@ -863,8 +890,7 @@ function pruneStale(targetDir, sourceDir) {
863
890
  for (const entry of readdirSync(targetDir, { withFileTypes: true })) {
864
891
  if (!entry.name.startsWith('huawei')) continue;
865
892
  if (!sourceNames.has(entry.name)) {
866
- removeIfExists(join(targetDir, entry.name));
867
- removed++;
893
+ if (removeIfExists(join(targetDir, entry.name))) removed++;
868
894
  }
869
895
  }
870
896
  return removed;
@@ -1011,8 +1037,7 @@ function uninstallOpenClaw() {
1011
1037
  if (existsSync(skillsDir)) {
1012
1038
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
1013
1039
  if (entry.name.startsWith('huawei')) {
1014
- removeIfExists(join(skillsDir, entry.name));
1015
- removed++;
1040
+ if (removeIfExists(join(skillsDir, entry.name))) removed++;
1016
1041
  }
1017
1042
  }
1018
1043
  console.log(` Removed ${removed} skills`);
@@ -1023,8 +1048,7 @@ function uninstallOpenClaw() {
1023
1048
  if (existsSync(cmdDir)) {
1024
1049
  for (const entry of readdirSync(cmdDir, { withFileTypes: true })) {
1025
1050
  if (entry.name.startsWith('huawei')) {
1026
- removeIfExists(join(cmdDir, entry.name));
1027
- cmdRemoved++;
1051
+ if (removeIfExists(join(cmdDir, entry.name))) cmdRemoved++;
1028
1052
  }
1029
1053
  }
1030
1054
  if (cmdRemoved > 0) console.log(` Removed ${cmdRemoved} commands`);
@@ -1170,8 +1194,7 @@ function uninstallCodexDesktop() {
1170
1194
  if (existsSync(skillsDir)) {
1171
1195
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
1172
1196
  if (entry.name.startsWith('huawei')) {
1173
- removeIfExists(join(skillsDir, entry.name));
1174
- removed++;
1197
+ if (removeIfExists(join(skillsDir, entry.name))) removed++;
1175
1198
  }
1176
1199
  }
1177
1200
  console.log(` Removed ${removed} skills`);
@@ -1182,8 +1205,7 @@ function uninstallCodexDesktop() {
1182
1205
  if (existsSync(cmdDir)) {
1183
1206
  for (const entry of readdirSync(cmdDir, { withFileTypes: true })) {
1184
1207
  if (entry.name.startsWith('huawei')) {
1185
- removeIfExists(join(cmdDir, entry.name));
1186
- cmdRemoved++;
1208
+ if (removeIfExists(join(cmdDir, entry.name))) cmdRemoved++;
1187
1209
  }
1188
1210
  }
1189
1211
  if (cmdRemoved > 0) console.log(` Removed ${cmdRemoved} commands`);
@@ -1327,8 +1349,7 @@ function uninstallCodeArts() {
1327
1349
  if (!existsSync(skillsDir)) continue;
1328
1350
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
1329
1351
  if (entry.name.startsWith('huawei')) {
1330
- removeIfExists(join(skillsDir, entry.name));
1331
- removed++;
1352
+ if (removeIfExists(join(skillsDir, entry.name))) removed++;
1332
1353
  }
1333
1354
  }
1334
1355
  }
@@ -1488,8 +1509,7 @@ function uninstallCodeArtsWork() {
1488
1509
  let removed = 0;
1489
1510
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
1490
1511
  if (entry.name.startsWith('huawei')) {
1491
- removeIfExists(join(skillsDir, entry.name));
1492
- removed++;
1512
+ if (removeIfExists(join(skillsDir, entry.name))) removed++;
1493
1513
  }
1494
1514
  }
1495
1515
  if (removed > 0) console.log(` Removed ${removed} skills`);
@@ -1776,8 +1796,7 @@ function uninstallWorkBuddy() {
1776
1796
  if (existsSync(skillsDir)) {
1777
1797
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
1778
1798
  if (entry.name.startsWith('huawei')) {
1779
- removeIfExists(join(skillsDir, entry.name));
1780
- removed++;
1799
+ if (removeIfExists(join(skillsDir, entry.name))) removed++;
1781
1800
  }
1782
1801
  }
1783
1802
  if (removed > 0) console.log(` Removed ${removed} skills`);
@@ -1813,9 +1832,10 @@ function uninstallWorkBuddy() {
1813
1832
  if (settings.hooks?.PostToolUse) {
1814
1833
  const before = settings.hooks.PostToolUse.length;
1815
1834
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((e) => e?.matcher !== '*');
1816
- if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
1835
+ const after = settings.hooks.PostToolUse.length;
1836
+ if (after === 0) delete settings.hooks.PostToolUse;
1817
1837
  if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
1818
- if (before !== settings.hooks.PostToolUse?.length) {
1838
+ if (before !== after) {
1819
1839
  writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
1820
1840
  console.log(` PostToolUse hook removed from ${settingsPath}`);
1821
1841
  }
@@ -1966,8 +1986,7 @@ function uninstallAtomCode() {
1966
1986
  if (existsSync(skillsDir)) {
1967
1987
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
1968
1988
  if (entry.name.startsWith('huawei')) {
1969
- removeIfExists(join(skillsDir, entry.name));
1970
- removed++;
1989
+ if (removeIfExists(join(skillsDir, entry.name))) removed++;
1971
1990
  }
1972
1991
  }
1973
1992
  if (removed > 0) console.log(` Removed ${removed} skills`);
@@ -2281,8 +2300,7 @@ function uninstallDsh() {
2281
2300
  if (existsSync(skillsDir)) {
2282
2301
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
2283
2302
  if (entry.name.startsWith('huawei')) {
2284
- removeIfExists(join(skillsDir, entry.name));
2285
- removed++;
2303
+ if (removeIfExists(join(skillsDir, entry.name))) removed++;
2286
2304
  }
2287
2305
  }
2288
2306
  if (removed > 0) console.log(` Removed ${removed} skills`);
@@ -2377,8 +2395,17 @@ async function installOfficeAce() {
2377
2395
  console.log(` Safety Policy -> ${join(pluginDest, 'safety')}`);
2378
2396
 
2379
2397
  installRuntimeDeps(pluginDest);
2380
- ensureOfficeaceMcpInSqlite();
2398
+ if (ensureOfficeaceMcpInSqlite() === 'owner-missing') {
2399
+ throw new Error(
2400
+ 'OfficeAce MCP connector registration failed (owner_user_id unavailable). ' +
2401
+ 'Open OfficeAce, add/enable any connector once, then re-run install.',
2402
+ );
2403
+ }
2381
2404
  registerOfficeaceSkillEntries();
2405
+ // Persist the resolved root so a later uninstall/update (new process, no
2406
+ // OFFICE_CLAW_CONFIG_ROOT env) still targets the same directory (#559).
2407
+ const resolvedRoot = officeaceCapabilitiesDir();
2408
+ if (resolvedRoot) writeOfficeaceRootMarker(resolvedRoot);
2382
2409
  }
2383
2410
 
2384
2411
  async function updateOfficeAce() {
@@ -2395,8 +2422,15 @@ async function updateOfficeAce() {
2395
2422
  copyDir(safetyDir, join(pluginDest, 'safety'));
2396
2423
  console.log(` Safety Policy updated -> ${join(pluginDest, 'safety')}`);
2397
2424
  installRuntimeDeps(pluginDest);
2398
- ensureOfficeaceMcpInSqlite();
2425
+ if (ensureOfficeaceMcpInSqlite() === 'owner-missing') {
2426
+ console.log(
2427
+ ` \x1b[31m[WARN]\x1b[0m OfficeAce MCP connector registration failed (owner_user_id unavailable). ` +
2428
+ `The connector may stay disconnected until you add/enable any connector once in OfficeAce and re-run update.`,
2429
+ );
2430
+ }
2399
2431
  registerOfficeaceSkillEntries();
2432
+ const resolvedRoot = officeaceCapabilitiesDir();
2433
+ if (resolvedRoot) writeOfficeaceRootMarker(resolvedRoot);
2400
2434
  mkdirSync(pluginDest, { recursive: true });
2401
2435
  writeFileSync(join(pluginDest, '.installed'), new Date().toISOString());
2402
2436
  }
@@ -2407,8 +2441,7 @@ function uninstallOfficeAce() {
2407
2441
  if (existsSync(skillsDir)) {
2408
2442
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
2409
2443
  if (entry.name.startsWith('huawei')) {
2410
- removeIfExists(join(skillsDir, entry.name));
2411
- removed++;
2444
+ if (removeIfExists(join(skillsDir, entry.name))) removed++;
2412
2445
  }
2413
2446
  }
2414
2447
  if (removed > 0) console.log(` Removed ${removed} skills`);
@@ -2991,8 +3024,7 @@ function uninstallHermes() {
2991
3024
  if (existsSync(skillsDir)) {
2992
3025
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
2993
3026
  if (entry.name.startsWith('huawei')) {
2994
- removeIfExists(join(skillsDir, entry.name));
2995
- removed++;
3027
+ if (removeIfExists(join(skillsDir, entry.name))) removed++;
2996
3028
  }
2997
3029
  }
2998
3030
  if (removed > 0) console.log(` Removed ${removed} skills`);
@@ -4133,7 +4165,7 @@ async function cmdDoctor() {
4133
4165
  console.log('\x1b[33mFix failures above, then restart your session.\x1b[0m');
4134
4166
  }
4135
4167
  if (fail === 0 && mcpConfigured) {
4136
- console.log('\n\x1b[32mAll checks passed.\x1b[0m Restart your session, then describe your Huawei Cloud task');
4168
+ console.log('\n\x1b[32mAll checks passed.\x1b[0m You can now describe your Huawei Cloud task');
4137
4169
  }
4138
4170
 
4139
4171
  // Detect "installed but not restarted" — check all supported agents