@baize-ai/core 0.3.13 → 0.3.15

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.
@@ -94,18 +94,9 @@ if ! baize init ${INIT_ARGS}; then
94
94
  fi
95
95
  ok "Workspace ready"
96
96
  mkdir -p "${BAIZE_DIR}/.baize"
97
- # ── Local-source skills override (BAIZE_LOCAL_SKILLS=1 images) ──────────────
98
- # Images built with BAIZE_LOCAL_SKILLS=1 bake the repo's skills into
99
- # /home/baize/.local-skills. syncSkills (npm postinstall) runs during init
100
- # with the npm-package skills as source, so apply the local override AFTER
101
- # init to keep un-released fixes (e.g. D44) authoritative.
102
- # Official images ship an empty .local-skills — this is a no-op there.
103
- if [ -d /home/baize/.local-skills ]; then
104
- cp -rf /home/baize/.local-skills/. "${BAIZE_DIR}/.claude/skills/" 2>/dev/null \
105
- && ok "Local-source skills applied (BAIZE_LOCAL_SKILLS=1)"
106
- fi
97
+ printf '{"status":"ok","at":"%s"}\n' "$(date -Iseconds)" > "${BAIZE_DIR}/.baize/init-state.json"
98
+
107
99
 
108
- # ── Pass through channel env vars to .env ─────────────────────────────────────
109
100
 
110
101
  # ── Pass through channel env vars to .env ─────────────────────────────────────
111
102
  # baize init doesn't write channel tokens — those come from component installs.
@@ -161,6 +152,8 @@ PM2_PID=$!
161
152
  sleep 3
162
153
  ok "Services started"
163
154
 
155
+ # ── Step 4: Start the configured agent runtime in tmux ───────────────────────
156
+
164
157
  # ── Step 4: Start the configured agent runtime in tmux ───────────────────────
165
158
  # Determine runtime: BAIZE_RUNTIME env var always wins; fall back to config.json.
166
159
  if [ -z "${BAIZE_RUNTIME:-}" ]; then
@@ -57,9 +57,27 @@ services:
57
57
 
58
58
  # ── Health ────────────────────────────────────────────────────────────────
59
59
  healthcheck:
60
- test: ["CMD", "pm2", "list"]
60
+ test:
61
+ - CMD
62
+ - node
63
+ - -e
64
+ - |
65
+ const fs = require('fs'), net = require('net');
66
+ // Core health = pm2 reports online. A2A (commercial component, D49):
67
+ // when installed and enabled, the container is only healthy if its
68
+ // daemon actually LISTENS (pm2 status alone masked the KI-015
69
+ // silent-idle shape); otherwise the check is skipped.
70
+ try { require('child_process').execSync('pm2 list', { stdio: 'ignore' }); } catch { process.exit(1); }
71
+ try {
72
+ const cfg = JSON.parse(fs.readFileSync('/home/baize/baize/components/a2a/config.json', 'utf8'));
73
+ if (cfg.enabled !== true) process.exit(0);
74
+ const s = net.connect({ host: '127.0.0.1', port: cfg.listenPort || 8443, timeout: 3000 });
75
+ s.on('connect', () => { s.destroy(); process.exit(0); });
76
+ s.on('error', () => process.exit(1));
77
+ s.on('timeout', () => { s.destroy(); process.exit(1); });
78
+ } catch { process.exit(0); }
61
79
  interval: 30s
62
- timeout: 10s
80
+ timeout: 15s
63
81
  retries: 3
64
82
  start_period: 600s
65
83
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@baize-ai/core",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "type": "module",
5
- "description": "Baize (\u767d\u6cfd) \u2014 autonomous AI agent infrastructure",
5
+ "description": "Baize (白泽) autonomous AI agent infrastructure",
6
6
  "main": "cli/baize.js",
7
7
  "bin": {
8
8
  "baize": "./cli/baize.js"
@@ -46,4 +46,4 @@
46
46
  "publishConfig": {
47
47
  "access": "public"
48
48
  }
49
- }
49
+ }
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  # Multi-platform build + push to Aliyun ACR for the mainland distribution.
3
- # Usage: bash build-push-acr.sh [VERSION] (default 0.3.11)
3
+ # Usage: bash build-push-acr.sh [VERSION] (default = latest published)
4
+ # IMPORTANT: bump this default when a new version is published — it must
5
+ # track @baize-ai/core latest (never point at an un-released version).
4
6
  # Requires: docker login already done, qemu-arm64 registered
5
7
  # (apt install qemu-user-binfmt), registry mirror configured.
6
8
  set -euo pipefail
7
9
 
8
- VERSION="${1:-0.3.11}"
10
+ VERSION="${1:-0.3.13}"
9
11
  ACR="crpi-8bg62qbrjs3cr3as.cn-hangzhou.personal.cr.aliyuncs.com/baize01/baize-core"
