@baize-ai/core 0.3.15 → 0.3.17

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.
Files changed (34) hide show
  1. package/.dockerignore +1 -0
  2. package/CHANGELOG.md +29 -0
  3. package/Dockerfile +10 -1
  4. package/cli/commands/doctor.js +60 -0
  5. package/cli/commands/init.js +104 -42
  6. package/cli/commands/runtime.js +9 -1
  7. package/cli/lib/__tests__/init-base-url.test.js +55 -13
  8. package/cli/lib/__tests__/runtime-base-url.test.js +3 -1
  9. package/cli/lib/__tests__/runtime-launch.test.js +4 -2
  10. package/cli/lib/__tests__/runtime-setup.test.js +53 -6
  11. package/cli/lib/__tests__/tmux-env.test.js +19 -5
  12. package/cli/lib/claude-eval.js +2 -1
  13. package/cli/lib/codex-hooks.js +12 -0
  14. package/cli/lib/path-bins.js +54 -0
  15. package/cli/lib/runtime/claude.js +4 -2
  16. package/cli/lib/runtime/codex.js +19 -0
  17. package/cli/lib/runtime/tmux-env.js +5 -4
  18. package/cli/lib/runtime-setup.js +157 -13
  19. package/docker/entrypoint.sh +4 -2
  20. package/docs/ops-runbook.md +161 -0
  21. package/package.json +2 -2
  22. package/scripts/docker-publish.sh +11 -5
  23. package/scripts/pack-release.sh +34 -26
  24. package/skills/activity-monitor/scripts/__tests__/guardian.test.js +51 -0
  25. package/skills/activity-monitor/scripts/adapters/runtime-components.js +27 -0
  26. package/skills/activity-monitor/scripts/guardian.js +45 -1
  27. package/skills/activity-monitor/scripts/monitor-orchestrator.js +8 -1
  28. package/skills/activity-monitor/scripts/upgrade-check.js +3 -1
  29. package/skills/web-console/public/app.js +20 -75
  30. package/skills/web-console/scripts/model-provider.js +97 -59
  31. package/skills/web-console/scripts/server.js +6 -24
  32. package/templates/pm2/ecosystem.config.cjs +5 -4
  33. package/test/model-provider.test.js +150 -40
  34. package/test/web-console-routes.test.js +11 -13
package/.dockerignore CHANGED
@@ -6,3 +6,4 @@
6
6
  !templates/pm2/ecosystem.config.cjs
7
7
  !.claude-local/claude
8
8
  !.claude-local/claude.version
9
+ !core.tgz
package/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ 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.17] - 2026-09-14
9
+
10
+ ### Added
11
+ - 启动链路 PATH 完整性加固(D54):共享 `cli/lib/path-bins.js`(`completeBinPath`/`knownBinDirs`——静态 `~/.npm-global/bin` + 动态 npm prefix);所有 runtime 二进制 spawn(codex/claude/baize)子进程 env 补完整 PATH——登录 shell PATH 污染(09-13 事故根因:缺 `~/.npm-global/bin`)不再导致 ENOENT
12
+ - `init.js ensureCompletePathProfile`:每次 init 修复 shell profile 完整 bin PATH(npm-global/.local/claude/baize/node),幂等 + 存量缺失自动补;`saveSystemPath` 写显式完整集
13
+ - `ecosystem.config.cjs` `ENHANCED_PATH` 显式含 `~/.npm-global/bin`(PM2 服务不依赖 SYSTEM_PATH/进程 PATH)
14
+ - Guardian 连续失败告警(`ALERT_AFTER_RESTARTS=3` → `~/baize/activity-monitor/guardian-alert.json` + 日志,恢复自动清除);ENOENT 启动失败打修复提示
15
+ - `baize doctor` 新增 PATH 完整性检查(`path_incomplete` issue + path 组)
16
+ - Dockerfile 本地源码构建层序修复:`COPY core.tgz` 移到 npm install RUN 前(tarball 变更正确失效 npm 层缓存)
17
+ - 运维手册 `docs/ops-runbook.md`:PATH 事故排查 + D54 加固说明 + 巡检命令
18
+
19
+ ### Changed
20
+ - `docker/entrypoint.sh` 不再写 `SYSTEM_PATH`(init.js `saveSystemPath` 唯一写者,写完整集)
21
+ - `templates/pm2/ecosystem.config.cjs` 与 `docker-publish.sh`:发布构建临时 context + 空 `core.tgz` 占位(可选 COPY 不破坏 registry 构建)
22
+
23
+ ## [0.3.16] - 2026-09-13
24
+
25
+ ### Added
26
+ - Codex 供应商配置改造(D51):独立 provider 块写入器 + kind/slug store + init/runtime 遗留路径转换 + web-console 只读激活卡;`/models` 探测读取 provider 块 base_url
27
+ - Dockerfile 本地源码构建支持(D51):`BAZE_CORE_TARBALL=/core.tgz` 安装工作区包而非 registry 版本——验证未发布修复(如 D51)进镜像
28
+
29
+ ### Fixed
30
+ - D51 部署缺陷(init):web-console `import ../../../cli/lib/runtime-setup.js` 运行时 `~/.claude/cli/lib/` 不存在 → ERR_MODULE_NOT_FOUND 崩溃——`syncCoreSkills` 补拷 `package/cli` 至 `SKILLS_DIR 上级/cli`,并从包根复制零依赖 `node_modules/smol-toml`
31
+ - D51 配置污染事故根因(4a37368):`globalCodexConfigPath` 优先读 `HOME` env 而非 `os.homedir()`——jest ESM sandbox 曾写穿真实 `~/.codex/config.toml`
32
+ - D51 review 修复:restore 只删 baize-written 块(`name=key && wire_api=responses`);写盘直设 `0600` + 去重用例;激活卡展示 kind
33
+
34
+ ### Changed
35
+ - A2A 授权治理收敛(D52 2b11171):agent 本地 authz 仅保留 mode 切换(open/allowlist)——allow/block 名单由 admin 控制台统一管控(PUT 忽略名单强制清空 + UI 隐藏名单编辑并提示管控来源;移除死代码 `validateAuthzList`/peerIds 请求)
36
+
8
37
  ## [0.3.15] - 2026-08-31
