@baize-ai/core 0.3.14 → 0.3.16

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.
@@ -11,7 +11,7 @@
11
11
  # 兼容 web 控制台上传与 `baize add --file` 两种安装)
12
12
  # baize-admin-workspace-<v>.tgz admin 套件源码:server 源码 + admin-ui 构建产物 +
13
13
  # Dockerfile + 部署说明(自建镜像 / 自托管用)
14
- # baize-admin-workspace-<v>.tar docker save 的 admin 镜像(docker load 直接运行,推荐)
14
+ # baize-admin-workspace-<v>.tar (可选)docker save 的 admin 镜像——仅 BUILD_ADMIN_DOCKER=1 打包时产出
15
15
  # INSTALL.md 客户侧安装说明(上传 / --file 安装 + 切集群 + admin 部署)
16
16
  #
17
17
  # 用法:
@@ -155,8 +155,14 @@ build_admin_tgz() {
155
155
  log " → ${out} ($(size "${out}"))"
156
156
  }
157
157
 
158
- # ── 3. Admin Docker 镜像(docker save)──────────────────────────────────────
158
+ # ── 3. Admin Docker 镜像(docker save)——可选 ─────────────────────────────
159
+ # 商业版 admin 默认源码交付(tgz 含 server + 已构建 admin-ui/dist)。
160
+ # 仅当 BUILD_ADMIN_DOCKER=1 时才导出 docker save 镜像(需先构建 baize-admin-workspace:test)。
159
161
  save_admin_image() {
162
+ if [ "${BUILD_ADMIN_DOCKER:-0}" != "1" ]; then
163
+ step "跳过 admin Docker 镜像导出(源码交付模式;需要时 BUILD_ADMIN_DOCKER=1 重跑)"
164
+ return 0
165
+ fi
160
166
  step "导出 admin Docker 镜像"
161
167
  local image="baize-admin-workspace:test"
162
168
  if ! docker image inspect "${image}" >/dev/null 2>&1; then
@@ -231,36 +237,35 @@ baize a2a status # 期望:集群·在线
231
237
  ```bash
232
238
  baize a2a disable # 停 daemon,保留配置与注册数据,模式回 standalone
233
239
  ```
240
+ ### 方式 A:源码部署(推荐——商业版默认交付形态)
234
241
 
235
- ## 三、部署 Admin(控制面)
236
-
237
- ### 方式 A:Docker 镜像(推荐)
242
+ 套件已含 server 源码与**构建好的** `admin-ui/dist`,无需前端构建:
238
243
 
239
244
  ```bash
240
- docker load -i baize-admin-workspace-__VERSION__.tar
241
- docker run -d --name baize-admin -p 8080:8080 \
242
- -v baize-admin-data:/data \
243
- -e BAIZE_ADMIN_TOKEN='<初始口令>' \
244
- baize-admin-workspace:test
245
+ tar xzf baize-admin-workspace-__VERSION__.tgz
246
+ cd baize-admin-workspace-__VERSION__
247
+ npm ci --omit=dev # Node ≥ 20.11
248
+ BAIZE_ADMIN_TOKEN='<初始口令>' BAIZE_ADMIN_DIR=/srv/baize-admin \
249
+ pm2 start server/src/index.js --name baize-admin # 或 nohup node server/src/index.js
245
250
  ```
246
251
 
247
- - 访问 `http://<主机>:8080`,首次登录:`admin` + 初始口令(日志打印或 `BAIZE_ADMIN_TOKEN`);
248
- - 登录后立即修改口令;数据全部落在 `/data` 卷(SQLite 库与签名密钥),务必持久化;
249
- - 公网部署建议放在 HTTPS 反代后面,`baize a2a enable --admin` 使用对外 HTTPS 地址。
252
+ - API 监听 8080;`BAIZE_ADMIN_DIR` 是数据目录(SQLite/CA/签名钥),必须持久化并提前建目录;
253
+ - 前端 `admin-ui/dist` 是静态产物:用 nginx/Caddy 反代到与 API 同源的根路径;
254
+ - 公网部署必须 `TRUST_PROXY=1`(信任反代 X-Forwarded-For,否则限流按代理 IP 计数);
255
+ - 首次登录 `admin` + 初始口令(日志打印),登录后到「设置」改口令。
250
256
 
251
- ### 方式 B:源码套件(自建镜像 / 自托管)
257
+ ### 方式 B:Docker(可选——需自建镜像)
252
258
 
253
259
  ```bash
254
- tar xzf baize-admin-workspace-__VERSION__.tgz
255
- cd baize-admin-workspace-__VERSION__
256
260
  docker build -t baize-admin-workspace . # 多阶段:node:22 构建 admin-ui → node:22-slim 运行
257
- docker run -d --name baize-admin -p 8080:8080 -v baize-admin-data:/data baize-admin-workspace
261
+ docker run -d --name baize-admin -p 8080:8080 \
262
+ -v baize-admin-data:/data -e BAIZE_ADMIN_TOKEN='<初始口令>' baize-admin-workspace
258
263
  ```
259
264
 
260
- - 本地运行:`npm ci && npm start`(Node ≥ 20.11,默认 8080);
261
- - 前端构建产物已含在 `admin-ui/dist`(容器内 `/app/dist`),可与 API 同源静态提供。
265
+ > 厂商如需分发 Docker 镜像 tar:打包时 `BUILD_ADMIN_DOCKER=1 ./scripts/pack-release.sh`(本默认交付不包含 tar)。
262
266
 
263
- ## 四、卸载
267
+ - 前端构建产物已含在 `admin-ui/dist`,与 API 同源静态提供(nginx/Caddy 指到该目录即可)。
268
+ - API 默认 8080(`PORT` 可改);本地直跑:`BAIZE_ADMIN_DIR=/srv/baize-admin npm start`。
264
269
 
265
270
  ```bash
266
271
  baize a2a disable # 先切回单机 / 停服务
@@ -332,13 +337,16 @@ verify() {
332
337
  echo " !! ${admin_tgz} 校验失败(解压或 manifest 不正确)" >&2
333
338
  rc=1
334
339
  fi
335
-
336
- # docker save 产物:存在且非空
337
- if [ -s "${admin_tar}" ]; then
338
- log " OK ${admin_tar} ($(size "${admin_tar}")) 存在且非空"
340
+ # docker save 产物:可选(BUILD_ADMIN_DOCKER=1 打包时才存在)
341
+ if [ -e "${admin_tar}" ] || [ "${BUILD_ADMIN_DOCKER:-0}" = "1" ]; then
342
+ if [ -s "${admin_tar}" ]; then
343
+ log " OK ${admin_tar} ($(size "${admin_tar}")) 存在且非空"
344
+ else
345
+ echo " !! ${admin_tar} 不存在或为空" >&2
346
+ rc=1
347
+ fi
339
348
  else
340
- echo " !! ${admin_tar} 不存在或为空" >&2
341
- rc=1
349
+ log " -- ${admin_tar##*/} 未打包(源码交付模式,BUILD_ADMIN_DOCKER=1 可启用)"
342
350
  fi
343
351
 
344
352
  [ -f "${DIST_DIR}/INSTALL.md" ] && log " OK ${DIST_DIR}/INSTALL.md ($(size "${DIST_DIR}/INSTALL.md"))"
@@ -1170,7 +1170,18 @@ async function loadProviders(basePath) {
1170
1170
  const names = { official: 'Anthropic 官方', 'official-openai': 'OpenAI 官方' };
1171
1171
  const prov = (body.providers || []).find((x) => x.id === body.active);
1172
1172
  const label = prov?.name || names[body.active] || body.active || 'official';
1173
- activeEl.textContent = `当前激活:${label}`;
1173
+ const codexSlug = body.codexProviderKey || prov?.codex?.providerKey || null;
1174
+ const codexKind = prov?.codex?.kind || null;
1175
+ // D51 review P3: surface the kind so openai-official (no block) vs
1176
+ // responses-compatible (provider block) are distinguishable at a glance.
1177
+ const codexTag = codexKind === 'responses-compatible' && codexSlug
1178
+ ? `(codex: ${codexSlug} · responses)`
1179
+ : codexKind === 'openai-official'
1180
+ ? '(codex: OpenAI 官方)'
1181
+ : codexSlug
1182
+ ? `(codex: ${codexSlug})`
1183
+ : '';
1184
+ activeEl.textContent = `当前激活:${label}${codexTag}`;
1174
1185
  }
1175
1186
  const list = document.getElementById('provider-list');
1176
1187
  if (!list) return;
@@ -1249,7 +1260,8 @@ async function activateProviderFromAdmin(basePath, id) {
1249
1260
  const restartNote = body.restart?.restarted ? ',会话已重启' : (body.restart?.reason === 'no_session' ? '(无运行中会话)' : '');
1250
1261
  const warnNote = body.warnings?.length ? `
1251
1262
  警告:${body.warnings.join('\n')}` : '';
1252
- setAdminMsg(`已激活 ${body.active}${restartNote}${warnNote}`);
1263
+ const slugNote = body.codexProviderKey ? `(codex: ${body.codexProviderKey})` : '';
1264
+ setAdminMsg(`已激活 ${body.active}${slugNote}${restartNote}${warnNote}`);
1253
1265
  } else {
1254
1266
  setAdminMsg(`激活失败:${body.error || '未知错误'}`, true);
1255
1267
  }
@@ -1719,10 +1731,10 @@ function updateA2aInstallArea(body) {
1719
1731
  if (!el || !fileInput || !btn) return;
1720
1732
  const version = body && (body.version || body.installedVersion);
1721
1733
  if (version) {
1722
- el.textContent = `已安装 baize-a2a v${String(version).replace(/^v/, '')}。如需升级请重新上传模块包。`;
1734
+ el.textContent = `已安装 baize-a2a v${String(version).replace(/^v/, '')} — 上传更高版本的模块包(.tgz)可在线升级(相同或更低版本将被拒绝)。`;
1723
1735
  if (state) { state.textContent = '已安装'; state.className = 'channel-state ok'; }
1724
- fileInput.disabled = true;
1725
- btn.disabled = true;
1736
+ fileInput.disabled = false;
1737
+ btn.disabled = false;
1726
1738
  } else {
1727
1739
  el.textContent = '未安装 — 上传 baize-a2a 模块包(.tgz)完成安装。';
1728
1740
  if (state) { state.textContent = '未安装'; state.className = 'channel-state warn'; }
@@ -1782,11 +1794,16 @@ function updateA2aHealth(body, peerCountOverride) {
1782
1794
  } else if (_lastA2aPeerCount !== null) {
1783
1795
  peerCount = _lastA2aPeerCount;
1784
1796
  }
1797
+ const bs = body && body.bootstrap ? body.bootstrap : null;
1798
+ const bsLabel = !bs ? '' : (bs.status === 'ok' || bs.status === 'launched'
1799
+ ? `启动中/已启动(${escapeHtml(String(bs.via || bs.status))})`
1800
+ : `失败:${escapeHtml(String(bs.error || bs.status))}`);
1785
1801
  const rows = [
1786
1802
  ['Daemon 健康', healthy ? '✅ 正常' : '❌ 异常'],
1787
1803
  ['运行模式', modeLabel],
1788
1804
  ['Peer 数', peerCount],
1789
1805
  ['最近心跳', a2aHeartbeatLabel(body)],
1806
+ ...(bsLabel ? [['init 自启动', bsLabel]] : []),
1790
1807
  ].filter(([, v]) => v !== undefined && v !== null && v !== '');
1791
1808
  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('');
1792
1809
  if (state) {
@@ -1858,13 +1875,11 @@ async function openAgentCardEditor(basePath) {
1858
1875
  let catalogue;
1859
1876
  let card;
1860
1877
  let authz;
1861
- let peerIds;
1862
1878
  try {
1863
- const [catRes, cardRes, authzRes, peersRes] = await Promise.all([
1879
+ const [catRes, cardRes, authzRes] = await Promise.all([
1864
1880
  adminFetch(basePath, '/api/admin/a2a/card/skills'),
1865
1881
  adminFetch(basePath, '/api/admin/a2a/card'),
1866
1882
  adminFetch(basePath, '/api/admin/a2a/authz'),
1867
- adminFetch(basePath, '/api/admin/a2a/peers'),
1868
1883
  ]);
1869
1884
  if (catRes.status !== 200) throw new Error(catRes.body.error || 'skills 列表加载失败');
1870
1885
  if (cardRes.status !== 200) throw new Error(cardRes.body.error || '名片加载失败');
@@ -1872,9 +1887,6 @@ async function openAgentCardEditor(basePath) {
1872
1887
  catalogue = catRes.body.skills || [];
1873
1888
  card = cardRes.body;
1874
1889
  authz = authzRes.body;
1875
- peerIds = peersRes.status === 200 && Array.isArray(peersRes.body.peers)
1876
- ? peersRes.body.peers.map((p) => p.agentId).filter(Boolean)
1877
- : [];
1878
1890
  } catch (err) {
1879
1891
  setAdminMsg(`❌ 无法打开名片编辑器:${err.message}`, true);
1880
1892
  return;
@@ -1912,14 +1924,7 @@ async function openAgentCardEditor(basePath) {
1912
1924
  <label class="authz-mode-option"><input type="radio" name="authz-mode" value="allowlist"> 白名单</label>
1913
1925
  </div>
1914
1926
  <p class="conn-block-desc" id="authz-mode-desc"></p>
1915
- <div class="agent-card-field-label"><span id="authz-list-title">阻止列表</span> <span class="agent-card-selected-count" id="authz-count"></span></div>
1916
- <div class="authz-tags" id="authz-tags" aria-live="polite"></div>
1917
- <div class="authz-add-row">
1918
- <input type="text" id="authz-peer-input" class="agent-card-input" placeholder="输入 peer agent_id 后添加" autocomplete="off" spellcheck="false">
1919
- <button type="button" class="btn btn-outline" id="authz-peer-add">添加</button>
1920
- </div>
1921
- <div class="agent-card-field-label">从 peer 列表选择</div>
1922
- <select id="authz-peer-select" class="agent-card-input" aria-label="从 peer 列表选择"></select>
1927
+ <p class="conn-block-desc" style="color:#8c8c8c">允许 / 阻止名单由 admin 控制台统一管控(组织架构 · 调用白名单),此处仅切换本 agent 的授权模式。</p>
1923
1928
  </div>
1924
1929
  </div>
1925
1930
  <div class="agent-card-modal-foot">
@@ -1938,18 +1943,10 @@ async function openAgentCardEditor(basePath) {
1938
1943
  nameInput.value = card.name || '';
1939
1944
  descInput.value = card.description || '';
1940
1945
 
1941
- // ── 调用授权 tab state (D22 单元 C) ──
1942
- const allowSet = new Set(Array.isArray(authz.allow) ? authz.allow : []);
1943
- const blockSet = new Set(Array.isArray(authz.block) ? authz.block : []);
1946
+ // ── 调用授权 tab state (D52: local mode only; lists are admin-managed) ──
1944
1947
  let authzMode = authz.mode === 'allowlist' ? 'allowlist' : 'open';
1945
- const activeAuthzList = () => (authzMode === 'allowlist' ? allowSet : blockSet);
1946
1948
 
1947
1949
  const authzModeDesc = overlay.querySelector('#authz-mode-desc');
1948
- const authzListTitle = overlay.querySelector('#authz-list-title');
1949
- const authzCount = overlay.querySelector('#authz-count');
1950
- const authzTagsEl = overlay.querySelector('#authz-tags');
1951
- const authzPeerInput = overlay.querySelector('#authz-peer-input');
1952
- const authzPeerSelect = overlay.querySelector('#authz-peer-select');
1953
1950
 
1954
1951
  const renderAuthz = () => {
1955
1952
  overlay.querySelectorAll('input[name="authz-mode"]').forEach((r) => {
@@ -1957,19 +1954,8 @@ async function openAgentCardEditor(basePath) {
1957
1954
  });
1958
1955
  const isAllowlist = authzMode === 'allowlist';
1959
1956
  authzModeDesc.textContent = isAllowlist
1960
- ? '仅允许列表中的 agent 可以调用本 agent(空列表 = 无人可调用)。'
1961
- : '接受所有已批准 agent,除阻止列表中的 agent。';
1962
- authzListTitle.textContent = isAllowlist ? '允许列表' : '阻止列表';
1963
- const list = [...activeAuthzList()];
1964
- authzCount.textContent = `${list.length} 个`;
1965
- authzTagsEl.innerHTML = list.length
1966
- ? list.map((id) => `<span class="authz-tag">${escapeHtml(id)}<button type="button" class="authz-tag-x" aria-label="移除 ${escapeHtml(id)}" data-id="${escapeHtml(id)}">×</button></span>`).join('')
1967
- : '<span class="cred-state">(空)</span>';
1968
- const current = new Set(activeAuthzList());
1969
- const available = peerIds.filter((id) => !current.has(id));
1970
- authzPeerSelect.innerHTML = `<option value="">${available.length ? '选择 peer 添加到列表…' : '列表已包含所有已知 peer'}</option>`
1971
- + available.map((id) => `<option value="${escapeHtml(id)}">${escapeHtml(id)}</option>`).join('');
1972
- authzPeerSelect.disabled = available.length === 0;
1957
+ ? '仅允许白名单中的 agent 调用本 agent(白名单由 admin 控制台配置;空名单 = 无人可调用)。'
1958
+ : '接受所有已批准 agent(除 admin 控制台配置的阻止名单)。';
1973
1959
  };
1974
1960
 
1975
1961
  overlay.querySelectorAll('input[name="authz-mode"]').forEach((r) => {
@@ -1981,42 +1967,6 @@ async function openAgentCardEditor(basePath) {
1981
1967
  });
1982
1968
  });
1983
1969
 
1984
- const addAuthzPeer = () => {
1985
- const value = authzPeerInput.value.trim();
1986
- if (!value) {
1987
- setAdminMsg('请输入 peer agent_id', true);
1988
- return;
1989
- }
1990
- const list = activeAuthzList();
1991
- if (list.has(value)) {
1992
- setAdminMsg(`⚠️ ${value} 已在${authzMode === 'allowlist' ? '允许' : '阻止'}列表中`, true);
1993
- return;
1994
- }
1995
- list.add(value);
1996
- authzPeerInput.value = '';
1997
- renderAuthz();
1998
- };
1999
- authzPeerInput.addEventListener('keydown', (e) => {
2000
- if (e.key === 'Enter') {
2001
- e.preventDefault();
2002
- addAuthzPeer();
2003
- }
2004
- });
2005
- overlay.querySelector('#authz-peer-add').addEventListener('click', addAuthzPeer);
2006
- authzPeerSelect.addEventListener('change', () => {
2007
- const value = authzPeerSelect.value;
2008
- if (!value) return;
2009
- activeAuthzList().add(value);
2010
- renderAuthz();
2011
- });
2012
- authzTagsEl.addEventListener('click', (e) => {
2013
- const btn = e.target.closest('.authz-tag-x');
2014
- if (btn) {
2015
- activeAuthzList().delete(btn.dataset.id);
2016
- renderAuthz();
2017
- }
2018
- });
2019
-
2020
1970
  // ── Tab switching ──
2021
1971
  const tabButtons = overlay.querySelectorAll('.agent-card-tab');
2022
1972
  const cardTabEl = overlay.querySelector('#agent-card-tab-card');
@@ -2118,7 +2068,7 @@ async function openAgentCardEditor(basePath) {
2118
2068
  adminFetch(basePath, '/api/admin/a2a/authz', {
2119
2069
  method: 'PUT',
2120
2070
  headers: { 'Content-Type': 'application/json' },
2121
- body: JSON.stringify({ mode: authzMode, allow: [...allowSet], block: [...blockSet] }),
2071
+ body: JSON.stringify({ mode: authzMode }),
2122
2072
  }),
2123
2073
  ]);
2124
2074
  if (cardRes.status === 401 || authzRes.status === 401) {
@@ -2282,7 +2232,7 @@ async function submitA2aInstall(basePath, btn) {
2282
2232
  }
2283
2233
  const original = btn.textContent;
2284
2234
  btn.disabled = true;
2285
- btn.textContent = '上传安装中...';
2235
+ btn.textContent = '上传处理中...';
2286
2236
  feedback.textContent = '';
2287
2237
  feedback.className = '';
2288
2238
  const fd = new FormData();
@@ -2291,11 +2241,23 @@ async function submitA2aInstall(basePath, btn) {
2291
2241
  const { status, body } = await adminFetch(basePath, '/api/admin/a2a/install', { method: 'POST', body: fd });
2292
2242
  if (body.ok === true || body.success === true) {
2293
2243
  const version = body.version ? ` v${String(body.version).replace(/^v/, '')}` : '';
2294
- feedback.textContent = `✅ 安装成功${version}`;
2244
+ const verb = body.upgraded ? '升级' : '安装';
2245
+ let okMsg = `✅ ${verb}成功${version}`;
2246
+ if (body.upgraded && body.restarted === false) {
2247
+ okMsg += `(⚠️ daemon 重启失败:${body.restartError || '未知原因'},请检查状态卡或手动重启)`;
2248
+ }
2249
+ feedback.textContent = okMsg;
2295
2250
  feedback.className = 'a2a-feedback-ok';
2296
2251
  fileInput.value = '';
2297
- setAdminMsg(`✅ baize-a2a 安装成功${version}`);
2298
- loadA2aStatus(basePath); // 刷新版本/模式/健康
2252
+ setAdminMsg(`✅ baize-a2a ${verb}成功${version}`);
2253
+ loadA2aStatus(basePath); // 刷新版本/模式/健康/安装卡
2254
+ } else if (body.error === 'version_not_higher') {
2255
+ const local = body.local ? `v${String(body.local).replace(/^v/, '')}` : '未知';
2256
+ const incoming = body.incoming ? `v${String(body.incoming).replace(/^v/, '')}` : '未知';
2257
+ const msg = `上传版本 ${incoming} 不高于已安装版本 ${local},未执行升级`;
2258
+ feedback.textContent = `❌ 升级被拒绝:${msg}`;
2259
+ feedback.className = 'a2a-feedback-error';
2260
+ setAdminMsg(`❌ 升级被拒绝:${msg}`, true);
2299
2261
  } else {
2300
2262
  const msg = body.error || body.message || `HTTP ${status}`;
2301
2263
  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).