@baize-ai/core 0.3.5 → 0.3.7

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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ 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.7] - 2026-08-26
9
+
10
+ ### Fixed
11
+ - Codex 配置链路(D31):web 控制台激活供应商现在同时写全局与项目级 `config.toml`(model + openai_base_url)——此前只写全局,baize init 回填的项目级默认 `gpt-5.5` 会压制用户配置(codex 键级合并,项目级优先)→ 大陆 DeepSeek 场景会话秒退
12
+ - `baize init` 的项目级 codex config 回填改为继承全局 model(无全局时才用 gpt-5.5 兜底)——升级不再覆盖用户配置
13
+ - `baize upgrade --self` 改为 npm registry 路径(`npm view` / `npm pack`,跟随用户 registry——大陆 npmmirror 可达);`--beta` 保留预发布通道(dist-tags/versions 选最高 prerelease);npm 返回值严格 semver 校验
14
+
15
+ ## [0.3.6] - 2026-08-26
16
+
17
+ ### Added
18
+ - web-console 诊断卡(模型设置页):服务状态 / agent 会话(含卡登录提示)/ 运行时与认证 / 最近错误——浏览器内排查,无需 SSH
19
+
8
20
  ## [0.3.5] - 2026-08-26
9
21
 
10
22
  ### Fixed
@@ -93,6 +93,27 @@ describe('renderCodexProjectConfig', () => {
93
93
  assert.doesNotMatch(content, /^model = "gpt-5\.5"$/m);
94
94
  assert.doesNotMatch(content, /^model_reasoning_effort = "medium"$/m);
95
95
  });
96
+ it('backfills the global codex model when the project config has none (D31)', () => {
97
+ fs.mkdirSync(path.join(fakeHome, '.codex'), { recursive: true });
98
+ fs.writeFileSync(
99
+ path.join(fakeHome, '.codex', 'config.toml'),
100
+ 'model = "deepseek-v4-flash"\nopenai_base_url = "https://api.deepseek.com"\n',
101
+ 'utf8',
102
+ );
103
+
104
+ const content = renderCodexProjectConfig('');
105
+
106
+ assert.match(content, /^model = "deepseek-v4-flash"$/m);
107
+ assert.doesNotMatch(content, /^model = "gpt-5\.5"$/m);
108
+ });
109
+
110
+ it('falls back to the gpt-5.5 default when no global model exists (D31)', () => {
111
+ fs.rmSync(path.join(fakeHome, '.codex', 'config.toml'), { force: true });
112
+
113
+ const content = renderCodexProjectConfig('');
114
+
115
+ assert.match(content, /^model = "gpt-5\.5"$/m);
116
+ });
96
117
 