9
38
 
10
39
  ### Fixed
package/Dockerfile CHANGED
@@ -82,8 +82,17 @@ ENV WEB_CONSOLE_BIND=0.0.0.0
82
82
  # build context) — source stays private, the image matches the npm version
83
83
  # exactly. Build with: docker build --build-arg BAZE_CORE_VERSION=0.2.0 .
84
84
  ARG BAZE_CORE_VERSION=latest
85
+ # Local-source build: pass BAZE_CORE_TARBALL=/core.tgz (COPY'd into the
86
+ # build context) to install the workspace package instead of the registry
87
+ # version — lets us verify un-released fixes (e.g. D51) in the image.
88
+ ARG BAZE_CORE_TARBALL=
89
+ # Local-source build tarball — COPY'd BEFORE the install RUN so the file is
90
+ # present in that layer AND a tarball change invalidates the install layer.
91
+ # Registry builds provide an empty placeholder via docker-publish.sh's temp
92
+ # context (or omit BAZE_CORE_TARBALL → the COPY still needs a file present).
93
+ COPY core.tgz /tmp/core.tgz
85
94
  WORKDIR /home/baize
86
- RUN npm install -g @baize-ai/core@${BAZE_CORE_VERSION} \
95
+ RUN if [ -n "${BAZE_CORE_TARBALL}" ]; then npm install -g /tmp/${BAZE_CORE_TARBALL}; else npm install -g @baize-ai/core@${BAZE_CORE_VERSION}; fi \
87
96
  && baize --version \
88
97
  # ── Build-time skill dependency preinstall (reproducibility) ─────────
89
98
  # `baize init` reinstalls skill deps at runtime; doing it here bakes the
@@ -20,6 +20,7 @@ import { loadComponents } from '../lib/components.js';
20
20
  import { fetchLatestTagAsync, compareSemverDesc } from '../lib/github.js';
21
21
  import { getCurrentVersion } from '../lib/self-upgrade.js';
22
22
  import { commandExists } from '../lib/shell-utils.js';
23
+ import { completeBinPath } from '../lib/path-bins.js';
23
24
  import { parseSkillMd } from '../lib/skill.js';
24
25
  import { bold, dim, green, red, yellow, heading } from '../lib/colors.js';
25
26
  import { getActiveAdapter } from '../lib/runtime/index.js';
@@ -280,6 +281,30 @@ function checkTmuxSession() {
280
281
  }
281
282
  }
282
283
 