10
12
 
11
13
  echo "== Configure buildkit registry mirror (mainland Docker Hub access) =="
@@ -30,22 +30,15 @@ ACR_NAMESPACE="${ACR_NAMESPACE:-baize01}"
30
30
 
31
31
  # ── Build ────────────────────────────────────────────────────────────────────
32
32
  echo "==> Building baize-core:${VERSION} (from npm @baize-ai/core@${VERSION})"
33
- BUILD_ARGS=(--build-arg "BAZE_CORE_VERSION=${VERSION}")
34
- rm -rf .docker-local-skills && mkdir -p .docker-local-skills
35
- if [ "${BAIZE_LOCAL_SKILLS:-0}" = "1" ]; then
36
- BUILD_ARGS+=(--build-arg BAIZE_LOCAL_SKILLS=1)
37
- echo "==> Overriding skills with LOCAL repo source (BAIZE_LOCAL_SKILLS=1) — un-released fixes baked in"
38
- cp -r skills/. .docker-local-skills/
39
- # Never ship host-compiled node_modules (mac binaries break linux runtime) —
40
- # dependency binaries come from the image's own npm install.
41
- find .docker-local-skills -type d -name node_modules -prune -exec rm -rf {} +
42
- fi
43
33
  docker build \
34
+ --build-arg BAZE_CORE_VERSION="${VERSION}" \
35
+ -t "${GHCR_IMAGE}:${VERSION}" \
36
+ -t "${GHCR_IMAGE}:latest" \
37
+ .
44
38
  "${BUILD_ARGS[@]}" \
45
39
  -t "${GHCR_IMAGE}:${VERSION}" \
46
40
  -t "${GHCR_IMAGE}:latest" \
47
41
  .
48
- rm -rf .docker-local-skills
49
42
 
50
43
  if [ -n "${ACR_REGISTRY}" ] && [ -n "${ACR_NAMESPACE}" ]; then
51
44
  ACR_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/baize-core"
@@ -1655,6 +1655,19 @@ async function loadA2aStatus(basePath) {
1655
1655
  state.textContent = enabled ? '已启用' : '未启用';
1656
1656
  state.className = `channel-state ${enabled ? 'ok' : 'warn'}`;
1657
1657
  }
