@baize-ai/core 0.3.16 → 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.
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,21 @@ 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
+
8
23
  ## [0.3.16] - 2026-09-13
9
24
 
10
25
  ### Added
package/Dockerfile CHANGED
@@ -86,6 +86,11 @@ ARG BAZE_CORE_VERSION=latest
86
86
  # build context) to install the workspace package instead of the registry
87
87
  # version — lets us verify un-released fixes (e.g. D51) in the image.
88
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
89
94
  WORKDIR /home/baize
90
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 \
91
96
  && baize --version \
@@ -138,11 +143,6 @@ RUN mkdir -p \
138
143
  # ── Copy PM2 ecosystem config ─────────────────────────────────────────────────
139
144
  COPY --chown=baize:baize templates/pm2/ecosystem.config.cjs /home/baize/baize/pm2/ecosystem.config.cjs
140
145
 
141
- # ── Local-source build context (optional) ────────────────────────────────────
142
- # When BAZE_CORE_TARBALL is set, the tarball must live at ./core.tgz in the
143
- # build context; it is staged at /tmp so the install RUN above can consume it.
144
- COPY core.tgz /tmp/core.tgz
145
-
146
146
  # ── Copy entrypoint ───────────────────────────────────────────────────────────
147
147
  COPY --chown=baize:baize docker/entrypoint.sh /entrypoint.sh
148
148
  RUN chmod +x /entrypoint.sh
@@ -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,
@@ -114,54 +115,68 @@ function installSystemPackage(pkg) {
114
115
  }
115
116
 
116
117
  /**
117
- * Ensure ~/.local/bin is in the user's shell profile.
118
- * 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).
119
129
  * Returns the profile path if modified, null otherwise.
120
130
  */
