@baize-ai/core 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.dockerignore CHANGED
@@ -6,3 +6,5 @@
6
6
  !templates/pm2/ecosystem.config.cjs
7
7
  !.claude-local/claude
8
8
  !.claude-local/claude.version
9
+ !skills/
10
+ !.docker-local-skills/
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to baize-core will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.13] - 2026-08-28
9
+
10
+ ### Fixed
11
+ - A2A 上传安装后自动 rebuild 原生模块(D44):上传安装用 --ignore-scripts(vendor 包安全),better-sqlite3 等 native 模块不编译 → 上传后 A2A 状态加载失败;安装流程现追加 `npm rebuild --build-from-source`(只编译、不跑 vendor 脚本),失败回滚
12
+ - Docker 镜像 npm 源修复(D43):容器 npm 运行时是 baize 用户(prefix /home/baize/.npm-global)——镜像只写了 /usr/etc/npmrc(root 视角)npm 不读 → ACR 镜像 npm 仍走 npmjs;现同时写 /home/baize/.npmrc(优先级最高必读)
13
+
14
+ ## [0.3.12] - 2026-08-28
15
+
16
+ ### Added
17
+ - web 控制台 runtime 未安装时异步安装(D39):切换 claude/codex 检测 CLI 缺失 → 后台安装(install.sh/npm,900s 超时 + 重试)→ 装完自动切换;状态端点 + 前端进度轮询(修复 240s/300s 双超时导致的切 cc 失败)
18
+ - 初始化失败 web 可见(D40):entrypoint 写 init-state.json,诊断卡显示「初始化未完成」+ 引导
19
+ - runtime 安装状态可见(D41):模型设置页每个 runtime 显示安装徽章(✅ 版本 / ❌ 未装 + 失败原因)+ [安装] 按钮(复用 D39 异步安装)+ 当前 runtime 缺失红色警告
20
+ - 环境修复闭环(D42):环境初始化卡置顶(未就绪缺口人话 + [重新初始化] 一键幂等 init + 失败人话原因 + web 内日志展开)——web 是唯一恢复入口,无需 SSH
21
+
22
+ ### Fixed
23
+ - Docker 镜像:不捆绑 claude(单架构二进制导致另一平台 exec format error);npm/apt 源跟随推送目标(ghcr 默认官方源,ACR 构建全 npmmirror/阿里云);entrypoint 首次默认 codex(与 install.sh 统一,已初始化尊重现有 runtime)
24
+
8
25
  ## [0.3.11] - 2026-08-27
9
26
 
10
27
  ### Fixed
package/Dockerfile CHANGED
@@ -14,8 +14,21 @@ FROM node:22-slim
14
14
 
15
15
  LABEL org.opencontainers.image.source="https://github.com/baize01-ai/baize-core"
16
16
  LABEL org.opencontainers.image.description="Baize — autonomous AI agent infrastructure"
17
+ # Build flags: official sources by default (npmjs + Debian). Set to "aliyun"
18
+ # when building for the Aliyun ACR (mainland distribution) — npmmirror npm +
19
+ # Aliyun Debian mirror, baked into the image so docker run npm also uses the
20
+ # mainland source. ARG must precede the RUNs that reference them.
21
+ ARG BAIZE_APT_MIRROR=off
22
+ ARG BAIZE_NPM_MIRROR=off
17
23
 
18
24
  # ── System packages ───────────────────────────────────────────────────────────
25
+ # Point apt at the Aliyun Debian mirror when building on mainland servers
26
+ # (deb.debian.org is unreachable/slow there; env flag lets non-CN builds keep
27
+ # the official source).
28
+ RUN if [ "${BAIZE_APT_MIRROR:-off}" = "aliyun" ]; then \
29
+ sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources; \
30
+ sed -i 's|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources; \
31
+ fi
19
32
  RUN apt-get update && apt-get install -y --no-install-recommends \
20
33
  git \
21
34
  curl \
@@ -35,22 +48,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
35
48
  g++ \