1658
+ // D48: certificate health — the daemon self-heals (re-registers) when the
1659
+ // admin rotated its CA. Only claim "auto-healing" when the daemon is
1660
+ // actually alive to run the 5-min renewal check; otherwise the user must
1661
+ // act (daemon down / renewals exhausted).
1662
+ const certValid = body.certValid === true;
1663
+ const daemonUp = body.daemonHealthy === true;
1664
+ let certLabel = '正常';
1665
+ if (!certValid) {
1666
+ certLabel = daemonUp ? '需续签(自动处理中)' : '证书待续签(daemon 未运行)';
1667
+ }
1668
+ const certRow = body.enabled === true
1669
+ ? `<div class="conn-summary-row"><span class="conn-summary-name">证书状态</span><span class="conn-summary-detail"><span class="channel-state ${certValid ? 'ok' : 'warn'}">${certLabel}</span></span></div>`
1670
+ : '';
1658
1671
  const rows = [
1659
1672
  ['启用', enabled ? '是' : '否'],
1660
1673
  ['Agent ID', body.agentId],
@@ -1664,7 +1677,7 @@ async function loadA2aStatus(basePath) {
1664
1677
  ['Endpoint', body.endpoint],
1665
1678
  ['版本', body.version],
1666
1679
  ].filter(([, v]) => v !== undefined && v !== null && v !== '');
1667
- el.innerHTML = rows.map(([k, v]) => `<div class="conn-summary-row"><span class="conn-summary-name">${escapeHtml(k)}</span><span class="conn-summary-detail">${escapeHtml(String(v))}</span></div>`).join('');
1680
+ el.innerHTML = rows.map(([k, v]) => `<div class="conn-summary-row"><span class="conn-summary-name">${escapeHtml(k)}</span><span class="conn-summary-detail">${escapeHtml(String(v))}</span></div>`).join('') + certRow;
1668
1681
  updateA2aInstallArea(body);
1669
1682
  updateA2aModeCard(body);
1670
1683
  updateA2aConfigArea(body);
@@ -1706,10 +1719,10 @@ function updateA2aInstallArea(body) {
1706
1719
  if (!el || !fileInput || !btn) return;
1707
1720
  const version = body && (body.version || body.installedVersion);
1708
1721
  if (version) {
1709
- el.textContent = `已安装 baize-a2a v${String(version).replace(/^v/, '')}。如需升级请重新上传模块包。`;
1722
+ el.textContent = `已安装 baize-a2a v${String(version).replace(/^v/, '')} — 上传更高版本的模块包(.tgz)可在线升级(相同或更低版本将被拒绝)。`;
1710
1723
  if (state) { state.textContent = '已安装'; state.className = 'channel-state ok'; }
1711
- fileInput.disabled = true;
1712
- btn.disabled = true;
1724
+ fileInput.disabled = false;
1725
+ btn.disabled = false;
1713
1726
  } else {
1714
1727
  el.textContent = '未安装 — 上传 baize-a2a 模块包(.tgz)完成安装。';
1715
1728
  if (state) { state.textContent = '未安装'; state.className = 'channel-state warn'; }
@@ -1769,11 +1782,16 @@ function updateA2aHealth(body, peerCountOverride) {
1769
1782
  } else if (_lastA2aPeerCount !== null) {
1770
1783
  peerCount = _lastA2aPeerCount;
1771
1784
  }
1785
+ const bs = body && body.bootstrap ? body.bootstrap : null;
1786
+ const bsLabel = !bs ? '' : (bs.status === 'ok' || bs.status === 'launched'
1787
+ ? `启动中/已启动(${escapeHtml(String(bs.via || bs.status))})`
1788
+ : `失败:${escapeHtml(String(bs.error || bs.status))}`);
1772
1789
  const rows = [
1773
1790
  ['Daemon 健康', healthy ? '✅ 正常' : '❌ 异常'],
1774
1791
  ['运行模式', modeLabel],
1775
1792
  ['Peer 数', peerCount],
1776
1793
  ['最近心跳', a2aHeartbeatLabel(body)],
1794
+ ...(bsLabel ? [['init 自启动', bsLabel]] : []),
1777
1795
  ].filter(([, v]) => v !== undefined && v !== null && v !== '');
1778
1796
  el.innerHTML = rows.map(([k, v]) => `<div class="conn-summary-row"><span class="conn-summary-name">${escapeHtml(k)}</span><span class="conn-summary-detail">${escapeHtml(String(v))}</span></div>`).join('');
1779
1797
  if (state) {
@@ -2269,7 +2287,7 @@ async function submitA2aInstall(basePath, btn) {
2269
2287
  }
2270
2288
  const original = btn.textContent;
2271
2289
  btn.disabled = true;
2272
- btn.textContent = '上传安装中...';
2290
+ btn.textContent = '上传处理中...';
2273
2291
  feedback.textContent = '';
2274
2292
  feedback.className = '';
2275
2293
  const fd = new FormData();
@@ -2278,11 +2296,23 @@ async function submitA2aInstall(basePath, btn) {
2278
2296
  const { status, body } = await adminFetch(basePath, '/api/admin/a2a/install', { method: 'POST', body: fd });
2279
2297
  if (body.ok === true || body.success === true) {
2280
2298
  const version = body.version ? ` v${String(body.version).replace(/^v/, '')}` : '';
2281
- feedback.textContent = `✅ 安装成功${version}`;
2299
+ const verb = body.upgraded ? '升级' : '安装';
2300
+ let okMsg = `✅ ${verb}成功${version}`;
2301
+ if (body.upgraded && body.restarted === false) {
2302
+ okMsg += `(⚠️ daemon 重启失败:${body.restartError || '未知原因'},请检查状态卡或手动重启)`;
2303
+ }
2304
+ feedback.textContent = okMsg;
2282
2305
  feedback.className = 'a2a-feedback-ok';
2283
2306
  fileInput.value = '';
2284
- setAdminMsg(`✅ baize-a2a 安装成功${version}`);
2285
- loadA2aStatus(basePath); // 刷新版本/模式/健康
2307
+ setAdminMsg(`✅ baize-a2a ${verb}成功${version}`);
2308
+ loadA2aStatus(basePath); // 刷新版本/模式/健康/安装卡
2309
+ } else if (body.error === 'version_not_higher') {
2310
+ const local = body.local ? `v${String(body.local).replace(/^v/, '')}` : '未知';
2311
+ const incoming = body.incoming ? `v${String(body.incoming).replace(/^v/, '')}` : '未知';
2312
+ const msg = `上传版本 ${incoming} 不高于已安装版本 ${local},未执行升级`;
2313
+ feedback.textContent = `❌ 升级被拒绝:${msg}`;
2314
+ feedback.className = 'a2a-feedback-error';
2315
+ setAdminMsg(`❌ 升级被拒绝:${msg}`, true);
2286
2316
  } else {
2287
2317
  const msg = body.error || body.message || `HTTP ${status}`;
2288
2318
  feedback.textContent = `❌ 安装失败:${msg}`;
@@ -323,7 +323,7 @@
323
323
  <div class="cred-state" id="a2a-install-status" role="status" aria-live="polite">加载中...</div>
324
324
  <form id="form-a2a-install" class="settings-form">
325
325
  <input type="file" id="a2a-install-file" accept=".tgz,.tar.gz,.zip" aria-label="选择 baize-a2a 模块包">
326
- <button type="submit" class="btn btn-primary" id="btn-a2a-install">上传安装</button>
326
+ <button type="submit" class="btn btn-primary" id="btn-a2a-install">上传安装/升级</button>
327
327
  </form>
328
328
  <div class="cred-state" id="a2a-install-feedback" role="status" aria-live="polite"></div>
329
329
  </div>
@@ -362,63 +362,76 @@ function rebuildNativeDeps(cwd) {
362
362
  }
363
363
 
364
364
  /**
365
- * Install @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付).
366
- * Pipeline: entry listing + path-safety vetting (no `..`/absolute/links)
367
- * manifest check (name = @baize-ai/baize-a2a, version present) → extract to
368
- * SKILLS_DIR/a2a → npm install --omit=dev → components.json registration.
369
- * Already installed → { ok:false, error } naming the installed version
370
- * (mirrors `baize add`; version bumps go through `baize upgrade a2a`).
365
+ * Shared upload vetting for install/upgrade (D25/D49): extension → size →
366
+ * entry listing path-traversal scan link scan package manifest.
371
367
  *
372
- * @returns {Promise<{ok:boolean, version?:string, error?:string}>}
368
+ * @returns {{ error: string } | { pkg: object, entries: string[] }}
373
369
  */
374
- export async function installA2aTarball(tarballPath, {
375
- skillsDir = defaultSkillsDir(),
376
- componentsFile: componentsPath = componentsFile(),
377
- installDeps = npmInstallDeps,
378
- rebuildDeps = rebuildNativeDeps,
379
- originalName = null,
380
- } = {}) {
381
- const file = String(tarballPath || '');
370
+ function validateA2aTarball(file, originalName) {
382
371
  const nameForType = String(originalName || file || '');
383
372
  if (!/\.(?:tar\.gz|tgz)$/i.test(nameForType)) {
384
- return { ok: false, error: '安装包必须是 .tar.gz 归档' };
373
+ return { error: '安装包必须是 .tar.gz 归档' };
385
374
  }
386
375
  let stat;
387
376
  try {
388
377
  stat = fs.statSync(file);
389
378
  } catch {
390
- return { ok: false, error: '上传文件不存在或不可读' };
379
+ return { error: '上传文件不存在或不可读' };
391
380
  }
392
381
  if (stat.size > A2A_INSTALL_MAX_BYTES) {
393
- return { ok: false, error: `文件超过 ${A2A_INSTALL_MAX_MB}MB 限制` };
382
+ return { error: `文件超过 ${A2A_INSTALL_MAX_MB}MB 限制` };
394
383
  }
395
384
  let entries;
396
385
  try {
397
386
  entries = listTarballEntries(file);
398
387
  } catch (err) {
399
- return { ok: false, error: `无法读取归档: ${err.message}` };
388
+ return { error: `无法读取归档: ${err.message}` };
400
389
  }
401
- if (entries.length === 0) return { ok: false, error: '归档为空' };
390
+ if (entries.length === 0) return { error: '归档为空' };
402
391
  const unsafe = entries.find(isUnsafeArchiveEntry);
403
- if (unsafe) return { ok: false, error: `归档包含不安全的路径条目: ${unsafe}` };
392
+ if (unsafe) return { error: `归档包含不安全的路径条目: ${unsafe}` };
404
393
  try {
405
394
  assertNoArchiveLinks(file);
406
395
  } catch (err) {
407
- return { ok: false, error: err.message };
396
+ return { error: err.message };
408
397
  }
409
398
  let pkg;
410
399
  try {
411
400
  pkg = readTarballPackageJson(file, entries);
412
401
  } catch (err) {
413
- return { ok: false, error: `manifest 解析失败: ${err.message}` };
402
+ return { error: `manifest 解析失败: ${err.message}` };
414
403
  }
415
- if (!pkg) return { ok: false, error: '归档缺少 package.json' };
404
+ if (!pkg) return { error: '归档缺少 package.json' };
416
405
  if (pkg.name !== A2A_PACKAGE_NAME) {
417
- return { ok: false, error: `manifest name 不符: 期望 ${A2A_PACKAGE_NAME},实际 ${pkg.name}` };
406
+ return { error: `manifest name 不符: 期望 ${A2A_PACKAGE_NAME},实际 ${pkg.name}` };
418
407
  }
419
408
  if (typeof pkg.version !== 'string' || !pkg.version.trim()) {
420
- return { ok: false, error: 'manifest 缺少 version' };
409
+ return { error: 'manifest 缺少 version' };
421
410
  }
411
+ return { pkg, entries };
412
+ }
413
+
414
+ /**
415
+ * Install @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付).
416
+ * Pipeline: entry listing + path-safety vetting (no `..`/absolute/links) →
417
+ * manifest check (name = @baize-ai/baize-a2a, version present) → extract to
418
+ * SKILLS_DIR/a2a → npm install --omit=dev → components.json registration.
419
+ * Already installed → { ok:false, error } naming the installed version
420
+ * (the web route dispatches to upgradeA2aTarball instead — D49 K3).
421
+ *
422
+ * @returns {Promise<{ok:boolean, version?:string, error?:string}>}
423
+ */
424
+ export async function installA2aTarball(tarballPath, {
425
+ skillsDir = defaultSkillsDir(),
426
+ componentsFile: componentsPath = componentsFile(),
427
+ installDeps = npmInstallDeps,
428
+ rebuildDeps = rebuildNativeDeps,
429
+ originalName = null,
430
+ } = {}) {
431
+ const file = String(tarballPath || '');
432
+ const verdict = validateA2aTarball(file, originalName);
433
+ if (verdict.error) return { ok: false, error: verdict.error };
434
+ const { pkg, entries } = verdict;
422
435
  const targetDir = path.join(skillsDir, 'a2a');
423
436
  const installed = readComponents(componentsPath).a2a;
424
437
  if (fs.existsSync(targetDir) || installed) {
@@ -456,6 +469,175 @@ export async function installA2aTarball(tarballPath, {
456
469
  return { ok: true, version: pkg.version };
457
470
  }
458
471
 
472
+ /** Semver compare (no deps; returns -1|0|1, null when unparseable).
473
+ * Prerelease ranks below release; prerelease identifiers compared
474
+ * numerically when numeric, lexically otherwise. */
475
+ export function compareA2aSemver(a, b) {
476
+ const parse = (v) => String(v || '').trim().replace(/^v/, '')
477
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
478
+ const pa = parse(a);
479
+ const pb = parse(b);
480
+ if (!pa || !pb) return null;
481
+ for (const i of [1, 2, 3]) {
482
+ const av = Number(pa[i]);
483
+ const bv = Number(pb[i]);
484
+ if (av !== bv) return av < bv ? -1 : 1;
485
+ }
486
+ if (pa[4] === pb[4]) return 0;
487
+ if (!pa[4]) return 1;
488
+ if (!pb[4]) return -1;
489
+ const aParts = pa[4].split('.');
490
+ const bParts = pb[4].split('.');
491
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
492
+ const av = aParts[i];
493
+ const bv = bParts[i];
494
+ if (av === bv) continue;
495
+ if (av === undefined) return -1;
496
+ if (bv === undefined) return 1;
497
+ const an = /^\d+$/.test(av) ? Number(av) : null;
498
+ const bn = /^\d+$/.test(bv) ? Number(bv) : null;
499
+ if (an !== null && bn !== null) return an < bn ? -1 : 1;
500
+ if (an !== null) return -1;
501
+ if (bn !== null) return 1;
502
+ return av < bv ? -1 : 1;
503
+ }
504
+ return 0;
505
+ }
506
+
507
+ function readInstalledPkgVersion(skillDir) {
508
+ try {
509
+ const pkg = JSON.parse(fs.readFileSync(path.join(skillDir, 'package.json'), 'utf8'));
510
+ return typeof pkg.version === 'string' ? pkg.version.trim() : null;
511
+ } catch {
512
+ return null;
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Upgrade an installed @baize-ai/baize-a2a from an uploaded .tar.gz
518
+ * (D49 K3 — web 重传 = 升级). Gate: incoming semver must be strictly higher
519
+ * than the installed one (components.json version first, skill package.json
520
+ * fallback); otherwise { ok:false, error:'version_not_higher', local, incoming }.
521
+ * Pipeline: backup skillDir/.backup/<ts> (upgrade.js .backup convention) →
522
+ * extract over skillDir → npm install --omit=dev + rebuild (D44) →
523
+ * components.json version bump → a2a CLI `restart` (covers pm2 AND nohup
524
+ * daemon lifecycles). Extraction/install/rebuild failure rolls the skill dir
525
+ * back from the backup; restart failure is reported, not rolled back (the
526
+ * on-disk upgrade itself succeeded).
527
+ *
528
+ * @returns {Promise<{ok:boolean, version?:string, previousVersion?:string,
529
+ * upgraded?:boolean, restarted?:boolean, restartError?:string, error?:string}>}
530
+ */
531
+ export async function upgradeA2aTarball(tarballPath, {
532
+ skillsDir = defaultSkillsDir(),
533
+ componentsFile: componentsPath = componentsFile(),
534
+ installDeps = npmInstallDeps,
535
+ rebuildDeps = rebuildNativeDeps,
536
+ restartFn = () => runA2aCli(['restart'], { timeout: 120000 }),
537
+ originalName = null,
538
+ } = {}) {
539
+ const file = String(tarballPath || '');
540
+ const verdict = validateA2aTarball(file, originalName);
541
+ if (verdict.error) return { ok: false, error: verdict.error };
542
+ const { pkg, entries } = verdict;
543
+ const targetDir = path.join(skillsDir, 'a2a');
544
+ const components = readComponents(componentsPath);
545
+ if (!fs.existsSync(targetDir) || !components.a2a) {
546
+ return { ok: false, error: 'a2a 未安装,请先安装' };
547
+ }
548
+
549
+ const localVersion = components.a2a.version || readInstalledPkgVersion(targetDir);
550
+ const cmp = compareA2aSemver(pkg.version, localVersion);
551
+ if (cmp === null) {
552
+ return { ok: false, error: '版本号无法解析,拒绝升级', local: localVersion || null, incoming: pkg.version };
553
+ }
554
+ if (cmp <= 0) {
555
+ return { ok: false, error: 'version_not_higher', local: localVersion || null, incoming: pkg.version };
556
+ }
557
+
558
+ // Backup before any write (mirrors cli/lib/upgrade.js step2_backup).
559
+ // Copied per top-level entry: cpSync refuses a destination that is a
560
+ // subdirectory of its source (.backup lives inside the skill dir).
561
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
562
+ const backupDir = path.join(targetDir, '.backup', timestamp);
563
+ try {
564
+ fs.mkdirSync(backupDir, { recursive: true });
565
+ for (const entry of fs.readdirSync(targetDir, { withFileTypes: true })) {
566
+ if (['node_modules', '.backup', '.baize'].includes(entry.name)) continue;
567
+ fs.cpSync(path.join(targetDir, entry.name), path.join(backupDir, entry.name), { recursive: true });
568
+ }
569
+ } catch (err) {
570
+ return { ok: false, error: `备份失败,升级中止: ${err.message}` };
571
+ }
572
+
573
+ // Rollback: clear everything except the backup itself, then copy the
574
+ // backup back (top-level .backup inside targetDir must survive the wipe).
575
+ const restoreBackup = async () => {
576
+ try {
577
+ for (const entry of fs.readdirSync(targetDir)) {
578
+ if (entry !== '.backup') fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true });
579
+ }
580
+ fs.cpSync(backupDir, targetDir, { recursive: true });
581
+ // Best-effort dependency restore for the rolled-back code (node_modules
582
+ // was excluded from the backup); failure here is reported, not fatal.
583
+ await installDeps(targetDir);
584
+ return true;
585
+ } catch {
586
+ return false;
587
+ }
588
+ };
589
+
590
+ try {
591
+ extractTarballTo(file, targetDir, entries);
592
+ } catch (err) {
593
+ const restored = await restoreBackup();
594
+ return { ok: false, error: `解压失败(${restored ? '已回滚' : '回滚失败,请检查 .backup'}): ${err.message}` };
595
+ }
596
+ const depResult = await installDeps(targetDir);
597
+ if (!depResult.ok) {
598
+ const restored = await restoreBackup();
599
+ return { ok: false, error: `npm install 失败(${restored ? '已回滚' : '回滚失败,请检查 .backup'}): ${depResult.error}` };
600
+ }
601
+ const rebuildResult = await rebuildDeps(targetDir);
602
+ if (!rebuildResult.ok) {
603
+ const restored = await restoreBackup();
604
+ return { ok: false, error: `native 模块重建失败(${restored ? '已回滚' : '回滚失败,请检查 .backup'}): ${rebuildResult.error}` };
605
+ }
606
+
607
+ components.a2a.version = pkg.version;
608
+ components.a2a.upgradedAt = new Date().toISOString();
609
+ try {
610
+ writeComponents(componentsPath, components);
611
+ } catch (err) {
612
+ const restored = await restoreBackup();
613
+ return { ok: false, error: `components.json 更新失败(${restored ? '已回滚' : '回滚失败,请检查 .backup'}): ${err.message}` };
614
+ }
615
+
616
+ const restart = await restartFn();
617
+ return {
618
+ ok: true,
619
+ version: pkg.version,
620
+ previousVersion: localVersion || null,
621
+ upgraded: true,
622
+ restarted: Boolean(restart?.success),
623
+ ...(restart?.success ? {} : { restartError: restart?.error || 'restart 未返回成功' }),
624
+ };
625
+ }
626
+
627
+ /**
628
+ * Web upload entry (D49 K3): 未安装 = 安装(D25 现状);已安装 = 走升级管线。
629
+ * The server route delegates here so the dispatch decision lives next to the
630
+ * install/upgrade implementations.
631
+ */
632
+ export async function installOrUpgradeA2aTarball(tarballPath, opts = {}) {
633
+ const skillsDir = opts.skillsDir ?? defaultSkillsDir();
634
+ const componentsPath = opts.componentsFile ?? componentsFile();
635
+ const targetDir = path.join(skillsDir, 'a2a');
636
+ const installed = fs.existsSync(targetDir) || Boolean(readComponents(componentsPath).a2a);
637
+ if (installed) return upgradeA2aTarball(tarballPath, opts);
638
+ return installA2aTarball(tarballPath, opts);
639
+ }
640
+
459
641
  function schedulerDbPath() {
460
642
  return path.join(baizeDir(), 'scheduler', 'scheduler.db');
461
643
  }
@@ -464,6 +646,23 @@ function a2aDbPath() {
464
646
  return process.env.BAIZE_A2A_DB || path.join(baizeDir(), 'components', 'a2a', 'a2a.db');
465
647
  }
466
648
 
649
+ /**
650
+ * D49: read the `baize init` A2A bootstrap result written by the detached
651
+ * `cli.js start --background` child (see baize-a2a daemon-ctl.bootstrapStatePath).
652
+ * Tolerant when the file does not exist (a2a never bootstrapped).
653
+ * @returns {{status?:string, via?:string, pid?:number, error?:string, at?:string}|null}
654
+ */
655
+ export function getA2aBootstrapState() {
656
+ const file = process.env.BAIZE_A2A_BOOTSTRAP_STATE
657
+ || path.join(baizeDir(), 'components', 'a2a', 'bootstrap-state.json');
658
+ try {
659
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
660
+ return raw && typeof raw === 'object' ? raw : null;
661
+ } catch {
662
+ return null;
663
+ }
664
+ }
665
+
467
666
  /**
468
667
  * Task board: task_runs rows created by the A2A worker (source = 'a2a', §3).
469
668
  * Tolerant when the table/DB does not exist yet (baize-a2a not started).
@@ -71,8 +71,9 @@ import {
71
71
  cancelA2aTask,
72
72
  getA2aTaskRuns,
73
73
  getA2aMessages,
74
- installA2aTarball,
74
+ installOrUpgradeA2aTarball,
75
75
  probeA2aDaemonHealthy,
76
+ getA2aBootstrapState,
76
77
  resolveA2aMode,
77
78
  getSchedulerTasks,
78
79
  } from './a2a-admin.js';
@@ -1266,6 +1267,7 @@ app.get('/api/admin/a2a/status', async (req, res) => {
1266
1267
  const payload = { ...(result.json || {}) };
1267
1268
  payload.mode = resolveA2aMode(payload.mode);
1268
1269
  payload.daemonHealthy = await probeA2aDaemonHealthy();
1270
+ payload.bootstrap = getA2aBootstrapState(); // D49: init bootstrap result (launched/ok/failed)
1269
1271
  if (result.success) {
1270
1272
  res.json({ success: true, ...payload });
1271
1273
  } else {
@@ -1385,11 +1387,14 @@ app.get('/api/admin/a2a/messages', (req, res) => {
1385
1387
  }
1386
1388
  });
1387
1389
 
1388
- // Install @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付).
1389
- // multipart field `file`; ≤100MB (413 over); entries vetted against `..` /
1390
- // absolute paths / links before extraction; manifest must be the a2a package
1391
- // with a version; then extract to SKILLS_DIR/a2a → npm install --omit=dev
1392
- // components.json. Response: {ok:true, version} | {ok:false, error}.
1390
+ // Install/upgrade @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付,
1391
+ // D49 K3 双语义). multipart field `file`; ≤100MB (413 over); entries vetted
1392
+ // against `..` / absolute paths / links before extraction. Dispatch (in
1393
+ // a2a-admin.js): 未安装 = 安装 (extract to SKILLS_DIR/a2a → npm install →
1394
+ // components.json); 已安装 = 升级 (version gate new>local else 409 → backup →
1395
+ // extract → npm install+rebuild → components.json bump → a2a cli restart).
1396
+ // Response: {ok:true, version, upgraded?} | {ok:false, error} | 409
1397
+ // {ok:false, error:'version_not_higher', local, incoming}.
1393
1398
  app.post('/api/admin/a2a/install', (req, res) => {
1394
1399
  a2aInstallUpload.single('file')(req, res, async (err) => {
1395
1400
  if (err) {
@@ -1403,10 +1408,13 @@ app.post('/api/admin/a2a/install', (req, res) => {
1403
1408
  return res.status(400).json({ ok: false, error: '缺少 file 字段(multipart 字段名必须为 file)' });
1404
1409
  }
1405
1410
  try {
1406
- const result = await installA2aTarball(file.path, { skillsDir: SKILLS_DIR, originalName: file.originalname });
1411
+ const result = await installOrUpgradeA2aTarball(file.path, { skillsDir: SKILLS_DIR, originalName: file.originalname });
1412
+ if (result.error === 'version_not_higher') {
1413
+ return res.status(409).json(result);
1414
+ }
1407
1415
  res.status(result.ok ? 200 : 400).json(result);
1408
1416
  } catch (e) {
1409
- res.status(500).json({ ok: false, error: `安装失败: ${e.message}` });
1417
+ res.status(500).json({ ok: false, error: `安装/升级失败: ${e.message}` });
1410
1418
  } finally {
1411
1419
  fs.promises.rm(file.path, { force: true }).catch(() => {});
1412
1420
  }
@@ -127,6 +127,12 @@ function loadComponentServices() {
127
127
  // Skip components that haven't finished setup (AI-mode install in progress)
128
128
  if (meta && meta.setupComplete === false) continue;
129
129
 
130
+ // D49: the baize-a2a daemon is owned by its own lifecycle manager
131
+ // (daemon-ctl: pm2 + readiness probe + nohup fallback). Registering it
132
+ // here too created a second start path that raced the dedicated one
133
+ // for port 8443 (double-instance) — a2a is intentionally excluded.
134
+ if (name === 'a2a' || (meta && meta.npmPkg === '@baize-ai/baize-a2a')) continue;
135
+
130
136
  const skillDir = (meta && meta.skillDir) || path.join(SKILLS_DIR, name);
131
137
 
132
138
  // Try loading the component's own ecosystem.config.cjs
@@ -237,14 +237,16 @@ describe('installChannel / uninstallChannel', () => {
237
237
  describe('a2a builtin channel (D19 单元 C)', () => {
238
238
  const a2aSchemaKeys = ['enabled', 'adminUrl', 'agentId', 'advertiseUrl', 'listenPort', 'certKeyPath', 'certCertPath'];
239
239
 
240
- test('catalogue includes a2a with the contract config schema (target: config)', async () => {
240
+ test('a2a keeps the contract config schema but is hidden from the catalogue (D33)', async () => {
241
241
  const channels = await ca.discoverChannels({ fetch: noResultsFetch });
242
- const a2a = channels.find((c) => c.name === 'a2a');
243
- expect(a2a).toBeTruthy();
242
+ // Commercial project: a2a must not appear in any channel/component listing.
243
+ expect(channels.find((c) => c.name === 'a2a')).toBeUndefined();
244
+ // The builtin definition (used by configure/status) keeps the D19 schema.
245
+ const a2a = ca.BUILTIN_CHANNELS.a2a;
244
246
  expect(a2a).toMatchObject({
247
+ name: 'a2a',
245
248
  npmPkg: '@baize-ai/baize-a2a',
246
249
  repo: 'baize-ai/baize-a2a',
247
- installed: false,
248
250
  });
249
251
  expect(a2a.configSchema.map((f) => f.key)).toEqual(a2aSchemaKeys);
250
252
  expect(a2a.configSchema.every((f) => f.target === 'config')).toBe(true);
@@ -286,7 +288,7 @@ describe('a2a builtin channel (D19 单元 C)', () => {
286
288
  });
287
289
 
288
290
  test('channelStatus for installed a2a: configured only when enabled + both urls', async () => {
289
- const entry = (await ca.discoverChannels({ fetch: noResultsFetch })).find((c) => c.name === 'a2a');
291
+ const entry = ca.BUILTIN_CHANNELS.a2a;
290
292
  fs.writeFileSync(path.join(baizeDir, '.baize', 'components.json'), JSON.stringify({
291
293
  a2a: { version: '0.1.0', source: { type: 'npm' } },
292
294
  }));
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Child-process driver for the K4 --file upgrade e2e tests: runs the REAL
3
+ * upgradeComponent() flow in a clean process so BAIZE_DIR resolves the fixture
4
+ * root (cli/lib/config.js reads env at import time) — Jest's sandboxed
5
+ * process.env is not inherited by grandchildren, so the flow must run outside
6
+ * the Jest process.
7
+ *
8
+ * argv: <component> <tgzPath> [--check]
9
+ * stdout: the command output (JSON — --json is always passed)
10
+ */
11
+ const [component, tgzPath, checkFlag] = process.argv.slice(2);
12
+ const { upgradeComponent } = await import('../../cli/commands/component.js');
13
+ const args = [component, '--file', tgzPath, '--yes', '--json'];
14
+ if (checkFlag === '--check') args.push('--check');
15
+ await upgradeComponent(args);