@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.
- package/.dockerignore +1 -0
- package/CHANGELOG.md +29 -0
- package/Dockerfile +10 -1
- package/cli/commands/doctor.js +60 -0
- package/cli/commands/init.js +104 -42
- package/cli/commands/runtime.js +9 -1
- package/cli/lib/__tests__/init-base-url.test.js +55 -13
- package/cli/lib/__tests__/runtime-base-url.test.js +3 -1
- package/cli/lib/__tests__/runtime-launch.test.js +4 -2
- package/cli/lib/__tests__/runtime-setup.test.js +53 -6
- package/cli/lib/__tests__/tmux-env.test.js +19 -5
- package/cli/lib/claude-eval.js +2 -1
- package/cli/lib/codex-hooks.js +12 -0
- package/cli/lib/path-bins.js +54 -0
- package/cli/lib/runtime/claude.js +4 -2
- package/cli/lib/runtime/codex.js +19 -0
- package/cli/lib/runtime/tmux-env.js +5 -4
- package/cli/lib/runtime-setup.js +157 -13
- package/docker/entrypoint.sh +4 -2
- package/docs/ops-runbook.md +161 -0
- package/package.json +2 -2
- package/scripts/docker-publish.sh +11 -5
- package/scripts/pack-release.sh +34 -26
- package/skills/activity-monitor/scripts/__tests__/guardian.test.js +51 -0
- package/skills/activity-monitor/scripts/adapters/runtime-components.js +27 -0
- package/skills/activity-monitor/scripts/guardian.js +45 -1
- package/skills/activity-monitor/scripts/monitor-orchestrator.js +8 -1
- package/skills/activity-monitor/scripts/upgrade-check.js +3 -1
- package/skills/web-console/public/app.js +20 -75
- package/skills/web-console/scripts/model-provider.js +97 -59
- package/skills/web-console/scripts/server.js +6 -24
- package/templates/pm2/ecosystem.config.cjs +5 -4
- package/test/model-provider.test.js +150 -40
- 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('
|
|
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.
|
|
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('
|
|
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.
|
|
241
|
+
assert.doesNotMatch(content, /openai_base_url/);
|
|
196
242
|
});
|
|
197
243
|
|
|
198
|
-
it('
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/cli/lib/claude-eval.js
CHANGED
|
@@ -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
|
package/cli/lib/codex-hooks.js
CHANGED
|
@@ -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';
|
package/cli/lib/runtime/codex.js
CHANGED
|
@@ -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
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
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 = [];
|
package/cli/lib/runtime-setup.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
427
|
-
|
|
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
|
|
442
|
-
*
|
|
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
|
package/docker/entrypoint.sh
CHANGED
|
@@ -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
|
-
#
|
|
128
|
-
|
|
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.
|