121
- function ensureLocalBinInProfile() {
131
+ function ensureCompletePathProfile() {
122
132
  const homedir = os.homedir();
123
133
  const shell = (process.env.SHELL || '').split('/').pop();
124
- const pathLine = 'export PATH="$HOME/.local/bin:$PATH"';
125
-
126
- // Map shell to profile file
127
134
  const profileMap = {
128
135
  zsh: '.zshrc',
129
136
  bash: '.bashrc',
130
- fish: null, // fish uses different syntax
137
+ fish: null, // fish uses a different config mechanism (handled elsewhere)
131
138
  sh: '.profile',
132
139
  };
133
-
134
140
  const profileName = profileMap[shell] || '.profile';
135
141
  if (!profileName) return null; // unsupported shell (fish)
136
-
137
142
  const profilePath = path.join(homedir, profileName);
138
143
 
139
- // Check if already present
140
- try {
141
- const content = fs.readFileSync(profilePath, 'utf8');
142
- if (content.includes('.local/bin')) return null; // already there
143
- } catch {
144
- // File doesn't exist — we'll create it
145
- }
144
+ // ── Complete bin set (single source of truth: cli/lib/path-bins.js) ──────
145
+ const bins = new Set(knownBinDirs());
146
146
 
147
- try {
148
- fs.appendFileSync(profilePath, `\n# Added by baize init\n${pathLine}\n`);
149
- return `~/${profileName}`;
150
- } catch {
151
- 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"`);
152
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}`;
153
164
  }
154
165
 
155
166
  /**
156
- * Save the current shell PATH to .env so PM2 services can use it.
157
- * 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.
158
171
  */
159
172
  function saveSystemPath(envPath) {
160
- const currentPath = process.env.PATH || '';
161
- // Deduplicate PATH entries before saving prevents bloat when baize init
162
- // runs while PM2 is already live (process.env.PATH may already contain
163
- // the previous ENHANCED_PATH from ecosystem.config.cjs).
164
- 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
+
165
180
  let content = '';
166
181
  try {
167
182
  content = fs.readFileSync(envPath, 'utf8');
@@ -169,7 +184,7 @@ function saveSystemPath(envPath) {
169
184
  return; // .env doesn't exist yet
170
185
  }
171
186
 
172
- const line = `SYSTEM_PATH=${dedupedPath}`;
187
+ const line = `SYSTEM_PATH=${fullPath}`;
173
188
  if (content.includes('SYSTEM_PATH=')) {
174
189
  content = content.replace(/^SYSTEM_PATH=.*$/m, line);
175
190
  } else {
@@ -477,7 +492,7 @@ function verifySetupToken() {
477
492
  try {
478
493
  const result = spawnSync('claude', ['-p', 'hi', '--max-turns', '1'], {
479
494
  timeout: 30000,
480
- env: { ...process.env },
495
+ env: { ...process.env, PATH: completeBinPath() },
481
496
  stdio: ['pipe', 'pipe', 'pipe'],
482
497
  });
483
498
 
@@ -2178,7 +2193,7 @@ export async function initCommand(args) {
2178
2193
  console.log(`\n ${cyan('Starting Codex device auth...')}`);
2179
2194
  console.log(` ${dim('Follow the instructions to authenticate. Press Ctrl+C when done.')}\n`);
2180
2195
  try {
2181
- spawnSync('codex', ['login', '--device-auth'], { stdio: 'inherit' });
2196
+ spawnSync('codex', ['login', '--device-auth'], { stdio: 'inherit', env: { ...process.env, PATH: completeBinPath() } });
2182
2197
  } catch { /* user may Ctrl+C */ }
2183
2198
  codexAuthenticated = isCodexAuthenticated();
2184
2199
  if (codexAuthenticated) {
@@ -2192,7 +2207,7 @@ export async function initCommand(args) {
2192
2207
  console.log(`\n ${cyan('Starting Codex browser login...')}`);
2193
2208
  console.log(` ${dim('After login completes, press Ctrl+C to return.')}\n`);
2194
2209
  try {
2195
- spawnSync('codex', ['login'], { stdio: 'inherit' });
2210
+ spawnSync('codex', ['login'], { stdio: 'inherit', env: { ...process.env, PATH: completeBinPath() } });
2196
2211
  } catch { /* user may Ctrl+C */ }
2197
2212
  codexAuthenticated = isCodexAuthenticated();
2198
2213
  if (codexAuthenticated) {
@@ -2309,9 +2324,9 @@ export async function initCommand(args) {
2309
2324
  // /dev/tty redirects. Use `script` to allocate a fresh pseudo-terminal
2310
2325
  // that gives Claude full terminal control.
2311
2326
  if (process.platform === 'darwin') {
2312
- spawnSync('script', ['-q', '/dev/null', 'claude'], { stdio: 'inherit' });
2327
+ spawnSync('script', ['-q', '/dev/null', 'claude'], { stdio: 'inherit', env: { ...process.env, PATH: completeBinPath() } });
2313
2328
  } else {
2314
- spawnSync('claude', [], { stdio: 'inherit' });
2329
+ spawnSync('claude', [], { stdio: 'inherit', env: { ...process.env, PATH: completeBinPath() } });
2315
2330
  }
2316
2331
  } catch { /* user may Ctrl+C */ }
2317
2332
  process.removeAllListeners('SIGINT');
@@ -2623,9 +2638,13 @@ export async function initCommand(args) {
2623
2638
 
2624
2639
  printWebConsoleInfo();
2625
2640
 
2626
- if (claudeJustInstalled) {
2627
- // Auto-add ~/.local/bin to shell profile so future shell sessions find claude
2628
- 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)`)}`);
2629
2648
  }
2630
2649
 
2631
2650
  if (!quiet) {
@@ -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)
@@ -464,11 +464,20 @@ describe('buildCleanEnv', () => {
464
464
  // ── buildCompatEnv ─────────────────────────────────────────────────────────
465
465
 
466
466
  describe('buildCompatEnv', () => {
467
- it('passes through full processEnv', () => {
467
+ it('passes through full processEnv and prepends known bin dirs (D54)', () => {
468
468
  const processEnv = { PATH: '/usr/bin', HOME: '/home/test', SECRET: 'abc123' };
469
469
  const { env } = buildCompatEnv({ processEnv, dotenvVars: {} });
470
470
  assert.equal(env.SECRET, 'abc123');
471
- assert.equal(env.PATH, '/usr/bin');
471
+ const parts = env.PATH.split(':');
472
+ // D54: known bin dirs are ALWAYS prepended (npm-global static first),
473
+ // original PATH entries preserved, no duplicates.
474
+ assert.equal(parts[0], '/home/test/.npm-global/bin');
475
+ assert.ok(parts.includes('/home/test/.local/bin'));
476
+ assert.ok(parts.includes('/home/test/.claude/bin'));
477
+ assert.ok(parts.includes('/home/test/baize/bin'));
478
+ assert.ok(parts.includes('/home/test/.local/node/bin'));
479
+ assert.ok(parts.includes('/usr/bin'));
480
+ assert.equal(new Set(parts).size, parts.length, 'PATH must be deduplicated');
472
481
  });
473
482
 
474
483
  it('overrides with BAIZE_TMUX_ENV manifest vars', () => {
@@ -478,10 +487,15 @@ describe('buildCompatEnv', () => {
478
487
  assert.equal(env.MY_VAR, 'new_value');
479
488
  });
480
489
 
481
- it('deduplicates PATH preserving first-occurrence order', () => {
490
+ it('deduplicates PATH preserving first-occurrence order with known bins first (D54)', () => {
482
491
  const processEnv = { PATH: '/a:/b:/a:/c:/b', HOME: '/home/test' };
483
492
  const { env } = buildCompatEnv({ processEnv, dotenvVars: {} });
484
- assert.equal(env.PATH, '/a:/b:/c');
493
+ const parts = env.PATH.split(':');
494
+ assert.equal(parts[0], '/home/test/.npm-global/bin');
495
+ // original entries deduped, first-occurrence order preserved after known bins
496
+ const tail = parts.slice(parts.indexOf('/a'));
497
+ assert.deepEqual(tail, ['/a', '/b', '/c']);
498
+ assert.equal(new Set(parts).size, parts.length);
485
499
  });
486
500
 
487
501
  it('does not inject GH_PROMPT_DISABLED, Homebrew paths, or PATH manifest', () => {
@@ -492,7 +506,7 @@ describe('buildCompatEnv', () => {
492
506
  };
493
507
  const { env } = buildCompatEnv({ processEnv, dotenvVars });
494
508
  assert.equal(env.GH_PROMPT_DISABLED, undefined);
495
- assert.ok(!env.PATH.includes('/opt/homebrew'), 'compat mode should not add Homebrew paths');
509
+ // compat mode does NOT apply PATH manifest PREPEND/APPEND (clean-env only)
496
510
  assert.ok(!env.PATH.includes('/custom/pre'), 'compat mode should not apply PREPEND');
497
511
  assert.ok(!env.PATH.includes('/custom/post'), 'compat mode should not apply APPEND');
498
512
  });
@@ -8,6 +8,7 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { spawn } from 'node:child_process';
11
+ import { completeBinPath } from './path-bins.js';
11
12
  import { parseSkillMd } from './skill.js';
12
13
 
13
14
  const MAX_FILE_LINES = 500;
@@ -133,7 +134,7 @@ function callClaude(prompt) {
133
134
 
134
135
  const child = spawn('claude', ['--print', '--output-format', 'text'], {
135
136
  stdio: ['pipe', 'pipe', 'ignore'],
136
- env: { ...process.env },
137
+ env: { ...process.env, PATH: completeBinPath() },
137
138
  });
138
139
 
139
140
  child.stdin.on('error', () => {}); // Ignore EPIPE if child exits early
@@ -289,12 +289,23 @@ function writeTrustMarker(markerPath, marker) {
289
289
  fs.writeFileSync(markerPath, JSON.stringify(stable(marker), null, 2) + '\n', { mode: 0o600 });
290
290
  }
291
291
 
292
+ /**
293
+ * D54 PATH hardening: build a PATH that always includes the known bin dirs
294
+ * (npm global prefix bin, ~/.local/bin, ~/.claude/bin, ~/baize/bin) on top of
295
+ * the current PATH — a login-shell-polluted PATH (missing ~/.npm-global/bin)
296
+ * must not make a bare `codex` spawn fail with ENOENT (09-13 incident).
297
+ * Single source of truth: cli/lib/path-bins.js.
298
+ */
299
+ import { completeBinPath } from './path-bins.js';
300
+ export { completeBinPath };
301
+
292
302
  export function getCodexVersion({ codexBin = process.env.CODEX_BIN || 'codex', execFileSyncImpl } = {}) {
293
303
  const execImpl = execFileSyncImpl || execFileSync;
294
304
  return String(execImpl(codexBin, ['--version'], {
295
305
  encoding: 'utf8',
296
306
  timeout: 10_000,
297
307
  stdio: 'pipe',
308
+ env: { ...process.env, PATH: completeBinPath() },
298
309
  })).trim();
299
310
  }
300
311
 
@@ -470,6 +481,7 @@ send('initialize', {
470
481
  cwd: baizeDir,
471
482
  env: {
472
483
  ...process.env,
484
+ PATH: completeBinPath(),
473
485
  BAIZE_CODEX_TRUST_CWD: baizeDir,
474
486
  BAIZE_CODEX_BIN: codexBin,
475
487
  },
@@ -0,0 +1,54 @@
1
+ /**
2
+ * D54: complete known-good bin PATH — the single source of truth for the bin
3
+ * directories where baize/claude/codex binaries actually live. Every spawn
4
+ * of a runtime binary must pass PATH built from this so a login-shell-polluted
5
+ * PATH (missing ~/.npm-global/bin — 09-13 incident) can never cause ENOENT.
6
+ *
7
+ * Consumers: init.js (profile repair + SYSTEM_PATH), codex-hooks.js
8
+ * (getCodexVersion), runtime-setup.js (auth checks), claude-eval.js,
9
+ * runtime/codex.js + runtime/claude.js (launch auth checks).
10
+ */
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { execFileSync } from 'node:child_process';
14
+
15
+ /**
16
+ * @param {object} [opts]
17
+ * @param {string} [opts.currentPath] base PATH to extend (default process.env.PATH)
18
+ * @returns {string} deduplicated PATH with all known bin dirs first
19
+ */
20
+ export function completeBinPath({ currentPath = process.env.PATH || '', home = os.homedir() } = {}) {
21
+ const bins = [];
22
+ // ~/.npm-global/bin — STATIC first (the 09-13 incident dir): codex/baize
23
+ // install there in the standard (Dockerfile NPM_CONFIG_PREFIX + install.sh
24
+ // user prefix) layouts; must never depend on `npm` being resolvable from
25
+ // the calling process (PM2 services, containers with replaced PATH).
26
+ bins.push(path.join(home, '.npm-global', 'bin'));
27
+ // npm global prefix bin — dynamic (npm config get prefix); covers custom
28
+ // prefixes (e.g. /opt/homebrew) in addition to the default.
29
+ try {
30
+ const prefix = execFileSync('npm', ['config', 'get', 'prefix'], { encoding: 'utf8', timeout: 10_000 }).trim();
31
+ if (prefix && prefix !== 'undefined' && prefix !== 'null') bins.push(path.join(prefix, 'bin'));
32
+ } catch { /* npm unavailable */ }
33
+ bins.push(path.join(home, '.local', 'bin'));
34
+ bins.push(path.join(home, '.claude', 'bin'));
35
+ bins.push(path.join(home, 'baize', 'bin'));
36
+ bins.push(path.join(home, '.local', 'node', 'bin'));
37
+ for (const entry of currentPath.split(':').filter(Boolean)) bins.push(entry);
38
+ return [...new Set(bins)].join(':');
39
+ }
40
+
41
+ /** Known bin dirs (without the current PATH tail) — for profile writes. */
42
+ export function knownBinDirs(home = os.homedir()) {
43
+ const bins = [];
44
+ bins.push(path.join(home, '.npm-global', 'bin'));
45
+ try {
46
+ const prefix = execFileSync('npm', ['config', 'get', 'prefix'], { encoding: 'utf8', timeout: 10_000 }).trim();
47
+ if (prefix && prefix !== 'undefined' && prefix !== 'null') bins.push(path.join(prefix, 'bin'));
48
+ } catch { /* npm unavailable */ }
49
+ bins.push(path.join(home, '.local', 'bin'));
50
+ bins.push(path.join(home, '.claude', 'bin'));
51
+ bins.push(path.join(home, 'baize', 'bin'));
52
+ bins.push(path.join(home, '.local', 'node', 'bin'));
53
+ return [...new Set(bins)];
54
+ }
@@ -16,6 +16,7 @@ import fs from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { execFileSync, execFile } from 'node:child_process';
19
+ import { completeBinPath } from '../path-bins.js';
19
20
  import { promisify } from 'node:util';
20
21
 
21
22
  const execFileAsync = promisify(execFile);
@@ -104,7 +105,7 @@ export class ClaudeAdapter extends RuntimeAdapter {
104
105
  */
105
106
  async checkAuth() {
106
107
  // Build subprocess env: inherit current env, inject .env API keys (same as launch()).
107
- const injectedEnv = { ...process.env };
108
+ const injectedEnv = { ...process.env, PATH: completeBinPath() };
108
109
  let envApiKey = '';
109
110
  let envOauthToken = '';
110
111
  let envBaseUrl = '';
@@ -257,6 +258,7 @@ export class ClaudeAdapter extends RuntimeAdapter {
257
258
  try {
258
259
  const out = execFileSync(CLAUDE_BIN, ['auth', 'status'], {
259
260
  encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'],
261
+ env: { ...process.env, PATH: completeBinPath() },
260
262
  });
261
263
  const status = JSON.parse(out);
262
264
  if (status?.loggedIn === true && status?.authMethod === 'claude.ai') {
@@ -435,7 +437,7 @@ function _ensureOnboardingComplete(projectDir) {
435
437
  config.hasCompletedOnboarding = true;
436
438
  try {
437
439
  config.lastOnboardingVersion = execFileSync(
438
- CLAUDE_BIN, ['--version'], { encoding: 'utf8', timeout: 5000 }
440
+ CLAUDE_BIN, ['--version'], { encoding: 'utf8', timeout: 5000, env: { ...process.env, PATH: completeBinPath() } }
439
441
  ).trim();
440
442
  } catch {
441
443
  config.lastOnboardingVersion = '2.1.59';
@@ -18,6 +18,7 @@ import os from 'node:os';
18
18
  import path from 'node:path';
19
19
  import { execFileSync, execFile } from 'node:child_process';
20
20
  import { promisify } from 'node:util';
21
+ import { completeBinPath } from '../path-bins.js';
21
22
 
22
23
  const execFileAsync = promisify(execFile);
23
24
  import { RuntimeAdapter } from './base.js';
@@ -147,6 +148,7 @@ export class CodexAdapter extends RuntimeAdapter {
147
148
  try {
148
149
  const { stdout, stderr } = await execFileAsync(CODEX_BIN, ['login', 'status'], {
149
150
  stdio: 'pipe', encoding: 'utf8', timeout: 10_000,
151
+ env: { ...process.env, PATH: completeBinPath() },
150
152
  });
151
153
  const status = classifyCodexLoginStatus((stdout || '') + (stderr || ''));
152
154
  if (status === 'success') return { status: 'success', reason: 'codex_login_status' };
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { completeBinPath } from '../path-bins.js';
4
5
 
5
6
  const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
6
7
 
@@ -280,10 +281,10 @@ export function buildCleanEnv({ processEnv, dotenvVars, manifest, platform, uid,
280
281
  export function buildCompatEnv({ processEnv, dotenvVars }) {
281
282
  const env = { ...processEnv };
282
283
 
283
- // Deduplicate PATH to prevent bloat across restarts (PR #499 defense)
284
- if (env.PATH) {
285
- env.PATH = [...new Set(env.PATH.split(':').filter(Boolean))].join(':');
286
- }
284
+ // D54: PATH built from the shared single source of truth (path-bins)
285
+ // always prepends npm-global/.local/.claude/baize/node bins and dedupes;
286
+ // a polluted login-shell PATH can never hide the runtime binaries.
287
+ env.PATH = completeBinPath({ currentPath: env.PATH, home: processEnv.HOME });
287
288
 
288
289
  // Override with BAIZE_TMUX_ENV manifest vars from dotenvVars
289
290
  const warnings = [];
@@ -13,6 +13,7 @@ import { execSync, execFileSync, spawnSync } from 'node:child_process';
13
13
  import { parse, stringify } from 'smol-toml';
14
14
  import crypto from 'node:crypto';
15
15
  import { BAIZE_DIR } from './config.js';
16
+ import { completeBinPath } from './path-bins.js';
16
17
  import { commandExists } from './shell-utils.js';
17
18
  import { parseClaudeAuthStatus, parseCodexLoginStatus } from './auth-parsers.js';
18
19
  import { installCoreCodexHook } from './codex-hooks.js';
@@ -90,6 +91,7 @@ export function isClaudeAuthenticated() {
90
91
  stdio: 'pipe',
91
92
  encoding: 'utf8',
92
93
  timeout: 10000,
94
+ env: { ...process.env, PATH: completeBinPath() },
93
95
  });
94
96
  return parseClaudeAuthStatus(result.stdout);
95
97
  } catch {
@@ -129,6 +131,7 @@ export function isCodexAuthenticated() {
129
131
  try {
130
132
  const result = spawnSync('codex', ['login', 'status'], {
131
133
  stdio: 'pipe', encoding: 'utf8', timeout: 10000,
134
+ env: { ...process.env, PATH: completeBinPath() },
132
135
  });
133
136
  return parseCodexLoginStatus((result.stdout || '') + (result.stderr || ''));
134
137
  } catch {
@@ -158,7 +161,7 @@ export function approveApiKey(keyOrToken) {
158
161
  if (!config.hasCompletedOnboarding) {
159
162
  config.hasCompletedOnboarding = true;
160
163
  try {
161
- const ver = execSync('claude --version 2>/dev/null', { encoding: 'utf8' }).trim();
164
+ const ver = execSync('claude --version 2>/dev/null', { encoding: 'utf8', env: { ...process.env, PATH: completeBinPath() } }).trim();
162
165
  config.lastOnboardingVersion = ver;
163
166
  } catch { /* omit if claude binary not yet available */ }
164
167
  }
@@ -124,8 +124,10 @@ upsert_env "CODEX_BYPASS_PERMISSIONS" "${CODEX_BYPASS_PERMISSIONS:-true}"
124
124
  upsert_env "OPENAI_API_KEY" "${OPENAI_API_KEY:-}"
125
125
  upsert_env "CODEX_API_KEY" "${CODEX_API_KEY:-}"
126
126
 
127
- # Save current PATH so PM2 services can find claude and node
128
- upsert_env "SYSTEM_PATH" "${PATH}"
127
+ # D54: SYSTEM_PATH is owned exclusively by baize init (saveSystemPath writes
128
+ # the complete known-good bin PATH). entrypoint no longer captures the
129
+ # container PATH here — ecosystem.config.cjs carries an explicit
130
+ # ~/.npm-global/bin fallback, so a polluted shell can never degrade services.
129
131
 
130
132
  # ── Graceful shutdown ─────────────────────────────────────────────────────────
131
133
  # docker stop sends SIGTERM to PID 1 (this script). Clean up tmux + PM2.
@@ -0,0 +1,161 @@
1
+ # Baize Agent 容器运维排查手册
2
+
3
+ > 适用:docker 部署的 baize agent(`baize-core` 镜像)。
4
+ > 原则:先只读诊断定位,再修改;生产环境每步执行前确认。
5
+
6
+ ---
7
+
8
+ ## 1. 症状:AI main 会话起不来
9
+
10
+ **表现**:agent 不再响应任务;`web-console` 可能还活着,但调度/消息链路断了。
11
+
12
+ **"main 会话"是什么**:AI 主循环(Claude Code / Codex)跑在持久 **tmux 会话**里
13
+ (`claude-main` / `codex-main`),由 `activity-monitor`(Guardian 看门狗)负责拉起,
14
+ `scheduler` 依赖它收发消息。会话没了 = 看门狗没拉起或拉起失败。
15
+
16
+ ---
17
+
18
+ ## 2. 快速排查流程(只读,从外到内)
19
+
20
+ ```bash
21
+ # ① 容器状态(是否在跑 / 重启次数)
22
+ docker ps -a | grep -i baize
23
+
24
+ # ② pm2 服务(web-console / scheduler / c4-dispatcher / activity-monitor)
25
+ docker exec <容器名> pm2 list
26
+
27
+ # ③ tmux 会话(main 会话在不在)
28
+ docker exec <容器名> bash -lc "tmux ls"
29
+
30
+ # ④ 看门狗日志(核心——Guardian 为什么没拉起)
31
+ docker exec <容器名> bash -lc "pm2 logs activity-monitor --lines 30 --nostream"
32
+
33
+ # ⑤ 调度器日志
34
+ docker exec <容器名> bash -lc "pm2 logs scheduler --lines 20 --nostream"
35
+
36
+ # ⑥ 初始化状态(runtime 装没装完)
37
+ docker exec <容器名> bash -lc "cat ~/baize/init-state.json 2>/dev/null"
38
+ ```
39
+
40
+ **日志判读**:
41
+
42
+ | 日志 | 含义 | 下一步 |
43
+ |---|---|---|
44
+ | `spawnSync codex ENOENT` / `spawnSync claude ENOENT` | **PATH 问题**(可执行文件不在 PATH)→ 见 §3 | §3 |
45
+ | `tmux ls` 无会话 + pm2 正常 | 看门狗没触发或拉起失败 | ④ 看 Guardian 日志 |
46
+ | `init-state.json` 失败 | 初始化未完成(runtime/模型未配置) | web 控制台模型设置重新安装 |
47
+ | tmux 会话在但卡住 | Claude/Codex 等输入 / 认证过期 / API 错误 | `tmux attach -t claude-main` 看输出 |
48
+
49
+ ---
50
+
51
+ ## 3. 本次实战:PATH 污染导致 `spawnSync codex ENOENT`
52
+
53
+ ### 现象
54
+
55
+ ```
56
+ activity-monitor: Guardian: Failed to start Codex: spawnSync codex ENOENT
57
+ scheduler: Waiting for agent runtime (offline or stopped)...
58
+ tmux ls: no server running on /tmp/tmux-1001/default
59
+ ```
60
+
61
+ ### 诊断证据链
62
+
63
+ ```bash
64
+ # ① codex symlink 在不在(npm 全局 bin 通常是 symlink)
65
+ docker exec baize-2 bash -lc "ls -la ~/.npm-global/bin/codex; readlink -f ~/.npm-global/bin/codex"
66
+ # → lrwxrwxrwx ... codex -> ../lib/node_modules/@openai/codex/bin/codex.js (symlink 完好)
67
+
68
+ # ② codex 二进制本身能不能跑(关键——区分"二进制坏了" vs "PATH 找不到")
69
+ docker exec baize-2 bash -lc "~/.npm-global/bin/codex --version; echo EXIT=\$?"
70
+ # → codex-cli 0.153.2 / EXIT=0 (二进制完好!)
71
+
72
+ # ③ pm2 进程实际 PATH(Guardian 用它 spawn codex)
73
+ docker exec baize-2 bash -lc "cat /proc/\$(pgrep -f 'activity-monitor' | head -1)/environ | tr '\0' '\n' | grep '^PATH='"
74
+ # → PATH=...codex-path:/home/baize/baize/bin:...(★ 没有 ~/.npm-global/bin)
75
+
76
+ # ④ 谁污染了 PATH(login shell 的 profile)
77
+ docker exec baize-2 bash -lc "grep -n 'PATH' ~/.bashrc ~/.profile ~/.bash_profile 2>/dev/null"
78
+ # → .bashrc/.profile 里有 export PATH="/home/baize/baize/bin:$PATH"
79
+ ```
80
+
81
+ **判定**:`codex` 二进制完好但 **pm2 进程 PATH 缺 `~/.npm-global/bin`**(codex symlink
82
+ 所在目录)→ `spawnSync codex` 找不到 → ENOENT。
83
+
84
+ ### 根因
85
+
86
+ 1. **PATH 被污染**:`~/.bashrc`/`~/.profile` 前置 `baize/bin` + codex npm 包
87
+ postinstall 前插 vendor 路径,把 `~/.npm-global/bin` 挤掉。
88
+ 2. **坏环境被固化**:在污染 PATH 的 shell 里执行过 `pm2 start`/`pm2 save`,
89
+ pm2 dump 保存了坏环境,之后 pm2 进程一直继承(`docker restart` 若走 pm2
90
+ resurrect 也不会恢复)。
91
+ 3. **谁干的**:使用人在容器内让 AI 执行环境类操作(改 profile / 重装 runtime /
92
+ 重跑 pm2)时污染了环境并固化。
93
+
94
+ ### 修复(干净 PATH + --update-env 覆盖 pm2 固化环境)
95
+
96
+ ```bash
97
+ # 用干净 PATH 重启看门狗;--update-env 强制用当前 shell 环境覆盖 pm2 dump
98
+ docker exec -e PATH=/home/baize/.npm-global/bin:/home/baize/.local/bin:/usr/local/bin:/usr/bin:/bin \
99
+ <容器名> pm2 restart activity-monitor --update-env
100
+
101
+ # 验证:15s 后会话出现、日志不再 ENOENT
102
+ sleep 15
103
+ docker exec <容器名> bash -lc "tmux ls"
104
+ docker exec <容器名> bash -lc "pm2 logs activity-monitor --lines 6 --nostream"
105
+ ```
106
+
107
+ > `docker exec -e PATH=...` 用 `-e` 注入,避免嵌套引号。
108
+ > 其他 pm2 服务(scheduler 等)若同样 PATH 坏,按同法 `--update-env` 重启。
109
+
110
+ ---
111
+
112
+ ## 4. 预防(如何避免再发生)
113
+
114
+ ### 操作纪律(人和 AI 都要遵守)
115
+
116
+ 1. **容器内操作统一走封装入口**:优先用 web 控制台 / `baize` CLI,避免裸
117
+ `docker exec ... bash -lc`(login shell 会加载 profile,触发 PATH 污染)。
118
+ 2. **禁止手工改 `~/.bashrc` / `~/.profile` 的 PATH**:本次污染源头。若必须加
119
+ 路径,用 `export PATH="$PATH:<新路径>"` **追加**,且绝不动
120
+ `~/.npm-global/bin` / `~/.local/bin` 两项。
121
+ 3. **不在容器内手工 `pm2 save`**:pm2 dump 会固化当时的 PATH 环境;容器重启
122
+ 由 entrypoint 统一注入环境(它会重存 `SYSTEM_PATH`)。
123
+ 4. **AI 执行环境修改类操作前先备份**:`cp ~/.bashrc ~/.bashrc.bak`,
124
+ 记录改动 diff,改完验证 `which codex claude baize node` 全部可解析。
125
+
126
+ ### 工程加固(D54 已落地,0.3.17 起)
127
+
128
+ - **PATH 完整性收敛到 init.js**:`baize init` 每次运行修复 shell profile 完整 bin PATH
129
+ (`~/.npm-global/bin` + `.local/bin` + `.claude/bin` + `baize/bin` + `.local/node/bin`),
130
+ 幂等 + 存量缺失自动补。裸机/docker/存量升级全部覆盖(docker entrypoint 每次启动跑 init)。
131
+ - **共享 `path-bins.js`**:所有 runtime 二进制 spawn(codex/claude/baize)统一走
132
+ `completeBinPath()`(子进程 env 补完整 PATH)——登录 shell PATH 污染不再导致 ENOENT。
133
+ - **ecosystem `ENHANCED_PATH` 显式含 `~/.npm-global/bin`**:PM2 服务即使继承残缺 PATH 也能
134
+ 找到 runtime。
135
+ - **Guardian 告警**:连续 3 次启动失败 → 写 `~/baize/activity-monitor/guardian-alert.json`
136
+ + 日志;恢复后自动清除。静默宕机不再发生。
137
+ - **doctor 检查**:`baize doctor` 新增 PATH 完整性组(`path_incomplete` issue)。
138
+ - install.sh / Dockerfile **不改**(收敛到 init.js);`--update-env` 从根源安全化。
139
+
140
+ ### 巡检(可选定时)
141
+
142
+ ```bash
143
+ # 检查 main 会话与 PATH 健康度
144
+ docker exec <容器名> bash -lc "tmux ls"
145
+ docker exec <容器名> bash -lc "which codex claude baize node"
146
+ # 告警文件存在 = 有未恢复的连续失败(无文件=健康)
147
+ docker exec <容器名> bash -lc "cat ~/baize/activity-monitor/guardian-alert.json 2>/dev/null"
148
+ # doctor PATH 组
149
+ docker exec <容器名> bash -lc "baize doctor --json" | python3 -c "import json,sys;print(json.load(sys.stdin)['groups']['path'])"
150
+ ```
151
+
152
+ ---
153
+
154
+ ## 5. 其他常见问题速查
155
+
156
+ | 问题 | 快速处理 |
157
+ |---|---|
158
+ | web 控制台打不开 | `pm2 logs web-console`;D51 起检查 `~/.claude/cli/lib` 是否存在(init 部署缺陷已修复,见 0.3.16) |
159
+ | 会话起了但任务不响应 | `tmux attach -t <claude-main\|codex-main>` 看 AI 输出;`pm2 logs scheduler` |
160
+ | 容器一直 restart | `docker logs <容器名>`(entrypoint 崩溃);检查 `init-state.json` |
161
+ | A2A 端口不通 | `docker exec <容器名> pm2 list` 看 baize-a2a;`ss -tlnp | grep 8443` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baize-ai/core",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "type": "module",
5
5
  "description": "Baize (白泽) — autonomous AI agent infrastructure",
6
6
  "main": "cli/baize.js",
@@ -30,11 +30,21 @@ ACR_NAMESPACE="${ACR_NAMESPACE:-baize01}"
30
30
 
31
31
  # ── Build ────────────────────────────────────────────────────────────────────
32
32
  echo "==> Building baize-core:${VERSION} (from npm @baize-ai/core@${VERSION})"
33
+ # Temp build context: .dockerignore is a whitelist (source stays out of the
34
+ # image), and the optional COPY core.tgz needs a placeholder file present —
35
+ # a temp dir keeps the repo worktree clean.
36
+ BUILD_CTX="$(mktemp -d)"
37
+ trap 'rm -rf "$BUILD_CTX"' EXIT
38
+ cp Dockerfile .dockerignore "$BUILD_CTX"/
39
+ mkdir -p "$BUILD_CTX/templates/pm2" "$BUILD_CTX/docker"
40
+ cp templates/pm2/ecosystem.config.cjs "$BUILD_CTX/templates/pm2/"
41
+ cp docker/entrypoint.sh "$BUILD_CTX/docker/"
42
+ touch "$BUILD_CTX/core.tgz" # placeholder — registry install ignores it (BAZE_CORE_TARBALL empty)
33
43
  docker build \
34
44
  --build-arg BAZE_CORE_VERSION="${VERSION}" \
35
45
  -t "${GHCR_IMAGE}:${VERSION}" \
36
46
  -t "${GHCR_IMAGE}:latest" \
37
- .
47
+ "$BUILD_CTX"
38
48
 
39
49
  if [ -n "${ACR_REGISTRY}" ] && [ -n "${ACR_NAMESPACE}" ]; then
40
50
  ACR_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/baize-core"
@@ -48,6 +48,7 @@ function createDeps(overrides = {}) {
48
48
  nowMs: overrides.nowMs ?? (() => 100_000),
49
49
  initialRuntimeLaunchAtMs: overrides.initialRuntimeLaunchAtMs ?? 0,
50
50
  };
51
+ if (overrides.alert) deps.alert = overrides.alert;
51
52
 
52
53
  return { deps, calls };
53
54
  }
@@ -174,4 +175,54 @@ describe('Guardian', () => {
174
175
  await Promise.resolve();
175
176
  assert.deepEqual(order, ['prepare', 'launch', 'prompt']);
176
177
  });
178
+
179
+ it('raises alert after ALERT_AFTER_RESTARTS consecutive ENOENT launch failures (D54 P2)', async () => {
180
+ const alerts = [];
181
+ const adapter = {
182
+ sessionName: 'test-main',
183
+ displayName: 'TestRuntime',
184
+ runtimeId: 'codex',
185
+ isRunning: async () => false,
186
+ launch: async () => { throw Object.assign(new Error('spawnSync codex ENOENT'), { code: 'ENOENT' }); },
187
+ clearStaleState: () => {},
188
+ enqueueStartupPrompt: () => {},
189
+ };
190
+ const { deps } = createDeps({ alert: (info) => alerts.push(info) });
191
+ const guardian = new Guardian(adapter, deps);
192
+ guardian._maybeAlert('spawnSync codex ENOENT'); // below threshold → no alert
193
+ assert.equal(alerts.length, 0);
194
+
195
+ // simulate 3 consecutive failed launches crossing the threshold
196
+ guardian.consecutiveRestarts = 3;
197
+ guardian._maybeAlert('spawnSync codex ENOENT');
198
+ guardian._maybeAlert('spawnSync codex ENOENT'); // no duplicate
199
+ assert.equal(alerts.length, 1, 'alert must fire exactly once per episode');
200
+ assert.equal(alerts[0].runtime, 'codex');
201
+ assert.equal(alerts[0].consecutive_restarts, 3);
202
+ assert.ok(/ENOENT/.test(alerts[0].reason));
203
+
204
+ // recovery resets alerted → next episode may alert again
205
+ guardian.alerted = false;
206
+ guardian._maybeAlert('again');
207
+ assert.equal(alerts.length, 2, 'alert must fire again after recovery');
208
+ });
209
+
210
+ it('logs a repair hint on ENOENT launch failure (D54 P2)', async () => {
211
+ const adapter = {
212
+ sessionName: 'test-main',
213
+ displayName: 'TestRuntime',
214
+ runtimeId: 'codex',
215
+ isRunning: async () => false,
216
+ launch: async () => { throw Object.assign(new Error('spawnSync codex ENOENT'), { code: 'ENOENT' }); },
217
+ clearStaleState: () => {},
218
+ enqueueStartupPrompt: () => {},
219
+ };
220
+ const { deps, calls } = createDeps({ alert: () => {} });
221
+ const guardian = new Guardian(adapter, deps);
222
+ guardian.consecutiveRestarts = 0;
223
+ await guardian.startAgent();
224
+ await Promise.resolve();
225
+ await Promise.resolve();
226
+ assert.ok(calls.log.some(m => m.includes('ENOENT') && m.includes('baize init')), 'must log the repair hint');
227
+ });
177
228
  });
@@ -1,5 +1,6 @@
1
1
  import { execFileSync, execSync } from 'child_process';
2
2
  import fs from 'fs';
3
+ import path from 'path';
3
4
  import { Guardian } from '../guardian.js';
4
5
  import { HealthEngine } from '../health-engine.js';
5
6
  import { ProcSampler } from '../proc-sampler.js';
@@ -62,6 +63,7 @@ export function createGuardian(activeAdapter, activeToolPipeline, initialRuntime
62
63
  hookStateFile,
63
64
  log,
64
65
  }) {
66
+ const monitorDir = apiActivityFile ? path.dirname(apiActivityFile) : undefined;
65
67
  return new Guardian(activeAdapter, {
66
68
  log,
67
69
  initialRuntimeLaunchAtMs,
@@ -76,6 +78,31 @@ export function createGuardian(activeAdapter, activeToolPipeline, initialRuntime
76
78
  }));
77
79
  fs.writeFileSync(hookStateFile, JSON.stringify({ active_tools: 0 }));
78
80
  },
81
+ // D54 P2: consecutive-failure alert — write a visible file in the monitor
82
+ // dir (alongside agent-status.json) so ops/AI can discover an outage that
83
+ // Guardian keeps retrying. Cleared on recovery via onRecovered.
84
+ alert: (info) => {
85
+ if (!monitorDir) return;
86
+ const alertFile = path.join(monitorDir, 'guardian-alert.json');
87
+ try {
88
+ const tmp = `${alertFile}.tmp.${process.pid}`;
89
+ fs.writeFileSync(tmp, `${JSON.stringify(info, null, 2)}\n`);
90
+ fs.renameSync(tmp, alertFile);
91
+ log(`Guardian: ⚠ ALERT after ${info.consecutive_restarts} consecutive failures — ${info.reason}`);
92
+ } catch (e) {
93
+ log(`Guardian: alert write failed: ${e.message}`);
94
+ }
95
+ },
96
+ onRecovered: () => {
97
+ if (!monitorDir) return;
98
+ const alertFile = path.join(monitorDir, 'guardian-alert.json');
99
+ try {
100
+ if (fs.existsSync(alertFile)) fs.unlinkSync(alertFile);
101
+ log('Guardian: runtime recovered — alert cleared');
102
+ } catch (e) {
103
+ log(`Guardian: alert clear failed: ${e.message}`);
104
+ }
105
+ },
79
106
  });
80
107
  }
81
108
 
@@ -5,6 +5,9 @@ export const MAX_RESTART_DELAY = 60;
5
5
  export const BACKOFF_RESET_THRESHOLD = 60;
6
6
  export const STARTUP_GRACE_TICKS = 30;
7
7
  export const MAINTENANCE_WAIT_TIMEOUT = 300;
8
+ // D54 P2: after this many consecutive launch failures, raise an alert —
9
+ // the 09-13 incident stayed silent for 44 attempts / 48 minutes.
10
+ export const ALERT_AFTER_RESTARTS = 3;
8
11
 
9
12
  export function getRunningMaintenance({ execSyncImpl = defaultExecSync } = {}) {
10
13
  try {
@@ -76,6 +79,8 @@ export class Guardian {
76
79
  execSyncImpl: defaultExecSync,
77
80
  nowMs: () => Date.now(),
78
81
  initialRuntimeLaunchAtMs: 0,
82
+ alert: () => {}, // D54 P2: consecutive-failure alert sink (default no-op)
83
+ onRecovered: () => {}, // D54 P2: clears the alert once the episode ends
79
84
  ...deps,
80
85
  };
81
86
  this.notRunningCount = 0;
@@ -84,6 +89,7 @@ export class Guardian {
84
89
  this.startupGrace = 0;
85
90
  this.startAgentInProgress = false;
86
91
  this.runtimeLaunchAtMs = this.deps.initialRuntimeLaunchAtMs;
92
+ this.alerted = false; // D54 P2: raise alert at most once per outage episode
87
93
  }
88
94
 
89
95
  getState() {
@@ -135,6 +141,8 @@ export class Guardian {
135
141
  } else if (currentTime - this.stableRunningSince >= BACKOFF_RESET_THRESHOLD) {
136
142
  this.consecutiveRestarts = 0;
137
143
  this.stableRunningSince = 0;
144
+ this.alerted = false; // D54 P2: outage episode over — next one may alert again
145
+ try { this.deps.onRecovered(); } catch (e) { this.deps.log(`Guardian: recovery callback failed: ${e.message}`); }
138
146
  }
139
147
  }
140
148
 
@@ -148,6 +156,29 @@ export class Guardian {
148
156
  };
149
157
  }
150
158
 
159
+ /**
160
+ * D54 P2: raise the consecutive-failure alert exactly once per outage
161
+ * episode (threshold ALERT_AFTER_RESTARTS). Sink is injectable — the
162
+ * activity-monitor wires it to a visible status/alert file.
163
+ */
164
+ _maybeAlert(reason) {
165
+ if (this.alerted) return;
166
+ if (this.consecutiveRestarts < ALERT_AFTER_RESTARTS) return;
167
+ this.alerted = true;
168
+ try {
169
+ this.deps.alert({
170
+ agent: this.adapter.displayName,
171
+ runtime: this.adapter.runtimeId,
172
+ consecutive_restarts: this.consecutiveRestarts,
173
+ reason: String(reason || '').slice(0, 500),
174
+ at: this.deps.nowMs(),
175
+ atIso: new Date(this.deps.nowMs()).toISOString(),
176
+ });
177
+ } catch (e) {
178
+ this.deps.log(`Guardian: alert sink failed: ${e.message}`);
179
+ }
180
+ }
181
+
151
182
  _handleNotRunning({ state, message, restartLog }) {
152
183
  if (this.startupGrace > 0) {
153
184
  this.startupGrace -= 1;
@@ -204,6 +235,11 @@ export class Guardian {
204
235
 
205
236
  this.deps.log(`Guardian: Starting ${this.adapter.displayName}...`);
206
237
 
238
+ // D54 P2: alert on consecutive startAgent attempts regardless of WHERE
239
+ // the failure lands (launch throw, or silent tmux-session-with-dead-runtime).
240
+ // Raised here (threshold checked inside) so every retry episode counts.
241
+ this._maybeAlert(`startAgent attempt #${this.consecutiveRestarts}`);
242
+
207
243
  try {
208
244
  this.adapter.clearStaleState?.();
209
245
  } catch { }