284
+ /**
285
+ * D54: verify the runtime binaries are resolvable from the CURRENT process
286
+ * PATH (i.e. PM2 services spawned from here can find codex/claude). A
287
+ * login-shell-polluted PATH (missing ~/.npm-global/bin) shows up here as
288
+ * missing binaries even though the files exist.
289
+ */
290
+ function checkBinResolvable(bin) {
291
+ try {
292
+ execFileSync(bin, ['--version'], {
293
+ stdio: 'pipe', encoding: 'utf8', timeout: 10000,
294
+ });
295
+ return true;
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+ function checkPathComplete() {
301
+ const bins = ['baize'];
302
+ if (ACTIVE_RUNTIME === 'codex') bins.push('codex');
303
+ else bins.push('claude');
304
+ const missing = bins.filter((b) => !checkBinResolvable(b));
305
+ return { complete: missing.length === 0, missing };
306
+ }
307
+
283
308
  // ── Diagnostics collection ───────────────────────────────────────
284
309
 
285
310
  async function collectDiagnostics(env) {
@@ -308,11 +333,13 @@ async function collectDiagnostics(env) {
308
333
  ? checkPm2Services()
309
334
  : { running: false, total: 0, online: 0, activityMonitor: false, procs: [] };
310
335
  const session = tmux.installed ? checkTmuxSession() : false;
336
+ const pathCheck = checkPathComplete();
311
337
 
312
338
  return {
313
339
  system: { tmux, pm2, network: net },
314
340
  ai: { cli, auth, authStatus, autonomous, networkSkipped: !net.reachable },
315
341
  services: { ...services, session },
342
+ path: pathCheck,
316
343
  };
317
344
  }
318
345
 
@@ -366,6 +393,19 @@ function buildDiagnosticJson(diag, coreVersion) {
366
393
  }
367
394
  }
368
395
  }
396
+ // D54: PATH completeness — runtime binaries must resolve from the current
397
+ // process PATH (services spawned from here inherit it). A polluted login
398
+ // shell PATH missing ~/.npm-global/bin surfaces here as ENOENT risk.
399
+ // Only relevant when the CLI is actually installed (else cli_missing covers
400
+ // it and a PATH repair hint would be misleading).
401
+ const pathSkipped = !diag.ai.cli.installed;
402
+ if (!pathSkipped && !diag.path.complete) {
403
+ issues.push({
404
+ id: 'path_incomplete',
405
+ label: `PATH 缺失运行时二进制: ${diag.path.missing.join(', ')}`,
406
+ hint: 'Run: baize init(修复 ~/.npm-global/bin 等完整 PATH)',
407
+ });
408
+ }
369
409
 
370
410
  return {
371
411
  version: coreVersion.success ? coreVersion.version : null,
@@ -395,6 +435,10 @@ function buildDiagnosticJson(diag, coreVersion) {
395
435
  procs: diag.services.procs.map(p => ({ name: p.name, status: p.pm2_env?.status || 'unknown' })),
396
436
  session: { active: diag.services.session },
397
437
  },
438
+ path: {
439
+ passed: !diag.ai.cli.installed ? null : diag.path.complete,
440
+ checks: { missing: diag.path.missing },
441
+ },
398
442
  },
399
443
  issues,
400
444
  };
@@ -512,6 +556,18 @@ function displayServiceGroup(diag, jsonGroup) {
512
556
  logToFile(`check: services — ${jsonGroup.passed ? 'passed' : 'failed'}`);
513
557
  }
514
558
 
559
+ // D54: PATH completeness group — runtime binaries must resolve from the
560
+ // current process PATH (services spawned from here inherit it).
561
+ function displayPathGroup(diag, jsonGroup) {
562
+ if (!diag.path.complete) {
563
+ const checks = diag.path.missing.map((b) => red(`${b} 不可解析(ENOENT 风险)`));
564
+ displayCheckGroup('PATH', 'fail', checks);
565
+ } else {
566
+ displayCheckGroup('PATH', 'pass', [green('运行时二进制可解析(npm-global/.local/claude/baize)')]);
567
+ }
568
+ logToFile(`check: path — ${jsonGroup.passed ? 'passed' : 'failed'}`);
569
+ }
570
+
515
571
  // ── Channel discovery ────────────────────────────────────────────
516
572
 