36
49
  && rm -rf /var/lib/apt/lists/*
37
50
 
38
- # ── Global npm tools ──────────────────────────────────────────────────────────
51
+ # Mainland: registry.npmjs.org is unreachable — point npm at npmmirror
52
+ # (build-arg BAIZE_NPM_MIRROR=off keeps the official registry).
53
+ RUN if [ "${BAIZE_NPM_MIRROR:-off}" = "aliyun" ]; then \
54
+ npm config set registry https://registry.npmmirror.com --global; \
55
+ # Also drop a system-wide npmrc so the runtime user (baize) and any
56
+ # docker exec npm use the mainland mirror too.
57
+ mkdir -p /usr/etc && printf 'registry=https://registry.npmmirror.com\n' > /usr/etc/npmrc; \
58
+ # Container npm runs as USER baize with prefix /home/baize/.npm-global —
59
+ # its globalconfig is NOT /usr/etc/npmrc, so also write the user-level
60
+ # ~/.npmrc (highest precedence, always read) or npm stays on npmjs.
61
+ mkdir -p /home/baize && printf 'registry=https://registry.npmmirror.com\n' > /home/baize/.npmrc; \
62
+ fi
39
63
  RUN npm install -g pm2@latest
40
64
 
41
- # ── Claude Code CLI (pre-bundled, pinned version) ─────────────────────────────
42
- # The official install.sh downloads a ~340MB binary at container runtime, which
43
- # is unreliable inside container networks (fails ~90% through on Docker
44
- # Desktop). Bundle a PINNED, verified binary in the image instead:
45
- # curl -fsSL https://downloads.claude.ai/claude-code-releases/<ver>/linux-arm64/claude
46
- # (verify against .../manifest.json platforms.linux-arm64 checksum)
47
- # echo <ver> > .claude-local/claude.version
48
- # Upgrade = replace the binary + bump claude.version, then rebuild the image
49
- # (explicit release decision — never auto-fetch latest at build time).
50
- COPY .claude-local/claude /usr/local/bin/claude
51
- RUN chmod +x /usr/local/bin/claude
52
- COPY .claude-local/claude.version /usr/local/bin/claude.version
53
- LABEL org.opencontainers.image.claude-version="$(cat .claude-local/claude.version 2>/dev/null || echo unknown)"
54
65
 
55
66
  # ── Create baize user (non-root) ──────────────────────────────────────────────
56
67
  RUN useradd -m -s /bin/bash baize \
@@ -71,6 +82,11 @@ ENV WEB_CONSOLE_BIND=0.0.0.0
71
82
  # build context) — source stays private, the image matches the npm version
72
83
  # exactly. Build with: docker build --build-arg BAZE_CORE_VERSION=0.2.0 .
73
84
  ARG BAZE_CORE_VERSION=latest
85
+ # Local-development override: when building from source with
86
+ # --build-arg BAIZE_LOCAL_SKILLS=1, copy the LOCAL skills over the npm-package
87
+ # skills so un-released fixes (e.g. D44) land in the image without publishing.
88
+ # Official builds leave this off — the published npm package is authoritative.
89
+ ARG BAIZE_LOCAL_SKILLS=0
74
90
  WORKDIR /home/baize
75
91
  RUN npm install -g @baize-ai/core@${BAZE_CORE_VERSION} \
76
92
  && baize --version \
@@ -113,6 +129,14 @@ RUN npm install -g @baize-ai/core@${BAZE_CORE_VERSION} \
113
129
  && node -e "const D=require('/home/baize/baize/.claude/skills/web-console/node_modules/better-sqlite3'); new D(':memory:')" \
114
130
  && echo "native modules OK (locally compiled, baked into runtime skills)"
115
131
 
132
+ # ── Local-source skills override (BAIZE_LOCAL_SKILLS=1) ─────────────────────
133
+ # docker-publish.sh stages the repo's skills/ into .docker-local-skills/ when
134
+ # BAIZE_LOCAL_SKILLS=1 (empty directory otherwise). They land in
135
+ # /home/baize/.local-skills and are applied by entrypoint AFTER init, because
136
+ # init's postinstall syncSkills uses the npm-package skills as source and
137
+ # would otherwise overwrite them. Official images carry an empty dir — no-op.
138
+ COPY --chown=baize:baize .docker-local-skills/ /home/baize/.local-skills/
139
+
116
140
  # ── Workspace directories ─────────────────────────────────────────────────────
117
141
  # ~/baize is mounted as a single volume in docker-compose.yml.
118
142
  # Creating subdirectories here ensures correct ownership in the image.
@@ -65,15 +65,14 @@ if [ -n "${AUTH_TOKEN}" ]; then
65
65
  fi
66
66
  fi
67
67
 
68
- # Detect runtime — if only Codex credentials are present (no Claude creds), default to codex.
69
- # BAIZE_RUNTIME env var always wins when explicitly set.
68
+ # Detect runtime — default to codex ONLY on first boot (mainland default;
69
+ # the image no longer bundles claude, which is installed manually when
70
+ # needed). If the workspace is already initialized (config.json exists) the
71
+ # existing runtime is respected — init would otherwise reset a runtime the
72
+ # user switched to in the web console. BAIZE_RUNTIME env always wins.
70
73
  RUNTIME_FLAG=""
71
74
  if [ -z "${BAIZE_RUNTIME:-}" ]; then
72
- HAS_CLAUDE_AUTH=false
73
- HAS_CODEX_AUTH=false
74
- [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && HAS_CLAUDE_AUTH=true
75
- [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${CODEX_API_KEY:-}" ] && HAS_CODEX_AUTH=true
76
- if [ "${HAS_CODEX_AUTH}" = true ] && [ "${HAS_CLAUDE_AUTH}" = false ]; then
75
+ if [ ! -f "${BAIZE_DIR}/.baize/config.json" ]; then
77
76
  RUNTIME_FLAG="--runtime codex"
78
77
  fi
79
78
  fi
@@ -87,9 +86,26 @@ INIT_ARGS="--yes --quiet"
87
86
  # shellcheck disable=SC2086
88
87
  if ! baize init ${INIT_ARGS}; then
89
88
  warn "baize init exited with errors (may be partial). Check logs."
89
+ # Surface init failure to the web console (diagnostics card) so the user can
90
+ # discover and fix it in-browser (e.g. a runtime CLI that failed to install).
91
+ mkdir -p "${BAIZE_DIR}/.baize"
92
+ printf '{"status":"failed","error":"baize init exited with errors","at":"%s"}\n' "$(date -Iseconds)" > "${BAIZE_DIR}/.baize/init-state.json"
93
+ exit 1
90
94
  fi
91
-
92
95
  ok "Workspace ready"
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
107
+
108
+ # ── Pass through channel env vars to .env ─────────────────────────────────────
93
109
 
94
110
  # ── Pass through channel env vars to .env ─────────────────────────────────────
95
111
  # baize init doesn't write channel tokens — those come from component installs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baize-ai/core",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "description": "Baize (\u767d\u6cfd) \u2014 autonomous AI agent infrastructure",
6
6
  "main": "cli/baize.js",
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # Multi-platform build + push to Aliyun ACR for the mainland distribution.
3
+ # Usage: bash build-push-acr.sh [VERSION] (default 0.3.11)
4
+ # Requires: docker login already done, qemu-arm64 registered
5
+ # (apt install qemu-user-binfmt), registry mirror configured.
6
+ set -euo pipefail
7
+
8
+ VERSION="${1:-0.3.11}"
9
+ ACR="crpi-8bg62qbrjs3cr3as.cn-hangzhou.personal.cr.aliyuncs.com/baize01/baize-core"
10
+
11
+ echo "== Configure buildkit registry mirror (mainland Docker Hub access) =="
12
+ mkdir -p /etc/buildkit
13
+ cat > /etc/buildkit/buildkit.toml <<'EOF'
14
+ [registry."docker.io"]
15
+ mirrors = ["https://docker.m.daocloud.io"]
16
+ EOF
17
+
18
+ echo "== Ensure multi-platform builder (reuse for layer cache) =="
19
+ if ! docker buildx inspect multi >/dev/null 2>&1; then
20
+ docker buildx create --name multi --driver docker-container \
21
+ --config /etc/buildkit/buildkit.toml --use
22
+ fi
23
+ docker buildx use multi 2>/dev/null || true
24
+
25
+ echo "== Build + push $VERSION (amd64 + arm64) =="
26
+ docker buildx build \
27
+ --platform linux/amd64,linux/arm64 \
28
+ --provenance=false --sbom=false \
29
+ --build-arg BAZE_CORE_VERSION="$VERSION" \
30
+ --build-arg BAIZE_APT_MIRROR=aliyun \
31
+ --build-arg BAIZE_NPM_MIRROR=aliyun \
32
+ -t "$ACR:$VERSION" \
33
+ -t "$ACR:latest" \
34
+ --push .
35
+
36
+ echo "== Verify =="
37
+ docker buildx imagetools inspect "$ACR:$VERSION" | grep -E "Platform|Architecture" | head -4
38
+ echo "== Done =="
@@ -30,11 +30,22 @@ 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
33
43
  docker build \
34
- --build-arg BAZE_CORE_VERSION="${VERSION}" \
44
+ "${BUILD_ARGS[@]}" \
35
45
  -t "${GHCR_IMAGE}:${VERSION}" \
36
46
  -t "${GHCR_IMAGE}:latest" \
37
47
  .
48
+ rm -rf .docker-local-skills
38
49
 
39
50
  if [ -n "${ACR_REGISTRY}" ] && [ -n "${ACR_NAMESPACE}" ]; then
40
51
  ACR_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/baize-core"
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # Install Docker Engine (with buildx) on a mainland Ubuntu server.
3
+ # Uses the Aliyun docker-ce mirror; falls back from the local Ubuntu
4
+ # codename to noble (24.04) when the mirror has not synced it yet.
5
+ # Run as root: bash install-docker.sh
6
+ set -euo pipefail
7
+
8
+ echo "== 1/4 Download Docker GPG key =="
9
+ curl -fsSL -o /tmp/docker.gpg https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg
10
+ gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg /tmp/docker.gpg
11
+
12
+ echo "== 2/4 Write apt source (deb822) =="
13
+ CODENAME=$(lsb_release -cs)
14
+ {
15
+ echo "Types: deb"
16
+ echo "URIs: https://mirrors.aliyun.com/docker-ce/linux/ubuntu"
17
+ echo "Suites: $CODENAME"
18
+ echo "Components: stable"
19
+ echo "Signed-By: /usr/share/keyrings/docker-archive-keyring.gpg"
20
+ } > /etc/apt/sources.list.d/docker.sources
21
+
22
+ if ! apt-get update; then
23
+ echo "== $CODENAME not on the mirror yet, falling back to noble =="
24
+ sed -i "s/Suites: $CODENAME/Suites: noble/" /etc/apt/sources.list.d/docker.sources
25
+ apt-get update
26
+ fi
27
+
28
+ echo "== 3/4 Install Docker =="
29
+ apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
30
+
31
+ echo "== 4/4 Start and verify =="
32
+ systemctl enable --now docker
33
+ docker --version
34
+ docker buildx version
35
+ echo "== Done =="
@@ -314,6 +314,7 @@ _ensure_path_in_profile() {
314
314
  # Added by baize installer
315
315
  fish_add_path -g $HOME/.local/bin
316
316
  fish_add_path -g $HOME/baize/bin
317
+ fish_add_path -g $HOME/.local/node/bin
317
318
  FISH_EOF
318
319
  ok "PATH configured in conf.d/baize.fish"
319
320
  fi
@@ -331,15 +332,26 @@ FISH_EOF
331
332
  local baize_marker='# baize-managed: bin PATH'
332
333
  local baize_bin_export="export PATH=\"\$HOME/baize/bin:\$PATH\""
333
334
 
335
+ # 3. ~/.local/node/bin — Node/npm installed from the npmmirror binary
336
+ # mirror (or a bundled Node). Idempotency: dedicated marker.
337
+ local node_marker='# baize-managed: node bin PATH'
338
+ local node_bin_export="export PATH=\"\$HOME/.local/node/bin:\$PATH\""
339
+
334
340
  # Write to ~/.profile (login shells + non-interactive shells)
335
341
  if ! grep -q 'baize-managed: bin PATH' "$HOME/.profile" 2>/dev/null; then
336
342
  printf '\n%s\n%s\n' "$baize_marker" "$baize_bin_export" >> "$HOME/.profile"
337
343
  fi
344
+ if ! grep -q 'baize-managed: node bin PATH' "$HOME/.profile" 2>/dev/null; then
345
+ printf '\n%s\n%s\n' "$node_marker" "$node_bin_export" >> "$HOME/.profile"
346
+ fi
338
347
  # Write to shell rc file (interactive shells)
339
348
  if [ "$shell_rc" != "$HOME/.profile" ]; then
340
349
  if ! grep -q 'baize-managed: bin PATH' "$shell_rc" 2>/dev/null; then
341
350
  printf '\n%s\n%s\n' "$baize_marker" "$baize_bin_export" >> "$shell_rc"
342
351
  fi
352
+ if ! grep -q 'baize-managed: node bin PATH' "$shell_rc" 2>/dev/null; then
353
+ printf '\n%s\n%s\n' "$node_marker" "$node_bin_export" >> "$shell_rc"
354
+ fi
343
355
  fi
344
356
 
345
357
  ok "PATH configured in $(basename "$shell_rc")"
@@ -347,6 +359,7 @@ FISH_EOF
347
359
 
348
360
  # Export for the running script (so baize init can find binaries)
349
361
  export PATH="$HOME/.local/bin:$HOME/baize/bin:$PATH"
362
+ export PATH="$HOME/.local/node/bin:$PATH"
350
363
  }
351
364
 
352
365
  # ── Install Baize ─────────────────────────────────────────────
@@ -972,6 +972,13 @@ async function switchRuntimeFromAdmin(basePath) {
972
972
  setAdminMsg('未登录或会话过期,请重新登录', true);
973
973
  return;
974
974
  }
975
+ if (body.installing) {
976
+ // D39: runtime binary missing — async install in progress, poll status.
977
+ const label = target === 'claude' ? 'Claude Code' : 'Codex';
978
+ setAdminMsg(`正在安装 ${label}(下载约 340MB,首次约 2-5 分钟)...`);
979
+ pollRuntimeInstall(basePath, target);
980
+ return;
981
+ }
975
982
  if (body.success) {
976
983
  const restartNote = body.restart?.restarted ? ',会话已自动重启' : (body.restart?.reason === 'no_session' ? '(无运行中会话)' : '');
977
984
  setAdminMsg(`已切换到 ${target}${restartNote}`);
@@ -981,6 +988,165 @@ async function switchRuntimeFromAdmin(basePath) {
981
988
  loadAdminStatus(basePath);
982
989
  }
983
990
 
991
+ async function pollRuntimeInstall(basePath, runtime) {
992
+ const label = runtime === 'claude' ? 'Claude Code' : 'Codex';
993
+ let attempts = 0;
994
+ while (attempts < 400) { // ~20 min cap
995
+ attempts += 1;
996
+ const { status, body } = await adminFetch(basePath, `/api/admin/runtime/install-status?runtime=${encodeURIComponent(runtime)}`);
997
+ if (status !== 200 || !body) {
998
+ await new Promise((r) => setTimeout(r, 3000));
999
+ continue;
1000
+ }
1001
+ if (body.status === 'done') {
1002
+ setAdminMsg(`${label} 安装完成,已切换到 ${runtime},会话重启中`);
1003
+ renderRuntimeInstallBadges(basePath);
1004
+ loadAdminStatus(basePath);
1005
+ return;
1006
+ }
1007
+ if (body.status === 'failed') {
1008
+ setAdminMsg(`${label} 安装失败:${body.error || '未知错误'}(可稍后重试)`, true);
1009
+ renderRuntimeInstallBadges(basePath);
1010
+ return;
1011
+ }
1012
+ await new Promise((r) => setTimeout(r, 3000));
1013
+ }
1014
+ setAdminMsg(`${label} 安装仍在进行,请稍后查看状态`, true);
1015
+ }
1016
+
1017
+ // D41: per-runtime install badges (installed/version/failure) + install button
1018
+ // (reuses D39 async install) + a warning when the ACTIVE runtime is missing.
1019
+ async function renderRuntimeInstallBadges(basePath) {
1020
+ const badgeClaude = document.getElementById('rt-install-claude');
1021
+ const badgeCodex = document.getElementById('rt-install-codex');
1022
+ const warnEl = document.getElementById('rt-install-warning');
1023
+ if (!badgeClaude || !badgeCodex) return;
1024
+ const { status, body } = await adminFetch(basePath, '/api/admin/diagnostics');
1025
+ if (status !== 200 || !body?.runtimes) return;
1026
+ const rt = body.runtime?.runtime || 'claude';
1027
+ const rtInfo = body.runtimes || {};
1028
+ const badge = (name, info) => {
1029
+ const el = name === 'claude' ? badgeClaude : badgeCodex;
1030
+ if (info.installed) {
1031
+ el.innerHTML = `<span class="channel-state ok">✅ 已安装${info.version ? ` ${escapeHtml(info.version)}` : ''}</span>`;
1032
+ return;
1033
+ }
1034
+ const reason = info.installError ? `(上次失败:${escapeHtml(info.installError)})` : '';
1035
+ el.innerHTML = `<span class="channel-state warn">❌ 未安装${reason}</span> `
1036
+ + `<button type="button" class="small-btn" data-install="${name}">安装</button>`;
1037
+ };
1038
+ badge('claude', rtInfo.claude || {});
1039
+ badge('codex', rtInfo.codex || {});
1040
+ // Warn when the active runtime CLI is missing — the session cannot start.
1041
+ if (warnEl) {
1042
+ const active = rtInfo[rt] || {};
1043
+ if (!active.installed) {
1044
+ const label = rt === 'claude' ? 'Claude Code' : 'Codex';
1045
+ warnEl.innerHTML = `<span class="channel-state warn">⚠ 会话无法启动:当前运行时 ${escapeHtml(rt)} 未安装——请点上方[安装]或[切换到 ${rt === 'claude' ? 'codex' : 'claude'}]</span>`;
1046
+ } else {
1047
+ warnEl.textContent = '';
1048
+ }
1049
+ }
1050
+ // Wire install buttons (delegate once per render is fine — small page).
1051
+ document.querySelectorAll('[data-install]').forEach((btn) => {
1052
+ btn.onclick = async () => {
1053
+ const r = btn.getAttribute('data-install');
1054
+ btn.disabled = true;
1055
+ btn.textContent = '安装中...';
1056
+ setAdminMsg(`正在安装 ${r === 'claude' ? 'Claude Code' : 'Codex'}(下载约 340MB,首次约 2-5 分钟)...`);
1057
+ const rr = await adminFetch(basePath, '/api/admin/runtime', {
1058
+ method: 'POST',
1059
+ headers: { 'Content-Type': 'application/json' },
1060
+ body: JSON.stringify({ runtime: r }),
1061
+ });
1062
+ if (rr.body?.installing) {
1063
+ pollRuntimeInstall(basePath, r);
1064
+ } else if (rr.body?.success) {
1065
+ setAdminMsg(`${r === 'claude' ? 'Claude Code' : 'Codex'} 已安装`);
1066
+ renderRuntimeInstallBadges(basePath);
1067
+ } else {
1068
+ setAdminMsg(`安装触发失败:${rr.body?.error || '未知错误'}`, true);
1069
+ btn.disabled = false;
1070
+ btn.textContent = '安装';
1071
+ }
1072
+ };
1073
+ });
1074
+ }
1075
+
1076
+ // D42: environment status card — gaps in plain language, one-click re-init,
1077
+ // failure reason (human) + in-web log.
1078
+ async function renderEnvironmentStatus(basePath) {
1079
+ const badge = document.getElementById('env-state-badge');
1080
+ const detail = document.getElementById('env-state-detail');
1081
+ const actions = document.getElementById('env-reinit-actions');
1082
+ if (!badge || !detail) return;
1083
+ const { status, body } = await adminFetch(basePath, '/api/admin/environment/status');
1084
+ if (status !== 200 || !body) { detail.textContent = '环境状态检测失败'; return; }
1085
+ const rt = body.runtime || 'codex';
1086
+ const claudeOk = body.runtimes?.claude?.installed ? '✅' : '❌';
1087
+ const codexOk = body.runtimes?.codex?.installed ? '✅' : '❌';
1088
+ const initOk = body.initComplete ? '✅' : '❌';
1089
+ if (body.ready) {
1090
+ badge.textContent = '✅ 就绪';
1091
+ badge.className = 'channel-state ok';
1092
+ detail.innerHTML = `运行时 ${escapeHtml(rt)} · CLI ${rt === 'claude' ? claudeOk : codexOk} · 初始化 ${initOk}`;
1093
+ actions.style.display = 'none';
1094
+ return;
1095
+ }
1096
+ badge.textContent = '⚠ 未就绪';
1097
+ badge.className = 'channel-state warn';
1098
+ const gapLines = (body.gaps || []).map((g) => `<li>${escapeHtml(g)}</li>`).join('');
1099
+ detail.innerHTML = `<ul style="margin:4px 0;padding-left:18px">${gapLines || '<li>环境未就绪</li>'}</ul>`
1100
+ + `<span style="font-size:12px;opacity:.8">Codex ${codexOk} · Claude ${claudeOk} · 初始化 ${initOk}</span>`;
1101
+ actions.style.display = 'block';
1102
+ renderReinitStatus(basePath);
1103
+ }
1104
+
1105
+ async function renderReinitStatus(basePath) {
1106
+ const prog = document.getElementById('env-reinit-progress');
1107
+ const logBox = document.getElementById('env-reinit-log');
1108
+ const logBody = document.getElementById('env-reinit-log-body');
1109
+ if (!prog) return;
1110
+ const { status, body } = await adminFetch(basePath, '/api/admin/environment/reinit-status');
1111
+ if (status !== 200 || !body || body.status === 'idle') { prog.textContent = ''; return; }
1112
+ if (body.status === 'running') {
1113
+ prog.textContent = '正在重新初始化(补全环境配置,约 1-2 分钟)...';
1114
+ logBox.style.display = 'none';
1115
+ } else if (body.status === 'done') {
1116
+ prog.innerHTML = '<span class="channel-state ok">✅ 初始化完成,环境已就绪,会话拉起中...</span>';
1117
+ logBox.style.display = 'none';
1118
+ setTimeout(() => renderEnvironmentStatus(basePath), 5000);
1119
+ loadAdminStatus(basePath);
1120
+ } else if (body.status === 'failed') {
1121
+ prog.innerHTML = `<span class="channel-state warn">⚠ 初始化失败:${escapeHtml(body.human || '未知原因')}</span>`;
1122
+ if (body.logTail) {
1123
+ logBody.textContent = body.logTail;
1124
+ logBox.style.display = 'block';
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ function startReinitFromAdmin(basePath) {
1130
+ const btn = document.getElementById('btn-env-reinit');
1131
+ if (!btn) return;
1132
+ btn.disabled = true;
1133
+ btn.textContent = '重新初始化中...';
1134
+ setAdminMsg('正在重新初始化环境(幂等,不会清除已有数据)...');
1135
+ adminFetch(basePath, '/api/admin/environment/reinit', { method: 'POST' }).then(({ body }) => {
1136
+ if (!body?.reinit) { setAdminMsg('重新初始化未能启动', true); btn.disabled = false; btn.textContent = '重新初始化(修复环境)'; return; }
1137
+ const iv = setInterval(async () => {
1138
+ const { status, body: b } = await adminFetch(basePath, '/api/admin/environment/reinit-status');
1139
+ if (status !== 200 || !b) return;
1140
+ renderReinitStatus(basePath);
1141
+ if (b.status === 'done' || b.status === 'failed') {
1142
+ clearInterval(iv);
1143
+ btn.disabled = false;
1144
+ btn.textContent = '重新初始化(修复环境)';
1145
+ }
1146
+ }, 3000);
1147
+ });
1148
+ }
1149
+
984
1150
  async function restartSessionFromAdmin(basePath) {
985
1151
  setAdminMsg('正在重启 agent 会话...');
986
1152
  const { body } = await adminFetch(basePath, '/api/admin/restart', { method: 'POST' });
@@ -2319,6 +2485,16 @@ async function renderDiagnostics(basePath) {
2319
2485
  }
2320
2486
  const rt = body.runtime || {};
2321
2487
  if (stateEl) stateEl.textContent = `runtime: ${escapeHtml(rt.runtime || '?')}`;
2488
+ // init state (D40): surface init failure so the user can act in-browser
2489
+ const init = body.init || null;
2490
+ if (init && init.status === 'failed') {
2491
+ const initEl = document.getElementById('diag-init-state');
2492
+ if (initEl) {
2493
+ initEl.innerHTML = '<span class="channel-state warn">⚠ 初始化未完成:'
2494
+ + `${escapeHtml(init.error || '未知原因')}</span> —— 请在「模型设置」安装/重试对应 runtime(Claude Code 或 Codex),然后重启 agent 会话。`
2495
+ + (init.at ? ` <small>(${escapeHtml(String(init.at))})</small>` : '');
2496
+ }
2497
+ }
2322
2498
  // runtime + auth
2323
2499
  const auth = body.auth || {};
2324
2500
  const authLine = [];
@@ -2382,6 +2558,8 @@ function showAppView(basePath, view) {
2382
2558
  schedulerView.hidden = true;
2383
2559
  modelView.hidden = false;
2384
2560
  renderDiagnostics(basePath);
2561
+ renderRuntimeInstallBadges(basePath);
2562
+ renderEnvironmentStatus(basePath);
2385
2563
  setNav(navModel, [navChat, navChannels, navA2a, navScheduler]);
2386
2564
  renderModelSettings(basePath);
2387
2565
  } else if (view === 'channels') {
@@ -2587,6 +2765,7 @@ function initViews(basePath) {
2587
2765
  });
2588
2766
  document.getElementById('btn-switch-runtime').addEventListener('click', () => switchRuntimeFromAdmin(basePath));
2589
2767
  document.getElementById('btn-restart-session').addEventListener('click', () => restartSessionFromAdmin(basePath));
2768
+ document.getElementById('btn-env-reinit')?.addEventListener('click', () => startReinitFromAdmin(basePath));
2590
2769
 
2591
2770
  document.getElementById('form-provider').addEventListener('submit', (e) => {
2592
2771
  e.preventDefault();
@@ -109,14 +109,27 @@
109
109
  <!-- Section 0: Current connection summary -->
110
110
  <section class="settings-section">
111
111
  <div class="settings-section-head">
112
- <h2>当前连接状态</h2>
113
- <p>两个运行时当前生效的接入方式,一目了然。</p>
112
+ <h2>运行时与连接</h2>
113
+ <p>环境未就绪时,请先完成「环境初始化」(可自动安装缺失组件并补全配置),再配置运行时接入方式。</p>
114
+ </div>
115
+ <div class="settings-card">
116
+ <div class="card-title">环境初始化 <span class="channel-state" id="env-state-badge"></span></div>
117
+ <div class="cred-state" id="env-state-detail" aria-live="polite">检测中...</div>
118
+ <div class="settings-actions" id="env-reinit-actions" style="display:none">
119
+ <button type="button" class="btn btn-primary" id="btn-env-reinit">重新初始化(一键修复环境)</button>
120
+ </div>
121
+ <div class="cred-state" id="env-reinit-progress" aria-live="polite"></div>
122
+ <details id="env-reinit-log" style="display:none;margin-top:8px">
123
+ <summary>查看初始化日志</summary>
124
+ <pre style="font-size:12px;background:var(--bg-2,#1a1a1a);padding:8px;border-radius:6px;white-space:pre-wrap;max-height:300px;overflow:auto" id="env-reinit-log-body"></pre>
125
+ </details>
114
126
  </div>
115
127
  <div class="settings-card">
116
128
  <div class="conn-summary" id="conn-summary" role="status" aria-live="polite">
117
- <div class="conn-summary-row"><span class="conn-summary-name">Codex</span><span class="conn-summary-detail" id="summary-codex">加载中...</span></div>
118
- <div class="conn-summary-row"><span class="conn-summary-name">Claude</span><span class="conn-summary-detail" id="summary-claude">加载中...</span></div>
129
+ <div class="conn-summary-row"><span class="conn-summary-name">Codex</span><span class="conn-summary-detail" id="summary-codex">加载中...</span><span class="runtime-install-badge" id="rt-install-codex"></span></div>
130
+ <div class="conn-summary-row"><span class="conn-summary-name">Claude</span><span class="conn-summary-detail" id="summary-claude">加载中...</span><span class="runtime-install-badge" id="rt-install-claude"></span></div>
119
131
  </div>
132
+ <div class="cred-state" id="rt-install-warning" aria-live="polite"></div>
120
133
  <div class="settings-actions">
121
134
  <button type="button" class="btn btn-outline" id="btn-switch-runtime">切换运行时</button>
122
135
  <button type="button" class="btn btn-outline" id="btn-restart-session">重启 agent 会话</button>
@@ -239,6 +252,7 @@
239
252
  <div class="settings-card">
240
253
  <div class="card-title">运行时与认证 <span class="channel-state" id="diag-runtime-state"></span></div>
241
254
  <div class="cred-state" id="diag-runtime" aria-live="polite">加载中...</div>
255
+ <div class="cred-state" id="diag-init-state" aria-live="polite"></div>
242
256
  </div>
243
257
  <div class="settings-card">
244
258
  <div class="card-title">服务状态 <button type="button" class="small-btn" id="btn-diag-refresh">刷新</button></div>
@@ -328,6 +328,39 @@ function npmInstallDeps(cwd) {
328
328
  });
329
329
  }
330
330
 
331
+ /** Rebuild native modules after --ignore-scripts install (D44).
332
+ * --ignore-scripts skips postinstall, so better-sqlite3 etc. never compile
333
+ * their native binary. Rebuilding runs node-gyp only (no vendor lifecycle
334
+ * scripts) — the security stance is kept while the module becomes loadable. */
335
+ function rebuildNativeDeps(cwd) {
336
+ return new Promise((resolve) => {
337
+ const child = spawn('npm', ['rebuild', '--build-from-source', '--no-audit', '--no-fund'], {
338
+ cwd, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'],
339
+ });
340
+ let stderr = '';
341
+ let settled = false;
342
+ child.stderr.on('data', (d) => { stderr += String(d); });
343
+ const timer = setTimeout(() => {
344
+ if (settled) return;
345
+ settled = true;
346
+ child.kill('SIGTERM');
347
+ resolve({ ok: false, error: `npm rebuild 超时(${Math.round(NPM_INSTALL_TIMEOUT_MS / 1000)}s)` });
348
+ }, NPM_INSTALL_TIMEOUT_MS);
349
+ child.on('error', (err) => {
350
+ if (settled) return;
351
+ settled = true;
352
+ clearTimeout(timer);
353
+ resolve({ ok: false, error: err.message });
354
+ });
355
+ child.on('close', (code) => {
356
+ if (settled) return;
357
+ settled = true;
358
+ clearTimeout(timer);
359
+ resolve(code === 0 ? { ok: true } : { ok: false, error: (stderr.trim() || `exit ${code}`).slice(-500) });
360
+ });
361
+ });
362
+ }
363
+
331
364
  /**
332
365
  * Install @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付).
333
366
  * Pipeline: entry listing + path-safety vetting (no `..`/absolute/links) →
@@ -342,6 +375,7 @@ export async function installA2aTarball(tarballPath, {
342
375
  skillsDir = defaultSkillsDir(),
343
376
  componentsFile: componentsPath = componentsFile(),
344
377
  installDeps = npmInstallDeps,
378
+ rebuildDeps = rebuildNativeDeps,
345
379
  originalName = null,
346
380
  } = {}) {
347
381
  const file = String(tarballPath || '');
@@ -401,6 +435,13 @@ export async function installA2aTarball(tarballPath, {
401
435
  fs.rmSync(targetDir, { recursive: true, force: true });
402
436
  return { ok: false, error: `npm install 失败: ${depResult.error}` };
403
437
  }
438
+ // D44: --ignore-scripts skipped native compilation — rebuild modules so
439
+ // better-sqlite3 etc. are loadable (node-gyp only, no vendor scripts).
440
+ const rebuildResult = await rebuildDeps(targetDir);
441
+ if (!rebuildResult.ok) {
442
+ fs.rmSync(targetDir, { recursive: true, force: true });
443
+ return { ok: false, error: `native 模块重建失败: ${rebuildResult.error}` };
444
+ }
404
445
  const components = readComponents(componentsPath);
405
446
  components.a2a = {
406
447
  version: pkg.version,
@@ -126,13 +126,57 @@ function readErrors() {
126
126
  return errors;
127
127
  }
128
128
 
129
+ /** Init state from .baize/init-state.json (written by entrypoint/init). */
130
+ function readInitState() {
131
+ try {
132
+ const p = path.join(process.env.BAIZE_DIR || path.join(os.homedir(), 'baize'), '.baize', 'init-state.json');
133
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
134
+ } catch { return null; }
135
+ }
136
+
137
+ /** Detect whether a runtime CLI is installed (PATH) + its version.
138
+ * Note: gate on `command -v` — a plain `<bin> --version | head -1` pipeline
139
+ * exits with head's status (0), so a missing binary would read as installed. */
140
+ function checkRuntimeBinary(bin) {
141
+ return new Promise((resolve) => {
142
+ execFile('sh', ['-c', `command -v ${bin} >/dev/null 2>&1 && ${bin} --version 2>/dev/null | head -1`], { timeout: 5000 }, (err, stdout) => {
143
+ resolve({ installed: !err, version: err ? null : (String(stdout).trim().split('\n')[0] || null) });
144
+ });
145
+ });
146
+ }
147
+
148
+ /** Read the D39 install state file (failure reason from an async web install). */
149
+ function readRuntimeInstallState(runtime) {
150
+ try {
151
+ const p = path.join(process.env.BAIZE_DIR || path.join(os.homedir(), 'baize'), '.baize', `runtime-install-${runtime}.json`);
152
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
153
+ } catch { return null; }
154
+ }
155
+
156
+ /**
157
+ * Per-runtime install status: installed + version, plus the last install
158
+ * failure reason (D39 state file) so the console can tell the user exactly
159
+ * which runtime is missing and why.
160
+ */
161
+ export async function readRuntimes() {
162
+ const [claude, codex] = await Promise.all([checkRuntimeBinary('claude'), checkRuntimeBinary('codex')]);
163
+ const enrich = (status, runtime) => {
164
+ const st = readRuntimeInstallState(runtime);
165
+ if (st && st.status === 'failed') status.installError = st.error || null;
166
+ return status;
167
+ };
168
+ return { claude: enrich(claude, 'claude'), codex: enrich(codex, 'codex') };
169
+ }
170
+
129
171
  /** Full diagnostics payload. */