97
118
  it('replaces baize-owned notice sections exactly without touching dotted siblings', () => {
98
119
  const existing = [
@@ -347,6 +347,21 @@ function isTomlSectionValue(value) {
347
347
  return value && typeof value === 'object' && !Array.isArray(value);
348
348
  }
349
349
 
350
+ /**
351
+ * Read the user's global Codex model (~/.codex/config.toml `model`), if any.
352
+ * Used so project-level config backfill inherits the user's provider model
353
+ * instead of shadowing it with a hard-coded default (D31).
354
+ */
355
+ function readGlobalCodexModel(homeDir = os.homedir()) {
356
+ try {
357
+ const globalPath = path.join(homeDir, '.codex', 'config.toml');
358
+ const parsed = parseCodexToml(fs.readFileSync(globalPath, 'utf8'));
359
+ return typeof parsed.model === 'string' && parsed.model ? parsed.model : null;
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
350
365
  /**
351
366
  * Render project-level .codex/config.toml with headless configuration.
352
367
  *
@@ -359,15 +374,19 @@ function isTomlSectionValue(value) {
359
374
  * @param {string} existingContent - Existing project config.toml contents (optional)
360
375
  * @returns {string}
361
376
  */
362
- export function renderCodexProjectConfig(existingContent = '') {
377
+ export function renderCodexProjectConfig(existingContent = '', opts = {}) {
363
378
  const obj = parseCodexToml(existingContent);
364
379
 
365
380
  // Always overwrite: these values are required for unattended Baize runtime behavior.
366
381
  obj.check_for_update_on_startup = false;
367
382
  obj.model_availability_nux = 'gpt-5.4';
368
383
 
369
- // Backfill: default only when the user has not configured a value.
370
- if (obj.model === undefined) obj.model = 'gpt-5.5';
384
+ // Backfill: default only when the user has not configured a value. Inherit
385
+ // the global codex model when present (web-console provider activation writes
386
+ // it there) — a hard-coded default would shadow the user's provider model.
387
+ if (obj.model === undefined) {
388
+ obj.model = readGlobalCodexModel(opts.homeDir) || 'gpt-5.5';
389
+ }
371
390
  if (obj.model_reasoning_effort === undefined) obj.model_reasoning_effort = 'medium';
372
391
 
373
392
  obj.features = isTomlSectionValue(obj.features) ? obj.features : {};
@@ -9,9 +9,9 @@ import path from 'node:path';
9
9
  import os from 'node:os';
10
10
  import { execSync, spawnSync } from 'node:child_process';
11
11
  import { SKILLS_DIR, BAIZE_DIR, getBaizeConfig } from './config.js';
12
- import { downloadArchive, downloadBranch } from './download.js';
12
+ import { downloadBranch } from './download.js';
13
13
  import { generateManifest, saveMergeBaseline } from './manifest.js';
14
- import { fetchRawFile, fetchLatestTag, compareSemverDesc, sanitizeError } from './github.js';
14
+ import { fetchRawFile, compareSemverDesc, sanitizeError } from './github.js';
15
15
  import { copyTree, syncTree } from './fs-utils.js';
16
16
  import { getCommandHooks, hookScriptKey } from './hook-utils.js';
17
17
  import { isCoreManaged } from './sync-settings-hooks.js';
@@ -67,15 +67,43 @@ function getLatestVersion({ branch, beta = false } = {}) {
67
67
  }
68
68
  }
69
69
 
70
- // Default: tag-based detection (unified with component upgrades)
70
+ // Default: npm registry — follows the user's npm config (npmmirror in CN),
71
+ // which GitHub-based tag detection cannot reach reliably (D31).
71
72
  try {
72
- const tagVersion = fetchLatestTag(REPO, { includePrerelease: beta });
73
- if (tagVersion) {
74
- return { success: true, version: tagVersion };
73
+ // Strict semver anchor (never interpolate unvalidated registry output into
74
+ // a shell command or semver comparison).
75
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
76
+ if (beta) {
77
+ // Prerelease channel: prefer the highest non-latest dist-tag, falling
78
+ // back to the highest prerelease in the full versions list.
79
+ const tagsOut = execSync('npm view @baize-ai/core dist-tags --json', {
80
+ encoding: 'utf8', timeout: 20000, stdio: 'pipe',
81
+ });
82
+ const tags = JSON.parse(tagsOut);
83
+ const prereleaseTags = Object.entries(tags)
84
+ .filter(([tag, v]) => tag !== 'latest' && typeof v === 'string' && SEMVER_RE.test(v) && v.includes('-'))
85
+ .map(([, v]) => v);
86
+ if (prereleaseTags.length > 0) {
87
+ return { success: true, version: prereleaseTags.sort(compareSemverDesc)[0] };
88
+ }
89
+ const versionsOut = execSync('npm view @baize-ai/core versions --json', {
90
+ encoding: 'utf8', timeout: 20000, stdio: 'pipe',
91
+ });
92
+ const prereleases = JSON.parse(versionsOut)
93
+ .filter((v) => typeof v === 'string' && SEMVER_RE.test(v) && v.includes('-'));
94
+ if (prereleases.length === 0) {
95
+ return { success: false, error: 'No prerelease versions found in npm registry' };
96
+ }
97
+ return { success: true, version: prereleases.sort(compareSemverDesc)[0] };
98
+ }
99
+ const out = execSync('npm view @baize-ai/core version', { encoding: 'utf8', timeout: 20000, stdio: 'pipe' });
100
+ const version = out.trim();
101
+ if (!SEMVER_RE.test(version)) {
102
+ return { success: false, error: `Unexpected npm version output: ${version}` };
75
103
  }
76
- return { success: false, error: 'No release tags found' };
104
+ return { success: true, version };
77
105
  } catch (err) {
78
- return { success: false, error: `Cannot fetch latest version: ${sanitizeError(err.message)}` };
106
+ return { success: false, error: `Cannot fetch latest version from npm registry: ${sanitizeError(err.message)}` };
79
107
  }
80
108
  }
81
109
 
@@ -150,17 +178,25 @@ export function downloadCoreToTemp(version, branch) {
150
178
  return { success: true, tempDir };
151
179
  }
152
180
 
153
- const result = downloadArchive(REPO, version, tempDir);
154
- if (!result.success) {
155
- // Fallback: try downloading main branch (for pre-release versions without tags)
156
- const branchResult = downloadBranch(REPO, 'main', tempDir);
157
- if (!branchResult.success) {
158
- fs.rmSync(tempDir, { recursive: true, force: true });
159
- return { success: false, error: result.error };
181
+ // Default: npm registry tarball (npm pack), extracted to the temp root so
182
+ // downstream steps see the source-tree layout (package.json at root).
183
+ try {
184
+ execSync(`npm pack @baize-ai/core@${version} --pack-destination "${tempDir}"`, {
185
+ encoding: 'utf8', timeout: 120000, stdio: 'pipe',
186
+ });
187
+ const tgz = fs.readdirSync(tempDir).find((f) => f.endsWith('.tgz'));
188
+ if (!tgz) throw new Error('npm pack produced no tarball');
189
+ execSync(`tar -xzf "${path.join(tempDir, tgz)}" -C "${tempDir}"`, { stdio: 'pipe' });
190
+ const pkgDir = path.join(tempDir, 'package');
191
+ for (const entry of fs.readdirSync(pkgDir)) {
192
+ fs.renameSync(path.join(pkgDir, entry), path.join(tempDir, entry));
160
193
  }
194
+ fs.rmSync(pkgDir, { recursive: true, force: true });
195
+ return { success: true, tempDir };
196
+ } catch (err) {
197
+ fs.rmSync(tempDir, { recursive: true, force: true });
198
+ return { success: false, error: `npm registry download failed: ${sanitizeError(err.message)}` };
161
199
  }
162
-
163
- return { success: true, tempDir };
164
200
  }
165
201
 
166
202
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baize-ai/core",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "description": "Baize (\u767d\u6cfd) \u2014 autonomous AI agent infrastructure",
6
6
  "main": "cli/baize.js",
@@ -2297,6 +2297,62 @@ async function submitA2aTaskForm(basePath) {
2297
2297
  }
2298
2298
  }
2299
2299
 
2300
+ /** Diagnostics: service/session/auth/errors rendered from /api/admin/diagnostics. */
2301
+ async function renderDiagnostics(basePath) {
2302
+ const runtimeEl = document.getElementById('diag-runtime');
2303
+ const servicesEl = document.getElementById('diag-services');
2304
+ const sessionsEl = document.getElementById('diag-sessions');
2305
+ const errorsEl = document.getElementById('diag-errors');
2306
+ const stateEl = document.getElementById('diag-runtime-state');
2307
+ if (!servicesEl) return;
2308
+ runtimeEl.textContent = '加载中...';
2309
+ let body;
2310
+ try {
2311
+ const r = await adminFetch(basePath, '/api/admin/diagnostics');
2312
+ if (r.status !== 200) throw new Error(r.body?.error || '加载失败');
2313
+ body = r.body;
2314
+ } catch (err) {
2315
+ runtimeEl.textContent = `加载失败:${escapeHtml(err.message)}`;
2316
+ return;
2317
+ }
2318
+ const rt = body.runtime || {};
2319
+ if (stateEl) stateEl.textContent = `runtime: ${escapeHtml(rt.runtime || '?')}`;
2320
+ // runtime + auth
2321
+ const auth = body.auth || {};
2322
+ const authLine = [];
2323
+ if (auth.claude?.configured) authLine.push(`Claude: ${escapeHtml(auth.claude.method)} (${escapeHtml(auth.claude.masked || '')})`);
2324
+ else authLine.push('Claude: 未配置');
2325
+ if (auth.codex?.configured) authLine.push(`Codex: ${escapeHtml(auth.codex.method)} (${escapeHtml(auth.codex.masked || '')})`);
2326
+ else authLine.push('Codex: 未配置');
2327
+ runtimeEl.innerHTML = `<strong>${escapeHtml(rt.runtime || 'claude')}</strong> · ${authLine.join(' · ')}`;
2328
+ // services
2329
+ const svcs = body.services || [];
2330
+ if (!svcs.length) {
2331
+ servicesEl.textContent = 'PM2 未运行或无服务(容器/服务未启动)';
2332
+ } else {
2333
+ servicesEl.innerHTML = '<table class="tasks-table"><thead><tr><th>服务</th><th>状态</th><th>重启</th><th>运行</th></tr></thead><tbody>'
2334
+ + svcs.map((s) => `<tr><td>${escapeHtml(s.name)}</td><td><span class="channel-state ${s.status === 'online' ? 'ok' : 'warn'}">${escapeHtml(s.status)}</span></td><td>${s.restarts}</td><td>${s.uptime != null ? Math.round(s.uptime / 60) + 'm' : '—'}</td></tr>`).join('')
2335
+ + '</tbody></table>';
2336
+ }
2337
+ // sessions
2338
+ const sessions = body.sessions || [];
2339
+ if (!sessions.length) {
2340
+ sessionsEl.innerHTML = '<span class="cred-state">无 agent 会话(tmux 未运行或未拉起)</span>';
2341
+ } else {
2342
+ sessionsEl.innerHTML = sessions.map((s) => {
2343
+ const stuck = /(sign in|login|authenticate)/i.test(s.tail || '') ? '<span class="channel-state warn">⚠ 可能卡在登录</span>' : '<span class="channel-state ok">运行中</span>';
2344
+ return `<div style="margin-bottom:8px"><strong>${escapeHtml(s.name)}</strong> ${stuck}<pre style="font-size:12px;background:var(--bg-2,#1a1a1a);padding:8px;border-radius:6px;white-space:pre-wrap">${escapeHtml(s.tail || '(空)')}</pre></div>`;
2345
+ }).join('');
2346
+ }
2347
+ // errors
2348
+ const errors = body.errors || [];
2349
+ if (!errors.length) {
2350
+ errorsEl.textContent = '无错误日志';
2351
+ } else {
2352
+ errorsEl.innerHTML = errors.map((e) => `<div style="margin-bottom:8px"><strong>${escapeHtml(e.service)}</strong><pre style="font-size:12px;background:var(--bg-2,#1a1a1a);padding:8px;border-radius:6px;white-space:pre-wrap;color:var(--danger,#e5534b)">${escapeHtml(e.tail)}</pre></div>`).join('');
2353
+ }
2354
+ }
2355
+
2300
2356
  function showAppView(basePath, view) {
2301
2357
  const chatView = document.getElementById('chat-view');
2302
2358
  const modelView = document.getElementById('model-view');
@@ -2323,6 +2379,7 @@ function showAppView(basePath, view) {
2323
2379
  a2aView.hidden = true;
2324
2380
  schedulerView.hidden = true;
2325
2381
  modelView.hidden = false;
2382
+ renderDiagnostics(basePath);
2326
2383
  setNav(navModel, [navChat, navChannels, navA2a, navScheduler]);
2327
2384
  renderModelSettings(basePath);
2328
2385
  } else if (view === 'channels') {
@@ -2518,6 +2575,7 @@ function initViews(basePath) {
2518
2575
  document.getElementById('nav-channels').addEventListener('click', () => showAppView(basePath, 'channels'));
2519
2576
  document.getElementById('nav-a2a').addEventListener('click', () => showAppView(basePath, 'a2a'));
2520
2577
  document.getElementById('nav-scheduler').addEventListener('click', () => showAppView(basePath, 'scheduler'));
2578
+ document.getElementById('btn-diag-refresh').addEventListener('click', () => renderDiagnostics(basePath));
2521
2579
 
2522
2580
  // D8 two-column layout: Claude official = setup-token form; custom API =
2523
2581
  // per-column quick forms. The standalone api-key forms were removed.
@@ -231,6 +231,28 @@
231
231
  </div>
232
232
  </section>
233
233
  </div>
234
+ <section class="settings-section">
235
+ <div class="settings-section-head">
236
+ <h2>诊断</h2>
237
+ <p>服务 / 会话 / 认证 / 最近错误——浏览器内排查,无需 SSH。</p>
238
+ </div>
239
+ <div class="settings-card">
240
+ <div class="card-title">运行时与认证 <span class="channel-state" id="diag-runtime-state"></span></div>
241
+ <div class="cred-state" id="diag-runtime" aria-live="polite">加载中...</div>
242
+ </div>
243
+ <div class="settings-card">
244
+ <div class="card-title">服务状态 <button type="button" class="small-btn" id="btn-diag-refresh">刷新</button></div>
245
+ <div class="cred-state" id="diag-services" aria-live="polite">加载中...</div>
246
+ </div>
247
+ <div class="settings-card">
248
+ <div class="card-title">Agent 会话</div>
249
+ <div class="cred-state" id="diag-sessions" aria-live="polite">加载中...</div>
250
+ </div>
251
+ <div class="settings-card">
252
+ <div class="card-title">最近错误</div>
253
+ <div class="cred-state" id="diag-errors" aria-live="polite">加载中...</div>
254
+ </div>
255
+ </section>
234
256
  </main>
235
257
 
236
258
  <main class="settings-view" id="channels-view" hidden>
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Diagnostics for the web console (D26): aggregate service/session/runtime
3
+ * state so operators can troubleshoot from the browser without SSH.
4
+ *
5
+ * readDiagnostics() returns:
6
+ * services[] — PM2 processes (name, status, restarts, uptime)
7
+ * sessions[] — tmux agent sessions (name, exists, last pane lines)
8
+ * runtime — active runtime (claude/codex) + CLI presence
9
+ * auth — claude/codex credential state (masked, never echoed)
10
+ * errors — tail of each service error log (last lines)
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { execFile } from 'node:child_process';
17
+ import { promisify } from 'node:util';
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ function baizeDir() {
22
+ return process.env.BAIZE_DIR || path.join(os.homedir(), 'baize');
23
+ }
24
+
25
+ function readJsonSafe(file) {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function maskSecret(secret) {
34
+ if (!secret || typeof secret !== 'string') return null;
35
+ if (secret.length <= 8) return '****';
36
+ return `${secret.slice(0, 4)}••••${secret.slice(-4)}`;
37
+ }
38
+
39
+ /** PM2 process table via `pm2 jlist` (tolerant when pm2 is absent). */
40
+ async function readServices() {
41
+ try {
42
+ const { stdout } = await execFileAsync('pm2', ['jlist'], { timeout: 8000, maxBuffer: 4 * 1024 * 1024 });
43
+ const list = JSON.parse(stdout);
44
+ return list.map((p) => ({
45
+ name: p.name,
46
+ status: p.pm2_env?.status || 'unknown',
47
+ restarts: p.pm2_env?.restart_time ?? 0,
48
+ uptime: p.pm2_env?.pm_uptime ? Math.round((Date.now() - p.pm2_env.pm_uptime) / 1000) : null,
49
+ cpu: p.monit?.cpu ?? null,
50
+ memory: p.monit?.memory ?? null,
51
+ }));
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /** tmux agent sessions: existence + last pane lines (for stuck-login detection). */
58
+ async function readSessions() {
59
+ const sessions = [];
60
+ try {
61
+ const { stdout } = await execFileAsync('tmux', ['ls'], { timeout: 5000 });
62
+ const names = stdout.split('\n').map((l) => l.split(':')[0]).filter(Boolean);
63
+ for (const name of names) {
64
+ if (!/(main|agent)/.test(name)) continue;
65
+ let tail = '';
66
+ try {
67
+ const pane = await execFileAsync('tmux', ['capture-pane', '-t', name, '-p', '-S', '-12'], { timeout: 5000 });
68
+ tail = pane.stdout.split('\n').filter((l) => l.trim()).slice(-8).join('\n').slice(0, 600);
69
+ } catch { /* pane read failed */ }
70
+ sessions.push({ name, tail });
71
+ }
72
+ } catch {
73
+ /* no tmux or no sessions */
74
+ }
75
+ return sessions;
76
+ }
77
+
78
+ /** Active runtime from ~/baize/.baize/config.json. */
79
+ function readRuntime() {
80
+ const cfg = readJsonSafe(path.join(baizeDir(), '.baize', 'config.json')) || {};
81
+ return { runtime: cfg.runtime || 'claude', configPresent: !!cfg };
82
+ }
83
+
84
+ /** Credential state (masked). Never echoes secrets. */
85
+ function readAuth() {
86
+ const out = { claude: { configured: false, method: null, masked: null }, codex: { configured: false, method: null, masked: null } };
87
+ // Claude: ~/.claude/settings.json (ANTHROPIC_API_KEY) or env
88
+ try {
89
+ const settings = readJsonSafe(path.join(os.homedir(), '.claude', 'settings.json')) || {};
90
+ const key = settings.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY;
91
+ if (key) { out.claude.configured = true; out.claude.method = 'api-key'; out.claude.masked = maskSecret(key); }
92
+ } catch { /* ignore */ }
93
+ // Codex: ~/.codex/auth.json (auth_mode apikey/chatgpt)
94
+ const codexAuth = readJsonSafe(path.join(os.homedir(), '.codex', 'auth.json'));
95
+ if (codexAuth) {
96
+ if (codexAuth.auth_mode === 'apikey' && codexAuth.OPENAI_API_KEY) {
97
+ out.codex.configured = true; out.codex.method = 'api-key'; out.codex.masked = maskSecret(codexAuth.OPENAI_API_KEY);
98
+ } else if (codexAuth.tokens?.access_token) {
99
+ out.codex.configured = true; out.codex.method = 'chatgpt-oauth';
100
+ }
101
+ }
102
+ const envKey = process.env.OPENAI_API_KEY;
103
+ if (envKey && !out.codex.configured) { out.codex.configured = true; out.codex.method = 'env'; out.codex.masked = maskSecret(envKey); }
104
+ return out;
105
+ }
106
+
107
+ /** Tail of each service error log under ~/.pm2/logs/. */
108
+ function readErrors() {
109
+ const logsDir = path.join(os.homedir(), '.pm2', 'logs');
110
+ const errors = [];
111
+ try {
112
+ for (const file of fs.readdirSync(logsDir)) {
113
+ if (!/error.*\.log$/.test(file)) continue;
114
+ const full = path.join(logsDir, file);
115
+ try {
116
+ const size = fs.statSync(full).size;
117
+ const fd = fs.openSync(full, 'r');
118
+ const buf = Buffer.alloc(Math.min(size, 3000));
119
+ fs.readSync(fd, buf, 0, buf.length, Math.max(0, size - buf.length));
120
+ fs.closeSync(fd);
121
+ const tail = buf.toString('utf8').split('\n').filter((l) => l.trim()).slice(-6).join('\n');
122
+ if (tail) errors.push({ service: file.replace(/-(error|out)\.log$/, ''), tail: tail.slice(0, 500) });
123
+ } catch { /* unreadable log */ }
124
+ }
125
+ } catch { /* no logs dir */ }
126
+ return errors;
127
+ }
128
+
129
+ /** Full diagnostics payload. */
130
+ export async function readDiagnostics() {
131
+ const [services, sessions] = await Promise.all([readServices(), readSessions()]);
132
+ return {
133
+ success: true,
134
+ at: new Date().toISOString(),
135
+ runtime: readRuntime(),
136
+ services,
137
+ sessions,
138
+ auth: readAuth(),
139
+ errors: readErrors(),
140
+ };
141
+ }
@@ -45,6 +45,13 @@ function claudeSettingsPath() {
45
45
  function codexConfigPath() {
46
46
  return path.join(process.env.HOME || '', '.codex', 'config.toml');
47
47
  }
48
+ // Project-level Codex config — the file codex actually prefers (project-level
49
+ // keys win over global ones). Web activation must write here too, or the
50
+ // project-level defaults baize init backfills (e.g. model = "gpt-5.5") would
51
+ // shadow the user's provider model/base_url (D31).
52
+ function projectCodexConfigPath() {
53
+ return path.join(process.env.BAIZE_DIR || path.join(process.env.HOME || '', 'baize'), '.codex', 'config.toml');
54
+ }
48
55
 
49
56
  function loadStore() {
50
57
  const data = readJson(providersPath()) || {};
@@ -319,17 +326,17 @@ function applyCodexOfficial() {
319
326
  }
320
327
 
321
328
  /**
322
- * Conservative top-level edit of ~/.codex/config.toml: set/replace the
329
+ * Conservative top-level edit of a Codex config.toml: set/replace the
323
330
  * `model` and `openai_base_url` keys, preserving every other line (comments,
324
331
  * sections, providers). Only the TOP-LEVEL prefix (before the first section
325
332
  * header) is touched — keys inside [table] sections are also unindented, so
326
333
  * regex edits must never run past the first `[` header.
327
334
  */
328
- export function editCodexConfigToml({ model, openaiBaseUrl }) {
329
- const codexDir = path.dirname(codexConfigPath());
330
- fs.mkdirSync(codexDir, { recursive: true });
335
+ function writeCodexTomlTopLevel(targetPath, { model, openaiBaseUrl }) {
336
+ const dir = path.dirname(targetPath);
337
+ fs.mkdirSync(dir, { recursive: true });
331
338
  let content = '';
332
- try { content = fs.readFileSync(codexConfigPath(), 'utf8'); } catch { /* new file */ }
339
+ try { content = fs.readFileSync(targetPath, 'utf8'); } catch { /* new file */ }
333
340
 
334
341
  const sectionAt = content.search(/^\[/m);
335
342
  const head = sectionAt === -1 ? content : content.slice(0, sectionAt);
@@ -348,7 +355,19 @@ export function editCodexConfigToml({ model, openaiBaseUrl }) {
348
355
  if (openaiBaseUrl) out = setTopLevel(out, 'openai_base_url', openaiBaseUrl);
349
356
  if (openaiBaseUrl === null) out = removeTopLevel(out, 'openai_base_url');
350
357
  const joined = tail && !out.endsWith('\n') ? `${out}\n${tail}` : `${out}${tail}`;
351
- fs.writeFileSync(codexConfigPath(), joined, 'utf8');
358
+ fs.writeFileSync(targetPath, joined, 'utf8');
359
+ return targetPath;
360
+ }
361
+
362
+ /**
363
+ * Apply a provider's Codex model/base_url to BOTH the global config.toml and
364
+ * the baize project-level config.toml. codex prefers project-level keys, so
365
+ * writing only the global file let baize init's backfilled defaults (model =
366
+ * "gpt-5.5") shadow the provider config (D31).
367
+ */
368
+ export function editCodexConfigToml({ model, openaiBaseUrl }) {
369
+ writeCodexTomlTopLevel(codexConfigPath(), { model, openaiBaseUrl });
370
+ writeCodexTomlTopLevel(projectCodexConfigPath(), { model, openaiBaseUrl });
352
371
  return codexConfigPath();
353
372
  }
354
373
 
@@ -77,6 +77,7 @@ import {
77
77
  getSchedulerTasks,
78
78
  } from './a2a-admin.js';
79
79
  import { listInstalledSkills } from './skill-catalog.js';
80
+ import { readDiagnostics } from './diagnostics.js';
80
81
 
81
82
  const __filename = fileURLToPath(import.meta.url);
82
83
  const __dirname = path.dirname(__filename);
@@ -825,6 +826,14 @@ app.post('/api/admin/codex-key', async (req, res) => {
825
826
  }
826
827
  });
827
828
 
829
+ app.get('/api/admin/diagnostics', async (req, res) => {
830
+ try {
831
+ res.json(await readDiagnostics());
832
+ } catch (err) {
833
+ jsonError(res, err);
834
+ }
835
+ });
836
+
828
837
  app.post('/api/admin/runtime', async (req, res) => {
829
838
  try {
830
839
  const result = await switchRuntime(req.body?.runtime);