@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
@@ -162,9 +162,55 @@ describe('renderCodexGlobalConfig', () => {
162
162
  assert.doesNotMatch(content, /check_for_update_on_startup/);
163
163
  });
164
164
 
165
- it('includes openai_base_url when provided', () => {
165
+ it('never writes openai_base_url legacy opt only cleans the override (D51)', () => {
166
166
  const content = renderCodexGlobalConfig('/home/user/baize', '', { openaiBaseUrl: 'https://proxy.example.com/v1' });
167
- assert.match(content, /openai_base_url = "https:\/\/proxy\.example\.com\/v1"/);
167
+ assert.doesNotMatch(content, /openai_base_url/);
168
+ });
169
+
170
+ it('renders a dedicated provider block from opts.codex (D51)', () => {
171
+ const content = renderCodexGlobalConfig('/home/user/baize', '', {
172
+ codex: {
173
+ providerKey: 'deepseek',
174
+ baseUrl: 'https://api.deepseek.com/',
175
+ token: 'sk-test',
176
+ model: 'deepseek-v4-flash',
177
+ },
178
+ });
179
+ assert.match(content, /^model = "deepseek-v4-flash"$/m);
180
+ assert.match(content, /^model_provider = "deepseek"$/m);
181
+ assert.match(content, /\[model_providers\.deepseek\]/);
182
+ assert.match(content, /name = "deepseek"/);
183
+ assert.match(content, /base_url = "https:\/\/api\.deepseek\.com\/"/);
184
+ assert.match(content, /wire_api = "responses"/);
185
+ assert.match(content, /experimental_bearer_token = "sk-test"/);
186
+ assert.doesNotMatch(content, /openai_base_url/);
187
+ });
188
+
189
+ it('provider block omits the token key when no token is given (D51)', () => {
190
+ const content = renderCodexGlobalConfig('/home/user/baize', '', {
191
+ codex: { providerKey: 'deepseek', baseUrl: 'https://api.deepseek.com/', model: 'deepseek-v4-flash' },
192
+ });
193
+ assert.match(content, /\[model_providers\.deepseek\]/);
194
+ assert.doesNotMatch(content, /experimental_bearer_token/);
195
+ });
196
+
197
+ it('provider block replaces the legacy override and keeps other provider tables (D51)', () => {
198
+ const existing = [
199
+ 'openai_base_url = "https://old.example.com/v1"',
200
+ 'model = "old"',
201
+ '',
202
+ '[model_providers.userown]',
203
+ 'name = "userown"',
204
+ 'base_url = "https://u.example"',
205
+ '',
206
+ ].join('\n');
207
+ const content = renderCodexGlobalConfig('/home/user/baize', existing, {
208
+ codex: { providerKey: 'deepseek', baseUrl: 'https://api.deepseek.com/' },
209
+ });
210
+ assert.doesNotMatch(content, /openai_base_url/);
211
+ assert.match(content, /^model = "old"$/m, 'model kept when opts.codex.model absent');
212
+ assert.match(content, /\[model_providers\.userown\]/, 'user tables survive');
213
+ assert.match(content, /\[model_providers\.deepseek\]/);
168
214
  });
169
215
 
170
216
  it('preserves unknown global top-level keys, sections, and unrelated projects', () => {
@@ -189,22 +235,23 @@ describe('renderCodexGlobalConfig', () => {
189
235
  assert.match(content, /\[projects\."\/home\/user\/baize"\]\ntrust_level = "trusted"/);
190
236
  });
191
237
 
192
- it('preserves existing openai_base_url when baize has no value', () => {
238
+ it('strips a stale legacy openai_base_url even without opts (D51 migration)', () => {
193
239
  const existing = 'openai_base_url = "https://user-proxy.example.com/v1"\n';
194
240
  const content = renderCodexGlobalConfig('/home/user/baize', existing);
195
- assert.match(content, /openai_base_url = "https:\/\/user-proxy\.example\.com\/v1"/);
241
+ assert.doesNotMatch(content, /openai_base_url/);
196
242
  });
197
243
 
198
- it('overwrites existing openai_base_url when baize has a value', () => {
244
+ it('cleans the legacy openai_base_url when the legacy opt is passed (D51)', () => {
199
245
  const existing = 'openai_base_url = "https://old-proxy.example.com/v1"\n';
200
246
  const content = renderCodexGlobalConfig('/home/user/baize', existing, {
201
247
  openaiBaseUrl: 'https://new-proxy.example.com/v1',
202
248
  });
203
- assert.match(content, /openai_base_url = "https:\/\/new-proxy\.example\.com\/v1"/);
249
+ assert.doesNotMatch(content, /openai_base_url/);
204
250
  assert.doesNotMatch(content, /old-proxy/);
205
251
  });
206
252
  });
207
253
 
254
+
208
255
  describe('writeCodexConfig', () => {
209
256
  it('writes project-level config and global config to separate locations', () => {
210
257
  const globalConfigPath = path.join(fakeHome, '.codex', 'config.toml');
@@ -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';
@@ -55,10 +56,27 @@ function getCodexApiBaseUrl() {
55
56
  try {
56
57
  const configPath = path.join(os.homedir(), '.codex', 'config.toml');
57
58
  const config = fs.readFileSync(configPath, 'utf8');
59
+ // legacy override first (pre-D51 configs)
58
60
  const match = config.match(/^\s*openai_base_url\s*=\s*"([^"]+)"\s*$/m);
59
61
  if (match?.[1]) {
60
62
  return match[1].replace(/\/+$/, '');
61
63
  }
64
+ // D51: the active provider block carries the endpoint — resolve the
65
+ // top-level model_provider slug and read its base_url, so the /models
66
+ // probe stays in sync with what codex actually calls (otherwise an
67
+ // external endpoint activation reports 401 against api.openai.com).
68
+ const providerKey = config.match(/^\s*model_provider\s*=\s*"([^"]+)"\s*$/m)?.[1];
69
+ if (providerKey) {
70
+ const escaped = providerKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
71
+ const blockRe = new RegExp(
72
+ `\\[model_providers\\.${escaped}\\][^[]*?\\bbase_url\\s*=\\s*"([^"]+)"`,
73
+ 'm'
74
+ );
75
+ const blockMatch = config.match(blockRe);
76
+ if (blockMatch?.[1]) {
77
+ return blockMatch[1].replace(/\/+$/, '');
78
+ }
79
+ }
62
80
  } catch { /* ignore missing config */ }
63
81
 
64
82
  if (process.env.OPENAI_BASE_URL) {
@@ -130,6 +148,7 @@ export class CodexAdapter extends RuntimeAdapter {
130
148
  try {
131
149
  const { stdout, stderr } = await execFileAsync(CODEX_BIN, ['login', 'status'], {
132
150
  stdio: 'pipe', encoding: 'utf8', timeout: 10_000,
151
+ env: { ...process.env, PATH: completeBinPath() },
133
152
  });
134
153
  const status = classifyCodexLoginStatus((stdout || '') + (stderr || ''));
135
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 = [];
@@ -11,7 +11,9 @@ import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { execSync, execFileSync, spawnSync } from 'node:child_process';
13
13
  import { parse, stringify } from 'smol-toml';
14
+ import crypto from 'node:crypto';
14
15
  import { BAIZE_DIR } from './config.js';
16
+ import { completeBinPath } from './path-bins.js';
15
17
  import { commandExists } from './shell-utils.js';
16
18
  import { parseClaudeAuthStatus, parseCodexLoginStatus } from './auth-parsers.js';
17
19
  import { installCoreCodexHook } from './codex-hooks.js';
@@ -89,6 +91,7 @@ export function isClaudeAuthenticated() {
89
91
  stdio: 'pipe',
90
92
  encoding: 'utf8',
91
93
  timeout: 10000,
94
+ env: { ...process.env, PATH: completeBinPath() },
92
95
  });
93
96
  return parseClaudeAuthStatus(result.stdout);
94
97
  } catch {
@@ -128,6 +131,7 @@ export function isCodexAuthenticated() {
128
131
  try {
129
132
  const result = spawnSync('codex', ['login', 'status'], {
130
133
  stdio: 'pipe', encoding: 'utf8', timeout: 10000,
134
+ env: { ...process.env, PATH: completeBinPath() },
131
135
  });
132
136
  return parseCodexLoginStatus((result.stdout || '') + (result.stderr || ''));
133
137
  } catch {
@@ -157,7 +161,7 @@ export function approveApiKey(keyOrToken) {
157
161
  if (!config.hasCompletedOnboarding) {
158
162
  config.hasCompletedOnboarding = true;
159
163
  try {
160
- 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();
161
165
  config.lastOnboardingVersion = ver;
162
166
  } catch { /* omit if claude binary not yet available */ }
163
167
  }
@@ -410,21 +414,30 @@ export function renderCodexProjectConfig(existingContent = '', opts = {}) {
410
414
  /**
411
415
  * Render global ~/.codex/config.toml with user/environment-level settings.
412
416
  *
413
- * Contains only trust declarations and optional base URL override.
414
- * Existing [projects.*] trust entries are preserved; the baize project trust
415
- * entry is always regenerated.
416
- *
417
- * @param {string} projectDir - The baize working directory to pre-trust
418
- * @param {string} existingContent - Existing global config.toml contents (optional)
419
- * @param {{ openaiBaseUrl?: string }} opts - Optional Codex config overrides
417
+ * Contains only trust declarations and the optional D51 provider block.
420
418
  * @returns {string}
421
419
  */
422
420
  export function renderCodexGlobalConfig(projectDir, existingContent = '', opts = {}) {
423
421
  const absProject = path.resolve(projectDir);
424
- const openaiBaseUrl = opts.openaiBaseUrl || process.env.OPENAI_BASE_URL || '';
425
422
  const obj = parseCodexToml(existingContent);
426
- if (openaiBaseUrl) {
427
- obj.openai_base_url = openaiBaseUrl;
423
+ // D51: external Codex endpoints are configured as a dedicated provider
424
+ // block (model_providers.<key> + top-level model_provider) instead of
425
+ // overriding the built-in OpenAI provider via openai_base_url — the override
426
+ // keeps provider.name === "OpenAI", which makes Codex enable remote
427
+ // compaction v2 against endpoints that cannot answer it (DeepSeek → fatal
428
+ // "expected exactly one compaction output item").
429
+ const codex = opts.codex || null;
430
+ if (codex && codex.providerKey && codex.baseUrl) {
431
+ obj.model = codex.model || obj.model;
432
+ obj.model_provider = codex.providerKey;
433
+ obj.model_providers = isTomlSectionValue(obj.model_providers) ? obj.model_providers : {};
434
+ const block = { name: codex.providerKey, base_url: codex.baseUrl, wire_api: 'responses' };
435
+ if (codex.token) block.experimental_bearer_token = codex.token;
436
+ obj.model_providers[codex.providerKey] = block;
437
+ delete obj.openai_base_url; // migration cleanup — never mix the two mechanisms
438
+ } else {
439
+ // D51: no provider block requested → never leave a stale override behind
440
+ delete obj.openai_base_url;
428
441
  }
429
442
  obj.features = isTomlSectionValue(obj.features) ? obj.features : {};
430
443
  obj.features.hooks = true;
@@ -438,8 +451,9 @@ export function renderCodexGlobalConfig(projectDir, existingContent = '', opts =
438
451
  *
439
452
  * - Project config (<projectDir>/.codex/config.toml): headless settings,
440
453
  * features, notice suppression — required for baize unattended operation.
441
- * - Global config (~/.codex/config.toml): trust declarations, optional
442
- * base URL override.
454
+ * - Global config (~/.codex/config.toml): trust declarations + optional D51
455
+ * provider block (opts.codex). Legacy opts.openaiBaseUrl is still accepted
456
+ * for compatibility but only cleans the override key (D51 migration).
443
457
  *
444
458
  * Called by both `baize init` (Codex runtime) and `baize runtime codex` so the
445
459
  * config is always present when switching to Codex.
@@ -485,6 +499,136 @@ export function writeCodexConfig(projectDir, opts = {}) {
485
499
  }
486
500
  }
487
501
 
502
+ // ── D51: dedicated provider block writers (DeepSeek remote-compaction fix) ──
503
+
504
+ // Keys Codex treats as built-in model_providers entries — a custom provider
505
+ // must never shadow these (merge_configured_model_providers silently drops
506
+ // colliding keys). Also covers the web-console restore targets.
507
+ export const CODEX_RESERVED_PROVIDER_KEYS = new Set([
508
+ 'openai', 'azure', 'amazon-bedrock', 'amazon-bedrock-runtime',
509
+ 'ollama', 'lmstudio', 'gpt-oss', 'official', 'official-openai',
510
+ ]);
511
+
512
+ /**
513
+ * Deterministic provider slug for a provider id: ASCII slug, or
514
+ * `codex-<id-hash8>` when empty or reserved. Pure — no store access.
515
+ */
516
+ export function codexProviderKeyForId(id) {
517
+ const base = String(id || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
518
+ if (base && !CODEX_RESERVED_PROVIDER_KEYS.has(base)) return base;
519
+ const hash = crypto.createHash('sha256').update(String(id || '')).digest('hex').slice(0, 8);
520
+ return `codex-${hash}`;
521
+ }
522
+
523
+ function globalCodexConfigPath(homeDir) {
524
+ // D51 incident hardening: jest's ESM sandbox resolves os.homedir() to the
525
+ // REAL home regardless of process.env.HOME — relying on the default once
526
+ // wrote test fixtures into the user's live ~/.codex. Callers must pass
527
+ // homeDir explicitly; when they don't, honor HOME env first and only then
528
+ // fall back to os.homedir().
529
+ const base = homeDir || process.env.HOME || os.homedir();
530
+ return path.join(base, '.codex', 'config.toml');
531
+ }
532
+
533
+ function projectCodexConfigPathFor(projectDir) {
534
+ // D51: resolve the fallback lazily — BAIZE_DIR is a module-load constant in
535
+ // config.js, so under test runners that mutate HOME/BAIZE_DIR per case the
536
+ // cached value would point at the first case's temp dir. env wins here.
537
+ const base = projectDir
538
+ ? path.resolve(projectDir)
539
+ : (process.env.BAIZE_DIR || BAIZE_DIR);
540
+ return path.join(base, '.codex', 'config.toml');
541
+ }
542
+
543
+ /** Parse + stringify round-trip preserving everything smol-toml supports. */
544
+ function rewriteCodexToml(targetPath, mutate, { mode } = {}) {
545
+ const dir = path.dirname(targetPath);
546
+ fs.mkdirSync(dir, { recursive: true });
547
+ let content = '';
548
+ try { content = fs.readFileSync(targetPath, 'utf8'); } catch { /* new file */ }
549
+ const obj = parseCodexToml(content);
550
+ mutate(obj);
551
+ const out = stringify(obj);
552
+ fs.writeFileSync(targetPath, out.endsWith('\n') ? out : `${out}\n`, mode ? { mode } : 'utf8');
553
+ return targetPath;
554
+ }
555
+
556
+ /**
557
+ * D51: activate an external Codex provider as a dedicated [model_providers.<key>]
558
+ * block in the GLOBAL config + top-level model_provider, and ensure both files
559
+ * are free of the legacy openai_base_url override. The block's `name` always
560
+ * equals the slug (never "OpenAI") so Codex treats the endpoint as a generic
561
+ * responses-compatible provider → local compaction (DeepSeek-safe).
562
+ *
563
+ * @param {{ providerKey: string, baseUrl: string, token?: string, model?: string,
564
+ * homeDir?: string, projectDir?: string }} opts
565
+ * @returns {{ globalPath: string, projectPath: string }} written file paths
566
+ */
567
+ export function applyCodexProviderBlock(opts) {
568
+ const { providerKey, baseUrl, token, model } = opts || {};
569
+ if (!providerKey || !baseUrl) {
570
+ throw new Error('applyCodexProviderBlock requires providerKey and baseUrl');
571
+ }
572
+ const key = CODEX_RESERVED_PROVIDER_KEYS.has(providerKey)
573
+ ? codexProviderKeyForId(providerKey)
574
+ : providerKey;
575
+ const globalPath = rewriteCodexToml(globalCodexConfigPath(opts.homeDir), (obj) => {
576
+ if (model) obj.model = model;
577
+ obj.model_provider = key;
578
+ obj.model_providers = isTomlSectionValue(obj.model_providers) ? obj.model_providers : {};
579
+ const block = { name: key, base_url: baseUrl, wire_api: 'responses' };
580
+ if (token) block.experimental_bearer_token = token;
581
+ obj.model_providers[key] = block;
582
+ delete obj.openai_base_url;
583
+ }, { mode: 0o600 }); // token may ride the global file — write user-only from the start
584
+
585
+
586
+ // D31 semantics: codex prefers project-level values, so the provider model
587
+ // must be mirrored here — otherwise a stale project model (e.g. gpt-5.5
588
+ // backfilled by init) would shadow the just-activated provider model.
589
+ const projectPath = rewriteCodexToml(projectCodexConfigPathFor(opts.projectDir), (obj) => {
590
+ if (model) obj.model = model;
591
+ delete obj.openai_base_url; // project-level never carries endpoint/auth
592
+ });
593
+ return { globalPath, projectPath };
594
+ }
595
+
596
+ /**
597
+ * D51: restore the official OpenAI configuration — remove the top-level
598
+ * model_provider and every baize-written custom provider block, drop the
599
+ * legacy openai_base_url override, reset model to gpt-5.5. Project-level
600
+ * model resets too (mirrors the pre-D51 applyCodexOfficial behavior).
601
+ *
602
+ * @param {{ homeDir?: string, projectDir?: string }} [opts]
603
+ * @returns {{ globalPath: string, projectPath: string }}
604
+ */
605
+ export function restoreCodexOfficialBlock(opts = {}) {
606
+ const globalPath = rewriteCodexToml(globalCodexConfigPath(opts.homeDir), (obj) => {
607
+ delete obj.model_provider;
608
+ if (isTomlSectionValue(obj.model_providers)) {
609
+ for (const key of Object.keys(obj.model_providers)) {
610
+ const block = obj.model_providers[key];
611
+ // D51 review P2: only remove blocks baize itself wrote — the writer
612
+ // always stamps name === key && wire_api === 'responses'. Hand-edited
613
+ // user providers (any other shape) must survive an official restore.
614
+ const isBaizeWritten = isTomlSectionValue(block)
615
+ && block.name === key
616
+ && block.wire_api === 'responses';
617
+ if (isBaizeWritten) delete obj.model_providers[key];
618
+ }
619
+ if (Object.keys(obj.model_providers).length === 0) delete obj.model_providers;
620
+ }
621
+ delete obj.openai_base_url;
622
+ obj.model = 'gpt-5.5';
623
+ });
624
+ const projectPath = rewriteCodexToml(projectCodexConfigPathFor(opts.projectDir), (obj) => {
625
+ delete obj.openai_base_url;
626
+ obj.model = 'gpt-5.5';
627
+ });
628
+ return { globalPath, projectPath };
629
+ }
630
+
631
+
488
632
  /**
489
633
  * Persist an OpenAI API key to ~/.codex/auth.json (Codex's native credential store).
490
634
  * Also sets OPENAI_API_KEY in process.env for the current init process so that
@@ -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.