130
172
  export async function readDiagnostics() {
131
- const [services, sessions] = await Promise.all([readServices(), readSessions()]);
173
+ const [services, sessions, runtimes] = await Promise.all([readServices(), readSessions(), readRuntimes()]);
132
174
  return {
133
175
  success: true,
134
176
  at: new Date().toISOString(),
135
177
  runtime: readRuntime(),
178
+ init: readInitState(),
179
+ runtimes,
136
180
  services,
137
181
  sessions,
138
182
  auth: readAuth(),
@@ -15,7 +15,7 @@ import crypto from 'crypto';
15
15
  import path from 'path';
16
16
  import os from 'os';
17
17
  import fs from 'fs';
18
- import { spawn } from 'child_process';
18
+ import { spawn, execFile } from 'child_process';
19
19
  import Database from 'better-sqlite3';
20
20
  import { fileURLToPath } from 'url';
21
21
  import {
@@ -834,9 +834,122 @@ app.get('/api/admin/diagnostics', async (req, res) => {
834
834
  }
835
835
  });
836
836
 
837
+ // ── Runtime install (D39): async install when the target runtime binary is
838
+ // missing — detached spawn (900s timeout + 2 retries), then auto-switch.
839
+ // No task framework: a state JSON + log file are the status.
840
+ const RUNTIME_INSTALL = {
841
+ claude: { bin: 'claude', cmd: 'curl -fsSL https://claude.ai/install.sh | bash' },
842
+ codex: { bin: 'codex', cmd: 'npm install -g @openai/codex' },
843
+ };
844
+
845
+ function runtimeInstallStateFile(runtime) {
846
+ return path.join(BAIZE_DIR, '.baize', `runtime-install-${runtime}.json`);
847
+ }
848
+ function runtimeInstallLogFile(runtime) {
849
+ return path.join(BAIZE_DIR, '.baize', `runtime-install-${runtime}.log`);
850
+ }
851
+ function readRuntimeInstallState(runtime) {
852
+ try { return JSON.parse(fs.readFileSync(runtimeInstallStateFile(runtime), 'utf8')); } catch { return null; }
853
+ }
854
+ function writeRuntimeInstallState(runtime, state) {
855
+ try {
856
+ fs.mkdirSync(path.join(BAIZE_DIR, '.baize'), { recursive: true });
857
+ fs.writeFileSync(runtimeInstallStateFile(runtime), JSON.stringify(state));
858
+ } catch { /* non-fatal */ }
859
+ }
860
+
861
+ function runtimeInstalled(runtime) {
862
+ const bin = RUNTIME_INSTALL[runtime]?.bin;
863
+ if (!bin) return Promise.resolve(true); // unknown runtime: don't gate
864
+ return new Promise((resolve) => {
865
+ execFile('sh', ['-c', `command -v ${bin}`], { timeout: 5000 }, (err) => resolve(!err));
866
+ });
867
+ }
868
+
869
+ function startRuntimeInstall(runtime) {
870
+ const spec = RUNTIME_INSTALL[runtime];
871
+ if (!spec) return false;
872
+ const cur = readRuntimeInstallState(runtime);
873
+ if (cur && cur.status === 'installing') {
874
+ // Stale-guard: a crashed/killed installer (OOM, container restart) would
875
+ // otherwise wedge the target forever. Budget = 3 attempts x 900s + 240s
876
+ // switch + buffer.
877
+ const started = Date.parse(cur.startedAt || '');
878
+ if (Number.isNaN(started) || Date.now() - started < 55 * 60 * 1000) {
879
+ return false; // genuinely running (or recent) — don't duplicate
880
+ }
881
+ }
882
+
883
+ writeRuntimeInstallState(runtime, { status: 'installing', startedAt: new Date().toISOString() });
884
+
885
+ const script = `
886
+ const { spawn } = require('child_process');
887
+ const fs = require('fs');
888
+ const LOG = ${JSON.stringify(runtimeInstallLogFile(runtime))};
889
+ const STATE = ${JSON.stringify(runtimeInstallStateFile(runtime))};
890
+ const CMD = ${JSON.stringify(spec.cmd)};
891
+ const RUNTIME = ${JSON.stringify(runtime)};
892
+ const log = (s) => { try { fs.appendFileSync(LOG, s + '\\n'); } catch {} };
893
+ const run = (cmd, timeoutMs) => new Promise((resolve, reject) => {
894
+ const child = spawn('bash', ['-c', cmd], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
895
+ let out = '';
896
+ child.stdout.on('data', (d) => { out += d; if (out.length > 4000) { log(out); out = ''; } });
897
+ child.stderr.on('data', (d) => { out += d; if (out.length > 4000) { log(out); out = ''; } });
898
+ const killTree = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch {} } };
899
+ const t = setTimeout(() => { killTree(); reject(new Error('timeout ' + timeoutMs + 'ms')); }, timeoutMs);
900
+ child.on('close', (code) => { clearTimeout(t); if (out) log(out); code === 0 ? resolve() : reject(new Error('exit ' + code)); });
901
+ });
902
+ const update = (s) => { try { fs.writeFileSync(STATE, JSON.stringify(s)); } catch {} };
903
+ (async () => {
904
+ for (let attempt = 1; attempt <= 3; attempt++) {
905
+ log('[install] attempt ' + attempt + ' starting: ' + CMD);
906
+ try {
907
+ await run(CMD, 900000);
908
+ log('[install] installed OK');
909
+ break;
910
+ } catch (e) {
911
+ log('[install] attempt ' + attempt + ' failed: ' + e.message);
912
+ if (attempt === 3) {
913
+ update({ status: 'failed', error: e.message, doneAt: new Date().toISOString() });
914
+ process.exit(1);
915
+ }
916
+ }
917
+ }
918
+ log('[install] switching runtime to ' + RUNTIME);
919
+ try {
920
+ await run('baize runtime ' + RUNTIME + ' --no-validate', 240000);
921
+ update({ status: 'done', doneAt: new Date().toISOString() });
922
+ log('[install] switch complete');
923
+ } catch (e) {
924
+ update({ status: 'failed', error: 'switch failed: ' + e.message, doneAt: new Date().toISOString() });
925
+ log('[install] switch failed: ' + e.message);
926
+ }
927
+ })();
928
+ `;
929
+ try {
930
+ const scriptPath = path.join(BAIZE_DIR, '.baize', `runtime-install-${runtime}.cjs`);
931
+ fs.mkdirSync(path.join(BAIZE_DIR, '.baize'), { recursive: true });
932
+ fs.writeFileSync(scriptPath, script);
933
+ const child = spawn(process.execPath, [scriptPath], { detached: true, stdio: 'ignore' });
934
+ child.unref();
935
+ return true;
936
+ } catch {
937
+ writeRuntimeInstallState(runtime, null); // don't leave a stuck 'installing'
938
+ return false;
939
+ }
940
+ }
941
+
837
942
  app.post('/api/admin/runtime', async (req, res) => {
838
943
  try {
839
- const result = await switchRuntime(req.body?.runtime);
944
+ const runtime = req.body?.runtime;
945
+ if (!['claude', 'codex'].includes(runtime)) {
946
+ return res.status(400).json({ success: false, error: `Invalid runtime: ${runtime}` });
947
+ }
948
+ if (!(await runtimeInstalled(runtime))) {
949
+ startRuntimeInstall(runtime);
950
+ return res.json({ success: true, installing: true, runtime });
951
+ }
952
+ const result = await switchRuntime(runtime);
840
953
  if (result.success) result.restart = await restartAgentSession();
841
954
  res.status(result.success ? 200 : 400).json(result);
842
955
  } catch (err) {
@@ -844,6 +957,181 @@ app.post('/api/admin/runtime', async (req, res) => {
844
957
  }
845
958
  });
846
959
 
960
+ // ── Environment status + one-click re-init (D42) ───────────────────────────
961
+ // Web is the only recovery surface: detect init gaps, trigger the idempotent
962
+ // `baize init`, and surface failure reasons (human summary + raw log) in-browser.
963
+ const REINIT_LOG = path.join(BAIZE_DIR, '.baize', 'reinit.log');
964
+ const REINIT_STATE = path.join(BAIZE_DIR, '.baize', 'reinit.json');
965
+
966
+ function readReinitState() {
967
+ try { return JSON.parse(fs.readFileSync(REINIT_STATE, 'utf8')); } catch { return null; }
968
+ }
969
+
970
+ function runtimeBinInstalled(bin) {
971
+ return new Promise((resolve) => {
972
+ execFile('sh', ['-c', `command -v ${bin} >/dev/null 2>&1`], { timeout: 5000 }, (err) => resolve(!err));
973
+ });
974
+ }
975
+
976
+ /** Init completeness: config.json with a runtime. The instruction file may be
977
+ * in the split-instruction pending-migration state without blocking the
978
+ * session (Guardian still launches), so it is not a readiness condition. */
979
+ function initComplete() {
980
+ try {
981
+ const cfg = JSON.parse(fs.readFileSync(path.join(BAIZE_DIR, '.baize', 'config.json'), 'utf8'));
982
+ return !!(cfg && cfg.runtime);
983
+ } catch { return false; }
984
+ }
985
+
986
+ function readCurrentRuntime() {
987
+ try {
988
+ const cfg = JSON.parse(fs.readFileSync(path.join(BAIZE_DIR, '.baize', 'config.json'), 'utf8'));
989
+ if (cfg && (cfg.runtime === 'codex' || cfg.runtime === 'claude')) return cfg.runtime;
990
+ } catch {}
991
+ return 'codex'; // mainland-safe default (init falls back to claude otherwise)
992
+ }
993
+
994
+ /** Map common failure text to a human-readable (Chinese) summary. */
995
+ function humanError(summary) {
996
+ const map = [
997
+ [/claude\.ai/i, '无法连接 Claude 官方服务(大陆不可达)——请使用 Codex 或第三方兼容端点'],
998
+ [/ETIMEDOUT|ECONNRESET|ECONNREFUSED|network.*(timeout|refused)|i\/o timeout/i, '网络连接失败(超时/被拒绝)——请检查网络后重试'],
999
+ [/registry\.npmjs\.org|npmmirror/i, '无法连接 npm 源——请检查网络或 npm 源配置'],
1000
+ [/Failed to install (Codex|claude)|Failed to install/i, '运行时 CLI 安装失败——请检查网络后重试'],
1001
+ [/permission denied|EACCES/i, '权限不足——请检查目录权限后重试'],
1002
+ [/command not found/i, '缺少必要命令——请检查环境'],
1003
+ ];
1004
+ for (const [re, msg] of map) {
1005
+ if (re.test(summary)) return msg;
1006
+ }
1007
+ return null;
1008
+ }
1009
+
1010
+ function startReinit() {
1011
+ const cur = readReinitState();
1012
+ if (cur && cur.status === 'running') {
1013
+ // Stale-guard: a crashed/killed reinit would otherwise wedge the button
1014
+ // forever. Budget = init install window (900s) + buffer.
1015
+ const started = Date.parse(cur.startedAt || '');
1016
+ if (Number.isNaN(started) || Date.now() - started < 30 * 60 * 1000) {
1017
+ return false;
1018
+ }
1019
+ }
1020
+ const runtime = readCurrentRuntime();
1021
+ const now = new Date().toISOString();
1022
+ try {
1023
+ fs.mkdirSync(path.join(BAIZE_DIR, '.baize'), { recursive: true });
1024
+ fs.writeFileSync(REINIT_STATE, JSON.stringify({ status: 'running', runtime, startedAt: now }));
1025
+ fs.writeFileSync(REINIT_LOG, `[reinit] started at ${now} (runtime=${runtime})
1026
+ `);
1027
+ } catch { return false; }
1028
+ const script = `
1029
+ const { spawn } = require('child_process');
1030
+ const fs = require('fs');
1031
+ const LOG = ${JSON.stringify(REINIT_LOG)};
1032
+ const STATE = ${JSON.stringify(REINIT_STATE)};
1033
+ const RUNTIME = ${JSON.stringify(runtime)};
1034
+ const log = (s) => { try { fs.appendFileSync(LOG, s + '\\n'); } catch {} };
1035
+ const run = (cmd, timeoutMs) => new Promise((resolve, reject) => {
1036
+ const child = spawn('bash', ['-c', cmd], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
1037
+ let out = '';
1038
+ child.stdout.on('data', (d) => { out += d; if (out.length > 8000) { log(out); out = ''; } });
1039
+ child.stderr.on('data', (d) => { out += d; if (out.length > 8000) { log(out); out = ''; } });
1040
+ const killTree = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch {} } };
1041
+ const t = setTimeout(() => { killTree(); reject(new Error('timeout ' + timeoutMs + 'ms')); }, timeoutMs);
1042
+ child.on('close', (code) => { clearTimeout(t); if (out) log(out); code === 0 ? resolve() : reject(new Error('exit ' + code)); });
1043
+ });
1044
+ const update = (s) => { try { fs.writeFileSync(STATE, JSON.stringify(s)); } catch {} };
1045
+ (async () => {
1046
+ log('[reinit] running baize init (idempotent)...');
1047
+ try {
1048
+ await run('baize init --yes --quiet --runtime ' + RUNTIME, 900000);
1049
+ log('[reinit] init OK');
1050
+ update({ status: 'done', doneAt: new Date().toISOString(), runtime: RUNTIME });
1051
+ } catch (e) {
1052
+ log('[reinit] init failed: ' + e.message);
1053
+ update({ status: 'failed', error: e.message, doneAt: new Date().toISOString(), runtime: RUNTIME });
1054
+ }
1055
+ })();
1056
+ `;
1057
+ try {
1058
+ const scriptPath = path.join(BAIZE_DIR, '.baize', 'reinit.cjs');
1059
+ fs.writeFileSync(scriptPath, script);
1060
+ const child = spawn(process.execPath, [scriptPath], { detached: true, stdio: 'ignore' });
1061
+ child.unref();
1062
+ return true;
1063
+ } catch {
1064
+ try { fs.writeFileSync(REINIT_STATE, JSON.stringify({ status: 'failed', error: '无法启动初始化进程', doneAt: new Date().toISOString() })); } catch {}
1065
+ return false;
1066
+ }
1067
+ }
1068
+
1069
+ // Environment status: gaps in plain language + readiness.
1070
+ app.get('/api/admin/environment/status', async (req, res) => {
1071
+ try {
1072
+ const [claude, codex] = await Promise.all([runtimeBinInstalled('claude'), runtimeBinInstalled('codex')]);
1073
+ const runtime = readCurrentRuntime();
1074
+ const initDone = initComplete();
1075
+ const gaps = [];
1076
+ if (!claude && !codex) gaps.push('运行时 CLI(Claude Code / Codex)均未安装');
1077
+ else if (runtime === 'claude' && !claude) gaps.push('当前运行时 Claude Code 未安装');
1078
+ else if (runtime === 'codex' && !codex) gaps.push('当前运行时 Codex 未安装');
1079
+ if (!initDone) gaps.push('环境初始化未完成(配置缺失)');
1080
+ res.json({
1081
+ success: true,
1082
+ runtime,
1083
+ runtimes: { claude: { installed: claude }, codex: { installed: codex } },
1084
+ initComplete: initDone,
1085
+ ready: (runtime === 'claude' ? claude : codex) && initDone,
1086
+ gaps,
1087
+ });
1088
+ } catch (err) {
1089
+ jsonError(res, err);
1090
+ }
1091
+ });
1092
+
1093
+ // Trigger the idempotent re-init (async; poll reinit-status).
1094
+ app.post('/api/admin/environment/reinit', (req, res) => {
1095
+ try {
1096
+ const started = startReinit();
1097
+ res.json({ success: true, reinit: started });
1098
+ } catch (err) {
1099
+ jsonError(res, err);
1100
+ }
1101
+ });
1102
+
1103
+ // Re-init progress/result + human-readable failure reason + log tail.
1104
+ app.get('/api/admin/environment/reinit-status', (req, res) => {
1105
+ try {
1106
+ const state = readReinitState();
1107
+ if (!state) return res.json({ success: true, status: 'idle' });
1108
+ let logTail = '';
1109
+ try { logTail = fs.readFileSync(REINIT_LOG, 'utf8').split('\n').filter(Boolean).slice(-20).join('\n'); } catch {}
1110
+ const human = state.status === 'failed' ? (humanError((state.error || '') + '\n' + logTail) || '初始化失败(详见日志)') : null;
1111
+ res.json({ success: true, status: state.status, error: state.error, human, logTail, runtime: state.runtime });
1112
+ } catch (err) {
1113
+ jsonError(res, err);
1114
+ }
1115
+ });
1116
+
1117
+ app.get('/api/admin/runtime/install-status', (req, res) => {
1118
+ try {
1119
+ const runtime = req.query?.runtime;
1120
+ if (!['claude', 'codex'].includes(runtime)) {
1121
+ return res.status(400).json({ success: false, error: `Invalid runtime: ${runtime}` });
1122
+ }
1123
+ const state = readRuntimeInstallState(runtime);
1124
+ if (!state) return res.json({ success: true, status: 'idle' });
1125
+ let logTail = '';
1126
+ try {
1127
+ logTail = fs.readFileSync(runtimeInstallLogFile(runtime), 'utf8').split('\n').filter(Boolean).slice(-10).join('\n');
1128
+ } catch {}
1129
+ res.json({ success: true, status: state.status, error: state.error, logTail });
1130
+ } catch (err) {
1131
+ jsonError(res, err);
1132
+ }
1133
+ });
1134
+
847
1135
  app.post('/api/admin/restart', async (req, res) => {
848
1136
  try {
849
1137
  res.json(await restartAgentSession());