@@ -213,7 +249,15 @@ export class Guardian {
213
249
  } catch { }
214
250
 
215
251
  const reportFailure = (err) => {
216
- this.deps.log(`Guardian: Failed to start ${this.adapter.displayName}: ${err.message}`);
252
+ const msg = err?.message || String(err);
253
+ this.deps.log(`Guardian: Failed to start ${this.adapter.displayName}: ${msg}`);
254
+ // D54 P2: an ENOENT on launch means the runtime binary is missing from
255
+ // PATH (or uninstalled) — surface the repair hint instead of silently
256
+ // retrying; then raise an alert once the failure count crosses the bar.
257
+ if (err?.code === 'ENOENT' || /ENOENT/.test(msg)) {
258
+ this.deps.log(`Guardian: runtime binary not found (ENOENT) — run "baize init" to repair PATH/binaries, or reinstall the ${this.adapter.displayName} runtime`);
259
+ }
260
+ this._maybeAlert(msg);
217
261
  };
218
262
  const launchPrepared = () => {
219
263
  try {
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs';
2
+ import path from 'path';
2
3
  import {
3
4
  WATCHDOG_INTERRUPT_AVAILABLE_IN_SEC,
4
5
  evaluateToolWatchdogTransition,
@@ -51,7 +52,13 @@ export class MonitorOrchestrator {
51
52
  const runtimeLaunchAtMs = Number(initialStatus.runtime_launch_at) || nowMs();
52
53
 
53
54
  const engine = createHealthEngine(adapter, initialStatus);
54
- const guardian = createGuardian(adapter, toolPipeline, runtimeLaunchAtMs);
55
+ // D54 P2: pass the alert sink wiring (apiActivityFile/hookStateFile/log)
56
+ // so Guardian's consecutive-failure alert writes guardian-alert.json.
57
+ const guardian = createGuardian(adapter, toolPipeline, runtimeLaunchAtMs, {
58
+ apiActivityFile: path.join(monitorDir, 'api-activity.json'),
59
+ hookStateFile: path.join(monitorDir, 'hook-state.json'),
60
+ log,
61
+ });
55
62
 
56
63
  if (initialHealth === 'rate_limited' && initialStatus.cooldown_until) {
57
64
  engine.enterRateLimited(initialStatus.cooldown_until, initialStatus.rate_limit_reset || '');
@@ -10,6 +10,7 @@ import { execFileSync } from 'child_process';
10
10
  import fs from 'fs';
11
11
  import path from 'path';
12
12
  import os from 'os';
13
+ import { completeBinPath } from '../../../cli/lib/path-bins.js';
13
14
 
14
15
  const BAIZE_DIR = process.env.BAIZE_DIR || path.join(os.homedir(), 'baize');
15
16
  const MONITOR_DIR = path.join(BAIZE_DIR, 'activity-monitor');
@@ -93,7 +94,8 @@ function main() {
93
94
  // Check baize-core
94
95
  try {
95
96
  const coreVersion = execFileSync('baize', ['--version'], {
96
- encoding: 'utf8', stdio: 'pipe', timeout: 5000
97
+ encoding: 'utf8', stdio: 'pipe', timeout: 5000,
98
+ env: { ...process.env, PATH: completeBinPath() },
97
99
  }).trim();
98
100
  const result = getLatestTag('baize-ai/baize-core');
99
101
  if (result.error) {
@@ -28,11 +28,12 @@ function readEnvValue(key, defaultValue = '') {
28
28
  return defaultValue;
29
29
  }
30
30
 
31
- // Build PATH: Claude locations + user's full shell PATH + PM2's own PATH
32
- // Deduplicate to prevent PATH bloat across PM2 restarts each restart
33
- // re-evaluates this file with process.env.PATH already containing the
34
- // previous ENHANCED_PATH, which would otherwise compound indefinitely.
31
+ // Build PATH: npm global bin + Claude locations + user's full shell PATH +
32
+ // PM2's own PATH. D54: ~/.npm-global/bin is added EXPLICITLY (independent of
33
+ // SYSTEM_PATH / process.env.PATH) codex/baize live there; a polluted login
34
+ // shell PATH must never hide them from PM2 services.
35
35
  const ENHANCED_PATH = [...new Set([
36
+ path.join(HOME, '.npm-global', 'bin'),
36
37
  path.join(HOME, '.local', 'bin'),
37
38
  path.join(HOME, '.claude', 'bin'),
38
39
  ...(readEnvValue('SYSTEM_PATH') || '').split(':').filter(Boolean),