517
573
  function getNetworkIP() {
@@ -651,6 +707,7 @@ function runClaudeFix(diagnosticJson) {
651
707
  const proc = spawn('claude', ['-p', prompt, ...permArgs], {
652
708
  cwd: BAIZE_DIR,
653
709
  stdio: ['ignore', 'pipe', 'pipe'],
710
+ env: { ...process.env, PATH: completeBinPath() },
654
711
  });
655
712
 
656
713
  let stdout = '';
@@ -772,6 +829,9 @@ export async function doctorCommand(args) {
772
829
  displaySystemGroup(diag, diagnostic.groups.system);
773
830
  displayAiGroup(diag, diagnostic.groups.ai_service);
774
831
  displayServiceGroup(diag, diagnostic.groups.services);
832
+ if (diagnostic.groups.path.passed !== null) {
833
+ displayPathGroup(diag, diagnostic.groups.path);
834
+ }
775
835
 
776
836
  // ── Phase 4: Channels ─────────────────────────────────────────
777
837
 
@@ -17,6 +17,7 @@ import { generateManifest, saveMergeBaseline } from '../lib/manifest.js';
17
17
  import { prompt, promptYesNo, promptChoice, promptSecret } from '../lib/prompts.js';
18
18
  import { bold, dim, green, red, yellow, cyan, bgGreen, success, error, warn, heading } from '../lib/colors.js';
19
19
  import { commandExists } from '../lib/shell-utils.js';
20
+ import { knownBinDirs, completeBinPath } from '../lib/path-bins.js';
20
21
  import { getActiveAdapter } from '../lib/runtime/index.js';
21
22
  import {
22
23
  activateFreshSplitInstructions,
@@ -41,6 +42,7 @@ import {
41
42
  saveCodexApiKeyToEnv,
42
43
  saveCodexBaseUrlToEnv,
43
44
  writeCodexConfig,
45
+ applyCodexProviderBlock,
44
46
  } from '../lib/runtime-setup.js';
45
47
 
46
48
  // Source directories (shipped with baize package)
@@ -113,54 +115,68 @@ function installSystemPackage(pkg) {
113
115
  }
114
116
 
115
117
  /**
116
- * Ensure ~/.local/bin is in the user's shell profile.
117
- * Detects shell from $SHELL and writes to the appropriate rc file.
118
+ * D54: ensure the user's shell profile carries the COMPLETE bin PATH —
119
+ * every directory where baize/claude/codex binaries actually live:
120
+ * ~/.npm-global/bin (static — codex/baize default install),
121
+ * npm global prefix bin (dynamic), ~/.local/bin (claude default),
122
+ * ~/.claude/bin, ~/baize/bin (components), ~/.local/node/bin.
123
+ * Idempotent (marker-based) and repairs existing profiles that miss any bin
124
+ * (the 09-13 incident root: ~/.npm-global/bin was never added, so login-shell
125
+ * PATH was always incomplete).
126
+ * NOTE: rc files are NOT chattr/chflags-locked — content-marker idempotency
127
+ * already prevents duplicate writes, and a lock would make `baize
128
+ * self-uninstall` / install.sh re-runs abort with EPERM (D54 review P1-3).
118
129
  * Returns the profile path if modified, null otherwise.
119
130
  */
120
- function ensureLocalBinInProfile() {
131
+ function ensureCompletePathProfile() {
121
132
  const homedir = os.homedir();
122
133
  const shell = (process.env.SHELL || '').split('/').pop();
123
- const pathLine = 'export PATH="$HOME/.local/bin:$PATH"';
124
-
125
- // Map shell to profile file
126
134
  const profileMap = {
127
135
  zsh: '.zshrc',
128
136
  bash: '.bashrc',
129
- fish: null, // fish uses different syntax
137
+ fish: null, // fish uses a different config mechanism (handled elsewhere)
130
138
  sh: '.profile',
131
139
  };
132
-
133
140
  const profileName = profileMap[shell] || '.profile';
134
141
  if (!profileName) return null; // unsupported shell (fish)
135
-
136
142
  const profilePath = path.join(homedir, profileName);
137
143
 
138
- // Check if already present
139
- try {
140
- const content = fs.readFileSync(profilePath, 'utf8');
141
- if (content.includes('.local/bin')) return null; // already there
142
- } catch {
143
- // File doesn't exist — we'll create it
144
- }
144
+ // ── Complete bin set (single source of truth: cli/lib/path-bins.js) ──────
145
+ const bins = new Set(knownBinDirs());
145
146
 
146
- try {
147
- fs.appendFileSync(profilePath, `\n# Added by baize init\n${pathLine}\n`);
148
- return `~/${profileName}`;
149
- } catch {
150
- return null;
147
+ // Read existing profile content (missing file → create)
148
+ let content = '';
149
+ try { content = fs.readFileSync(profilePath, 'utf8'); } catch { /* absent */ }
150
+
151
+ // ── Idempotent + repair: add any bin not already present ─────────────────
152
+ const toAdd = [];
153
+ for (const bin of bins) {
154
+ // match both absolute (~/...) and $HOME-relative forms already in profile
155
+ const rel = bin.startsWith(homedir) ? bin.slice(homedir.length) : bin;
156
+ if (content.includes(bin) || content.includes(`$HOME${rel}`)) continue;
157
+ const portable = bin.startsWith(homedir) ? `$HOME${rel}` : bin;
158
+ toAdd.push(`export PATH="${portable}:$PATH"`);
151
159
  }
160
+ if (toAdd.length === 0) return null; // already complete
161
+
162
+ fs.appendFileSync(profilePath, `\n# Added by baize init (D54 complete PATH)\n${toAdd.join('\n')}\n`);
163
+ return `~/${profileName}`;
152
164
  }
153
165
 
154
166
  /**
155
- * Save the current shell PATH to .env so PM2 services can use it.
156
- * Updates SYSTEM_PATH= line if exists, appends if not.
167
+ * Save the COMPLETE bin PATH to .env so PM2 services can use it.
168
+ * D54: no longer captures process.env.PATH (which may be polluted by a login
169
+ * shell) — builds the full known-good set explicitly: npm global prefix bin,
170
+ * ~/.local/bin, ~/.claude/bin, ~/baize/bin, ~/.local/node/bin.
157
171
  */
158
172
  function saveSystemPath(envPath) {
159
- const currentPath = process.env.PATH || '';
160
- // Deduplicate PATH entries before saving prevents bloat when baize init
161
- // runs while PM2 is already live (process.env.PATH may already contain
162
- // the previous ENHANCED_PATH from ecosystem.config.cjs).
163
- const dedupedPath = [...new Set(currentPath.split(':').filter(Boolean))].join(':');
173
+ const homedir = os.homedir();
174
+ // D54: build the full known-good set via path-bins (single source of truth)
175
+ const bins = new Set(knownBinDirs());
176
+ // keep the current PATH too (it may carry user-added entries worth keeping)
177
+ for (const entry of (process.env.PATH || '').split(':').filter(Boolean)) bins.add(entry);
178
+ const fullPath = [...bins].join(':');
179
+
164
180
  let content = '';
165
181
  try {
166
182
  content = fs.readFileSync(envPath, 'utf8');
@@ -168,7 +184,7 @@ function saveSystemPath(envPath) {
168
184
  return; // .env doesn't exist yet
169
185
  }
170
186
 
171
- const line = `SYSTEM_PATH=${dedupedPath}`;
187
+ const line = `SYSTEM_PATH=${fullPath}`;
172
188
  if (content.includes('SYSTEM_PATH=')) {
173
189
  content = content.replace(/^SYSTEM_PATH=.*$/m, line);
174
190
  } else {
@@ -476,7 +492,7 @@ function verifySetupToken() {
476
492
  try {
477
493
  const result = spawnSync('claude', ['-p', 'hi', '--max-turns', '1'], {
478
494
  timeout: 30000,
479
- env: { ...process.env },
495
+ env: { ...process.env, PATH: completeBinPath() },
480
496
  stdio: ['pipe', 'pipe', 'pipe'],
481
497
  });
482
498
 
@@ -839,6 +855,30 @@ function syncCoreSkills() {
839
855
  const installed = [];
840
856
  const updated = [];
841
857
 
858
+ // D51: web-console imports `../../../cli/lib/runtime-setup.js`, which from
859
+ // `~/.claude/skills/web-console/scripts/` resolves to `~/.claude/cli/lib/`.
860
+ // The npm package ships `package/cli`, but init never deployed it to the
861
+ // runtime tree — web-console crashed with ERR_MODULE_NOT_FOUND in the image.
862
+ // Deploy the shared CLI lib beside SKILLS_DIR so the import path mirrors the
863
+ // package layout (package/cli ↔ <SKILLS_DIR>/../cli).
864
+ const cliSrc = path.join(PACKAGE_ROOT, 'cli');
865
+ const cliDest = path.join(path.dirname(SKILLS_DIR), 'cli');
866
+ if (fs.existsSync(cliSrc)) {
867
+ try {
868
+ copyTree(cliSrc, cliDest);
869
+ // runtime-setup.js imports smol-toml (D51). The cli/ tree is deployed
870
+ // outside any skill's node_modules, so the dependency must ride along —
871
+ // mirror the package layout by copying the package's smol-toml into
872
+ // <cliDest>/node_modules/.
873
+ const tomlSrc = path.join(PACKAGE_ROOT, 'node_modules', 'smol-toml');
874
+ if (fs.existsSync(tomlSrc)) {
875
+ copyTree(tomlSrc, path.join(cliDest, 'node_modules', 'smol-toml'));
876
+ }
877
+ } catch {
878
+ console.log(` ${warn(`Failed to sync cli/ to ${cliDest}`)}`);
879
+ }
880
+ }
881
+
842
882
  const entries = fs.readdirSync(CORE_SKILLS_SRC, { withFileTypes: true });
843
883
  for (const entry of entries) {
844
884
  if (!entry.isDirectory()) continue;
@@ -2153,7 +2193,7 @@ export async function initCommand(args) {
2153
2193
  console.log(`\n ${cyan('Starting Codex device auth...')}`);
2154
2194
  console.log(` ${dim('Follow the instructions to authenticate. Press Ctrl+C when done.')}\n`);
2155
2195
  try {
2156
- spawnSync('codex', ['login', '--device-auth'], { stdio: 'inherit' });
2196
+ spawnSync('codex', ['login', '--device-auth'], { stdio: 'inherit', env: { ...process.env, PATH: completeBinPath() } });
2157
2197
  } catch { /* user may Ctrl+C */ }
2158
2198
  codexAuthenticated = isCodexAuthenticated();
2159
2199
  if (codexAuthenticated) {
@@ -2167,7 +2207,7 @@ export async function initCommand(args) {
2167
2207
  console.log(`\n ${cyan('Starting Codex browser login...')}`);
2168
2208
  console.log(` ${dim('After login completes, press Ctrl+C to return.')}\n`);
2169
2209
  try {
2170
- spawnSync('codex', ['login'], { stdio: 'inherit' });
2210
+ spawnSync('codex', ['login'], { stdio: 'inherit', env: { ...process.env, PATH: completeBinPath() } });
2171
2211
  } catch { /* user may Ctrl+C */ }
2172
2212
  codexAuthenticated = isCodexAuthenticated();
2173
2213
  if (codexAuthenticated) {
@@ -2182,9 +2222,17 @@ export async function initCommand(args) {
2182
2222
  }
2183
2223
  }
2184
2224
  }
2185
-
2186
- // Write ~/.codex/config.toml to suppress interactive prompts on first launch
2187
- if (writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: pendingCodexBaseUrl || undefined })) {
2225
+ // Write ~/.codex/config.toml to suppress interactive prompts on first
2226
+ // launch. A custom --codex-base-url becomes a dedicated provider block
2227
+ // (D51) never the built-in-OpenAI openai_base_url override.
2228
+ if (pendingCodexBaseUrl) {
2229
+ applyCodexProviderBlock({
2230
+ providerKey: 'baize-init',
2231
+ baseUrl: pendingCodexBaseUrl,
2232
+ token: opts.codexApiKey || undefined,
2233
+ });
2234
+ if (!quiet) console.log(` ${success('Codex startup config written (provider block)')}`);
2235
+ } else if (writeCodexConfig(BAIZE_DIR)) {
2188
2236
  if (!quiet) console.log(` ${success('Codex startup config written')}`);
2189
2237
  }
2190
2238
  }
@@ -2276,9 +2324,9 @@ export async function initCommand(args) {
2276
2324
  // /dev/tty redirects. Use `script` to allocate a fresh pseudo-terminal
2277
2325
  // that gives Claude full terminal control.
2278
2326
  if (process.platform === 'darwin') {
2279
- spawnSync('script', ['-q', '/dev/null', 'claude'], { stdio: 'inherit' });
2327
+ spawnSync('script', ['-q', '/dev/null', 'claude'], { stdio: 'inherit', env: { ...process.env, PATH: completeBinPath() } });
2280
2328
  } else {
2281
- spawnSync('claude', [], { stdio: 'inherit' });
2329
+ spawnSync('claude', [], { stdio: 'inherit', env: { ...process.env, PATH: completeBinPath() } });
2282
2330
  }
2283
2331
  } catch { /* user may Ctrl+C */ }
2284
2332
  process.removeAllListeners('SIGINT');
@@ -2409,7 +2457,12 @@ export async function initCommand(args) {
2409
2457
  }
2410
2458
  if (pendingCodexBaseUrl) {
2411
2459
  saveCodexBaseUrlToEnv(pendingCodexBaseUrl);
2412
- writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: pendingCodexBaseUrl });
2460
+ // D51: custom endpoint → dedicated provider block, not the built-in-OpenAI override.
2461
+ applyCodexProviderBlock({
2462
+ providerKey: 'baize-init',
2463
+ baseUrl: pendingCodexBaseUrl,
2464
+ token: opts.codexApiKey || undefined,
2465
+ });
2413
2466
  }
2414
2467
 
2415
2468
  // Timezone: use resolved value or show current
@@ -2518,7 +2571,12 @@ export async function initCommand(args) {
2518
2571
  }
2519
2572
  if (pendingCodexBaseUrl) {
2520
2573
  saveCodexBaseUrlToEnv(pendingCodexBaseUrl);
2521
- writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: pendingCodexBaseUrl });
2574
+ // D51: custom endpoint → dedicated provider block, not the built-in-OpenAI override.
2575
+ applyCodexProviderBlock({
2576
+ providerKey: 'baize-init',
2577
+ baseUrl: pendingCodexBaseUrl,
2578
+ token: opts.codexApiKey || undefined,
2579
+ });
2522
2580
  }
2523
2581
  // Step 8: Configure timezone
2524
2582
  if (!quiet) console.log(`\n${heading('Timezone configuration...')}`);
@@ -2580,9 +2638,13 @@ export async function initCommand(args) {
2580
2638
 
2581
2639
  printWebConsoleInfo();
2582
2640
 
2583
- if (claudeJustInstalled) {
2584
- // Auto-add ~/.local/bin to shell profile so future shell sessions find claude
2585
- ensureLocalBinInProfile();
2641
+ // D54: EVERY init run repairs the complete bin PATH in the shell profile
2642
+ // (npm-global/.local/claude/baize/node bins) idempotent, no-op when
2643
+ // complete. Runs unconditionally: a claude-runtime deployment re-running
2644
+ // init with --yes/--quiet must also get the missing bins repaired.
2645
+ const profile = ensureCompletePathProfile();
2646
+ if (profile && !quiet) {
2647
+ console.log(`${green(` ✔ PATH 已写入 ${profile}(含 npm-global/.local/claude/baize/node bin)`)}`);
2586
2648
  }
2587
2649
 
2588
2650
  if (!quiet) {
@@ -29,6 +29,7 @@ import {
29
29
  saveSetupTokenToEnv,
30
30
  saveCodexApiKey,
31
31
  saveCodexBaseUrlToEnv,
32
+ applyCodexProviderBlock,
32
33
  saveCodexApiKeyToEnv,
33
34
  writeCodexConfig,
34
35
  } from '../lib/runtime-setup.js';
@@ -123,7 +124,14 @@ export function applyBaseUrl(target, baseUrl) {
123
124
  }
124
125
  if (target === 'codex') {
125
126
  if (!saveCodexBaseUrlToEnv(baseUrl)) return false;
126
- return writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: baseUrl });
127
+ // D51: external endpoints get a dedicated provider block (DeepSeek-safe,
128
+ // local compaction) — never the built-in-openai openai_base_url override.
129
+ try {
130
+ applyCodexProviderBlock({ providerKey: 'baize-runtime', baseUrl, projectDir: BAIZE_DIR });
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
127
135
  }
128
136
  return false;
129
137
  }
@@ -72,7 +72,7 @@ describe('base URL support', () => {
72
72
  }
73
73
  });
74
74
 
75
- test('writeCodexConfig writes openai_base_url when OPENAI_BASE_URL is set', async () => {
75
+ test('writeCodexConfig never writes openai_base_url env override is dropped (D51)', async () => {
76
76
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-base-url-'));
77
77
  const originalHome = process.env.HOME;
78
78
  const originalOpenAiBaseUrl = process.env.OPENAI_BASE_URL;
@@ -87,7 +87,7 @@ describe('base URL support', () => {
87
87
 
88
88
  const configPath = path.join(tmpRoot, '.codex', 'config.toml');
89
89
  const config = fs.readFileSync(configPath, 'utf8');
90
- assert.match(config, /openai_base_url = "https:\/\/openai-proxy\.example\.com\/v1"/);
90
+ assert.doesNotMatch(config, /openai_base_url/);
91
91
  } finally {
92
92
  if (originalHome === undefined) delete process.env.HOME;
93
93
  else process.env.HOME = originalHome;
@@ -99,31 +99,73 @@ describe('base URL support', () => {
99
99
  }
100
100
  });
101
101
 
102
- test('writeCodexConfig writes openai_base_url when explicit opts.openaiBaseUrl is provided', async () => {
102
+ test('writeCodexConfig strips a stale legacy openai_base_url from existing config (D51)', async () => {
103
103
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-base-url-opt-'));
104
104
  const originalHome = process.env.HOME;
105
- const originalOpenAiBaseUrl = process.env.OPENAI_BASE_URL;
106
105
 
107
106
  process.env.HOME = tmpRoot;
108
- delete process.env.OPENAI_BASE_URL;
109
107
 
110
108
  try {
111
109
  const { writeCodexConfig } = await import('../runtime-setup.js');
112
110
 
113
- assert.equal(
114
- writeCodexConfig(path.join(tmpRoot, 'baize-project'), { openaiBaseUrl: 'https://explicit-proxy.example.com/v1' }),
115
- true
116
- );
117
-
118
111
  const configPath = path.join(tmpRoot, '.codex', 'config.toml');
112
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
113
+ fs.writeFileSync(configPath, 'openai_base_url = "https://explicit-proxy.example.com/v1"\nmodel = "old"\n');
114
+
115
+ assert.equal(writeCodexConfig(path.join(tmpRoot, 'baize-project'), { openaiBaseUrl: 'https://explicit-proxy.example.com/v1' }), true);
116
+
119
117
  const config = fs.readFileSync(configPath, 'utf8');
120
- assert.match(config, /openai_base_url = "https:\/\/explicit-proxy\.example\.com\/v1"/);
118
+ assert.doesNotMatch(config, /openai_base_url/);
119
+ assert.match(config, /^model = "old"$/m, 'unrelated keys preserved');
121
120
  } finally {
122
121
  if (originalHome === undefined) delete process.env.HOME;
123
122
  else process.env.HOME = originalHome;
124
123
 
125
- if (originalOpenAiBaseUrl === undefined) delete process.env.OPENAI_BASE_URL;
126
- else process.env.OPENAI_BASE_URL = originalOpenAiBaseUrl;
124
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test('--codex-base-url path writes a baize-init provider block (D51)', async () => {
129
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-init-provider-'));
130
+ const originalHome = process.env.HOME;
131
+ const originalBaizeDir = process.env.BAIZE_DIR;
132
+
133
+ process.env.HOME = tmpRoot;
134
+ process.env.BAIZE_DIR = path.join(tmpRoot, 'baize');
135
+
136
+ try {
137
+ const { applyCodexProviderBlock } = await import('../runtime-setup.js');
138
+
139
+ // This mirrors exactly what init.js does when --codex-base-url is given:
140
+ // pendingCodexBaseUrl → applyCodexProviderBlock (providerKey 'baize-init').
141
+ // projectDir passed explicitly: the module-level BAIZE_DIR fallback is
142
+ // captured at first import and may not match this test's env override.
143
+ applyCodexProviderBlock({
144
+ providerKey: 'baize-init',
145
+ baseUrl: 'https://proxy.example.com/v1',
146
+ token: 'sk-init-key',
147
+ projectDir: process.env.BAIZE_DIR,
148
+ });
149
+
150
+ const config = fs.readFileSync(path.join(tmpRoot, '.codex', 'config.toml'), 'utf8');
151
+ assert.match(config, /\[model_providers\.baize-init\]/);
152
+ assert.match(config, /^model_provider = "baize-init"$/m);
153
+ assert.match(config, /^name = "baize-init"$/m);
154
+ assert.match(config, /^base_url = "https:\/\/proxy\.example\.com\/v1"$/m);
155
+ assert.match(config, /^wire_api = "responses"$/m);
156
+ assert.match(config, /^experimental_bearer_token = "sk-init-key"$/m);
157
+ assert.doesNotMatch(config, /openai_base_url/);
158
+
159
+ // Project-level: no provider block, no override.
160
+ const projectConfig = fs.readFileSync(path.join(tmpRoot, 'baize', '.codex', 'config.toml'), 'utf8');
161
+ assert.doesNotMatch(projectConfig, /model_provider/);
162
+ assert.doesNotMatch(projectConfig, /\[model_providers\./);
163
+ assert.doesNotMatch(projectConfig, /openai_base_url/);
164
+ } finally {
165
+ if (originalHome === undefined) delete process.env.HOME;
166
+ else process.env.HOME = originalHome;
167
+ if (originalBaizeDir === undefined) delete process.env.BAIZE_DIR;
168
+ else process.env.BAIZE_DIR = originalBaizeDir;
127
169
 
128
170
  fs.rmSync(tmpRoot, { recursive: true, force: true });
129
171
  }
@@ -126,6 +126,8 @@ describe('runtime base URL support', () => {
126
126
  assert.equal(claudeSettings.env.ANTHROPIC_BASE_URL, 'https://claude-proxy.example.com');
127
127
 
128
128
  const codexConfig = fs.readFileSync(path.join(tmpRoot, '.codex', 'config.toml'), 'utf8');
129
- assert.match(codexConfig, /openai_base_url = "https:\/\/codex-proxy\.example\.com\/v1"/);
129
+ assert.match(codexConfig, /model_provider = "baize-runtime"/);
130
+ assert.match(codexConfig, /\[model_providers\.baize-runtime\][\s\S]*?base_url = "https:\/\/codex-proxy\.example\.com\/v1"/);
131
+ assert.doesNotMatch(codexConfig, /openai_base_url/);
130
132
  });
131
133
  });
@@ -278,12 +278,14 @@ describe('Claude launch — compat mode PATH dedupe', () => {
278
278
  await makeAdapter(ClaudeAdapter).launch({ bypassPermissions: false });
279
279
  const env = readSpecEnv();
280
280
  assert.ok(env, 'spec should be written');
281
- assert.equal(env.PATH, '/a:/b:/c', 'PATH must be deduplicated in compat mode');
281
+ // D54: known bin dirs first + deduplicated (npm-global static first)
282
+ const expected = `${fakeHome}/.npm-global/bin:${fakeHome}/.local/bin:${fakeHome}/.claude/bin:${fakeHome}/baize/bin:${fakeHome}/.local/node/bin:/a:/b:/c`;
283
+ assert.equal(env.PATH, expected, 'PATH must carry known bins first and be deduplicated in compat mode');
282
284
 
283
285
  const tmux = findTmuxNewSession();
284
286
  const pathArg = tmux.args.find(a => a.startsWith('PATH='));
285
287
  assert.ok(pathArg, 'tmux args should contain PATH= env');
286
- assert.equal(pathArg, 'PATH=/a:/b:/c', 'tmux -e PATH must also be deduplicated');
288
+ assert.equal(pathArg, `PATH=${expected}`, 'tmux -e PATH must also carry known bins + dedupe');
287
289
  } finally {
288
290
  process.env.PATH = origPath;
289
291
  // Restore default (clean env is now the default)