@baize-ai/core 0.3.15 → 0.3.16

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,20 @@ 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.16] - 2026-09-13
9
+
10
+ ### Added
11
+ - Codex 供应商配置改造(D51):独立 provider 块写入器 + kind/slug store + init/runtime 遗留路径转换 + web-console 只读激活卡;`/models` 探测读取 provider 块 base_url
12
+ - Dockerfile 本地源码构建支持(D51):`BAZE_CORE_TARBALL=/core.tgz` 安装工作区包而非 registry 版本——验证未发布修复(如 D51)进镜像
13
+
14
+ ### Fixed
15
+ - D51 部署缺陷(init):web-console `import ../../../cli/lib/runtime-setup.js` 运行时 `~/.claude/cli/lib/` 不存在 → ERR_MODULE_NOT_FOUND 崩溃——`syncCoreSkills` 补拷 `package/cli` 至 `SKILLS_DIR 上级/cli`,并从包根复制零依赖 `node_modules/smol-toml`
16
+ - D51 配置污染事故根因(4a37368):`globalCodexConfigPath` 优先读 `HOME` env 而非 `os.homedir()`——jest ESM sandbox 曾写穿真实 `~/.codex/config.toml`
17
+ - D51 review 修复:restore 只删 baize-written 块(`name=key && wire_api=responses`);写盘直设 `0600` + 去重用例;激活卡展示 kind
18
+
19
+ ### Changed
20
+ - A2A 授权治理收敛(D52 2b11171):agent 本地 authz 仅保留 mode 切换(open/allowlist)——allow/block 名单由 admin 控制台统一管控(PUT 忽略名单强制清空 + UI 隐藏名单编辑并提示管控来源;移除死代码 `validateAuthzList`/peerIds 请求)
21
+
8
22
  ## [0.3.15] - 2026-08-31
9
23
 
10
24
  ### Fixed
package/Dockerfile CHANGED
@@ -82,8 +82,12 @@ ENV WEB_CONSOLE_BIND=0.0.0.0
82
82
  # build context) — source stays private, the image matches the npm version
83
83
  # exactly. Build with: docker build --build-arg BAZE_CORE_VERSION=0.2.0 .
84
84
  ARG BAZE_CORE_VERSION=latest
85
+ # Local-source build: pass BAZE_CORE_TARBALL=/core.tgz (COPY'd into the
86
+ # build context) to install the workspace package instead of the registry
87
+ # version — lets us verify un-released fixes (e.g. D51) in the image.
88
+ ARG BAZE_CORE_TARBALL=
85
89
  WORKDIR /home/baize
86
- RUN npm install -g @baize-ai/core@${BAZE_CORE_VERSION} \
90
+ RUN if [ -n "${BAZE_CORE_TARBALL}" ]; then npm install -g /tmp/${BAZE_CORE_TARBALL}; else npm install -g @baize-ai/core@${BAZE_CORE_VERSION}; fi \
87
91
  && baize --version \
88
92
  # ── Build-time skill dependency preinstall (reproducibility) ─────────
89
93
  # `baize init` reinstalls skill deps at runtime; doing it here bakes the
@@ -134,6 +138,11 @@ RUN mkdir -p \
134
138
  # ── Copy PM2 ecosystem config ─────────────────────────────────────────────────
135
139
  COPY --chown=baize:baize templates/pm2/ecosystem.config.cjs /home/baize/baize/pm2/ecosystem.config.cjs
136
140
 
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
+
137
146
  # ── Copy entrypoint ───────────────────────────────────────────────────────────
138
147
  COPY --chown=baize:baize docker/entrypoint.sh /entrypoint.sh
139
148
  RUN chmod +x /entrypoint.sh
@@ -41,6 +41,7 @@ import {
41
41
  saveCodexApiKeyToEnv,
42
42
  saveCodexBaseUrlToEnv,
43
43
  writeCodexConfig,
44
+ applyCodexProviderBlock,
44
45
  } from '../lib/runtime-setup.js';
45
46
 
46
47
  // Source directories (shipped with baize package)
@@ -839,6 +840,30 @@ function syncCoreSkills() {
839
840
  const installed = [];
840
841
  const updated = [];
841
842
 
843
+ // D51: web-console imports `../../../cli/lib/runtime-setup.js`, which from
844
+ // `~/.claude/skills/web-console/scripts/` resolves to `~/.claude/cli/lib/`.
845
+ // The npm package ships `package/cli`, but init never deployed it to the
846
+ // runtime tree — web-console crashed with ERR_MODULE_NOT_FOUND in the image.
847
+ // Deploy the shared CLI lib beside SKILLS_DIR so the import path mirrors the
848
+ // package layout (package/cli ↔ <SKILLS_DIR>/../cli).
849
+ const cliSrc = path.join(PACKAGE_ROOT, 'cli');
850
+ const cliDest = path.join(path.dirname(SKILLS_DIR), 'cli');
851
+ if (fs.existsSync(cliSrc)) {
852
+ try {
853
+ copyTree(cliSrc, cliDest);
854
+ // runtime-setup.js imports smol-toml (D51). The cli/ tree is deployed
855
+ // outside any skill's node_modules, so the dependency must ride along —
856
+ // mirror the package layout by copying the package's smol-toml into
857
+ // <cliDest>/node_modules/.
858
+ const tomlSrc = path.join(PACKAGE_ROOT, 'node_modules', 'smol-toml');
859
+ if (fs.existsSync(tomlSrc)) {
860
+ copyTree(tomlSrc, path.join(cliDest, 'node_modules', 'smol-toml'));
861
+ }
862
+ } catch {
863
+ console.log(` ${warn(`Failed to sync cli/ to ${cliDest}`)}`);
864
+ }
865
+ }
866
+
842
867
  const entries = fs.readdirSync(CORE_SKILLS_SRC, { withFileTypes: true });
843
868
  for (const entry of entries) {
844
869
  if (!entry.isDirectory()) continue;
@@ -2182,9 +2207,17 @@ export async function initCommand(args) {
2182
2207
  }
2183
2208
  }
2184
2209
  }
2185
-
2186
- // Write ~/.codex/config.toml to suppress interactive prompts on first launch
2187
- if (writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: pendingCodexBaseUrl || undefined })) {
2210
+ // Write ~/.codex/config.toml to suppress interactive prompts on first
2211
+ // launch. A custom --codex-base-url becomes a dedicated provider block
2212
+ // (D51) never the built-in-OpenAI openai_base_url override.
2213
+ if (pendingCodexBaseUrl) {
2214
+ applyCodexProviderBlock({
2215
+ providerKey: 'baize-init',
2216
+ baseUrl: pendingCodexBaseUrl,
2217
+ token: opts.codexApiKey || undefined,
2218
+ });
2219
+ if (!quiet) console.log(` ${success('Codex startup config written (provider block)')}`);
2220
+ } else if (writeCodexConfig(BAIZE_DIR)) {
2188
2221
  if (!quiet) console.log(` ${success('Codex startup config written')}`);
2189
2222
  }
2190
2223
  }
@@ -2409,7 +2442,12 @@ export async function initCommand(args) {
2409
2442
  }
2410
2443
  if (pendingCodexBaseUrl) {
2411
2444
  saveCodexBaseUrlToEnv(pendingCodexBaseUrl);
2412
- writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: pendingCodexBaseUrl });
2445
+ // D51: custom endpoint → dedicated provider block, not the built-in-OpenAI override.
2446
+ applyCodexProviderBlock({
2447
+ providerKey: 'baize-init',
2448
+ baseUrl: pendingCodexBaseUrl,
2449
+ token: opts.codexApiKey || undefined,
2450
+ });
2413
2451
  }
2414
2452
 
2415
2453
  // Timezone: use resolved value or show current
@@ -2518,7 +2556,12 @@ export async function initCommand(args) {
2518
2556
  }
2519
2557
  if (pendingCodexBaseUrl) {
2520
2558
  saveCodexBaseUrlToEnv(pendingCodexBaseUrl);
2521
- writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: pendingCodexBaseUrl });
2559
+ // D51: custom endpoint → dedicated provider block, not the built-in-OpenAI override.
2560
+ applyCodexProviderBlock({
2561
+ providerKey: 'baize-init',
2562
+ baseUrl: pendingCodexBaseUrl,
2563
+ token: opts.codexApiKey || undefined,
2564
+ });
2522
2565
  }
2523
2566
  // Step 8: Configure timezone
2524
2567
  if (!quiet) console.log(`\n${heading('Timezone configuration...')}`);
@@ -29,6 +29,7 @@ import {
29
29
  saveSetupTokenToEnv,
30
30
  saveCodexApiKey,
31
31
  saveCodexBaseUrlToEnv,
32
+ applyCodexProviderBlock,
32
33
  saveCodexApiKeyToEnv,
33
34
  writeCodexConfig,
34
35
  } from '../lib/runtime-setup.js';
@@ -123,7 +124,14 @@ export function applyBaseUrl(target, baseUrl) {
123
124
  }
124
125
  if (target === 'codex') {
125
126
  if (!saveCodexBaseUrlToEnv(baseUrl)) return false;
126
- return writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: baseUrl });
127
+ // D51: external endpoints get a dedicated provider block (DeepSeek-safe,
128
+ // local compaction) — never the built-in-openai openai_base_url override.
129
+ try {
130
+ applyCodexProviderBlock({ providerKey: 'baize-runtime', baseUrl, projectDir: BAIZE_DIR });
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
127
135
  }
128
136
  return false;
129
137
  }
@@ -72,7 +72,7 @@ describe('base URL support', () => {
72
72
  }
73
73
  });
74
74
 
75
- test('writeCodexConfig writes openai_base_url when OPENAI_BASE_URL is set', async () => {
75
+ test('writeCodexConfig never writes openai_base_url env override is dropped (D51)', async () => {
76
76
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-base-url-'));
77
77
  const originalHome = process.env.HOME;
78
78
  const originalOpenAiBaseUrl = process.env.OPENAI_BASE_URL;
@@ -87,7 +87,7 @@ describe('base URL support', () => {
87
87
 
88
88
  const configPath = path.join(tmpRoot, '.codex', 'config.toml');
89
89
  const config = fs.readFileSync(configPath, 'utf8');
90
- assert.match(config, /openai_base_url = "https:\/\/openai-proxy\.example\.com\/v1"/);
90
+ assert.doesNotMatch(config, /openai_base_url/);
91
91
  } finally {
92
92
  if (originalHome === undefined) delete process.env.HOME;
93
93
  else process.env.HOME = originalHome;
@@ -99,31 +99,73 @@ describe('base URL support', () => {
99
99
  }
100
100
  });
101
101
 
102
- test('writeCodexConfig writes openai_base_url when explicit opts.openaiBaseUrl is provided', async () => {
102
+ test('writeCodexConfig strips a stale legacy openai_base_url from existing config (D51)', async () => {
103
103
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-base-url-opt-'));
104
104
  const originalHome = process.env.HOME;
105
- const originalOpenAiBaseUrl = process.env.OPENAI_BASE_URL;
106
105
 
107
106
  process.env.HOME = tmpRoot;
108
- delete process.env.OPENAI_BASE_URL;
109
107
 
110
108
  try {
111
109
  const { writeCodexConfig } = await import('../runtime-setup.js');
112
110
 
113
- assert.equal(
114
- writeCodexConfig(path.join(tmpRoot, 'baize-project'), { openaiBaseUrl: 'https://explicit-proxy.example.com/v1' }),
115
- true
116
- );
117
-
118
111
  const configPath = path.join(tmpRoot, '.codex', 'config.toml');
112
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
113
+ fs.writeFileSync(configPath, 'openai_base_url = "https://explicit-proxy.example.com/v1"\nmodel = "old"\n');
114
+
115
+ assert.equal(writeCodexConfig(path.join(tmpRoot, 'baize-project'), { openaiBaseUrl: 'https://explicit-proxy.example.com/v1' }), true);
116
+
119
117
  const config = fs.readFileSync(configPath, 'utf8');
120
- assert.match(config, /openai_base_url = "https:\/\/explicit-proxy\.example\.com\/v1"/);
118
+ assert.doesNotMatch(config, /openai_base_url/);
119
+ assert.match(config, /^model = "old"$/m, 'unrelated keys preserved');
121
120
  } finally {
122
121
  if (originalHome === undefined) delete process.env.HOME;
123
122
  else process.env.HOME = originalHome;
124
123
 
125
- if (originalOpenAiBaseUrl === undefined) delete process.env.OPENAI_BASE_URL;
126
- else process.env.OPENAI_BASE_URL = originalOpenAiBaseUrl;
124
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test('--codex-base-url path writes a baize-init provider block (D51)', async () => {
129
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-init-provider-'));
130
+ const originalHome = process.env.HOME;
131
+ const originalBaizeDir = process.env.BAIZE_DIR;
132
+
133
+ process.env.HOME = tmpRoot;
134
+ process.env.BAIZE_DIR = path.join(tmpRoot, 'baize');
135
+
136
+ try {
137
+ const { applyCodexProviderBlock } = await import('../runtime-setup.js');
138
+
139
+ // This mirrors exactly what init.js does when --codex-base-url is given:
140
+ // pendingCodexBaseUrl → applyCodexProviderBlock (providerKey 'baize-init').
141
+ // projectDir passed explicitly: the module-level BAIZE_DIR fallback is
142
+ // captured at first import and may not match this test's env override.
143
+ applyCodexProviderBlock({
144
+ providerKey: 'baize-init',
145
+ baseUrl: 'https://proxy.example.com/v1',
146
+ token: 'sk-init-key',
147
+ projectDir: process.env.BAIZE_DIR,
148
+ });
149
+
150
+ const config = fs.readFileSync(path.join(tmpRoot, '.codex', 'config.toml'), 'utf8');
151
+ assert.match(config, /\[model_providers\.baize-init\]/);
152
+ assert.match(config, /^model_provider = "baize-init"$/m);
153
+ assert.match(config, /^name = "baize-init"$/m);
154
+ assert.match(config, /^base_url = "https:\/\/proxy\.example\.com\/v1"$/m);
155
+ assert.match(config, /^wire_api = "responses"$/m);
156
+ assert.match(config, /^experimental_bearer_token = "sk-init-key"$/m);
157
+ assert.doesNotMatch(config, /openai_base_url/);
158
+
159
+ // Project-level: no provider block, no override.
160
+ const projectConfig = fs.readFileSync(path.join(tmpRoot, 'baize', '.codex', 'config.toml'), 'utf8');
161
+ assert.doesNotMatch(projectConfig, /model_provider/);
162
+ assert.doesNotMatch(projectConfig, /\[model_providers\./);
163
+ assert.doesNotMatch(projectConfig, /openai_base_url/);
164
+ } finally {
165
+ if (originalHome === undefined) delete process.env.HOME;
166
+ else process.env.HOME = originalHome;
167
+ if (originalBaizeDir === undefined) delete process.env.BAIZE_DIR;
168
+ else process.env.BAIZE_DIR = originalBaizeDir;
127
169
 
128
170
  fs.rmSync(tmpRoot, { recursive: true, force: true });
129
171
  }
@@ -126,6 +126,8 @@ describe('runtime base URL support', () => {
126
126
  assert.equal(claudeSettings.env.ANTHROPIC_BASE_URL, 'https://claude-proxy.example.com');
127
127
 
128
128
  const codexConfig = fs.readFileSync(path.join(tmpRoot, '.codex', 'config.toml'), 'utf8');
129
- assert.match(codexConfig, /openai_base_url = "https:\/\/codex-proxy\.example\.com\/v1"/);
129
+ assert.match(codexConfig, /model_provider = "baize-runtime"/);
130
+ assert.match(codexConfig, /\[model_providers\.baize-runtime\][\s\S]*?base_url = "https:\/\/codex-proxy\.example\.com\/v1"/);
131
+ assert.doesNotMatch(codexConfig, /openai_base_url/);
130
132
  });
131
133
  });
@@ -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');
@@ -55,10 +55,27 @@ function getCodexApiBaseUrl() {
55
55
  try {
56
56
  const configPath = path.join(os.homedir(), '.codex', 'config.toml');
57
57
  const config = fs.readFileSync(configPath, 'utf8');
58
+ // legacy override first (pre-D51 configs)
58
59
  const match = config.match(/^\s*openai_base_url\s*=\s*"([^"]+)"\s*$/m);
59
60
  if (match?.[1]) {
60
61
  return match[1].replace(/\/+$/, '');
61
62
  }
63
+ // D51: the active provider block carries the endpoint — resolve the
64
+ // top-level model_provider slug and read its base_url, so the /models
65
+ // probe stays in sync with what codex actually calls (otherwise an
66
+ // external endpoint activation reports 401 against api.openai.com).
67
+ const providerKey = config.match(/^\s*model_provider\s*=\s*"([^"]+)"\s*$/m)?.[1];
68
+ if (providerKey) {
69
+ const escaped = providerKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
70
+ const blockRe = new RegExp(
71
+ `\\[model_providers\\.${escaped}\\][^[]*?\\bbase_url\\s*=\\s*"([^"]+)"`,
72
+ 'm'
73
+ );
74
+ const blockMatch = config.match(blockRe);
75
+ if (blockMatch?.[1]) {
76
+ return blockMatch[1].replace(/\/+$/, '');
77
+ }
78
+ }
62
79
  } catch { /* ignore missing config */ }
63
80
 
64
81
  if (process.env.OPENAI_BASE_URL) {
@@ -11,6 +11,7 @@ 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';
15
16
  import { commandExists } from './shell-utils.js';
16
17
  import { parseClaudeAuthStatus, parseCodexLoginStatus } from './auth-parsers.js';
@@ -410,21 +411,30 @@ export function renderCodexProjectConfig(existingContent = '', opts = {}) {
410
411
  /**
411
412
  * Render global ~/.codex/config.toml with user/environment-level settings.
412
413
  *
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
414
+ * Contains only trust declarations and the optional D51 provider block.
420
415
  * @returns {string}
421
416
  */
422
417
  export function renderCodexGlobalConfig(projectDir, existingContent = '', opts = {}) {
423
418
  const absProject = path.resolve(projectDir);
424
- const openaiBaseUrl = opts.openaiBaseUrl || process.env.OPENAI_BASE_URL || '';
425
419
  const obj = parseCodexToml(existingContent);
426
- if (openaiBaseUrl) {
427
- obj.openai_base_url = openaiBaseUrl;
420
+ // D51: external Codex endpoints are configured as a dedicated provider
421
+ // block (model_providers.<key> + top-level model_provider) instead of
422
+ // overriding the built-in OpenAI provider via openai_base_url — the override
423
+ // keeps provider.name === "OpenAI", which makes Codex enable remote
424
+ // compaction v2 against endpoints that cannot answer it (DeepSeek → fatal
425
+ // "expected exactly one compaction output item").
426
+ const codex = opts.codex || null;
427
+ if (codex && codex.providerKey && codex.baseUrl) {
428
+ obj.model = codex.model || obj.model;
429
+ obj.model_provider = codex.providerKey;
430
+ obj.model_providers = isTomlSectionValue(obj.model_providers) ? obj.model_providers : {};
431
+ const block = { name: codex.providerKey, base_url: codex.baseUrl, wire_api: 'responses' };
432
+ if (codex.token) block.experimental_bearer_token = codex.token;
433
+ obj.model_providers[codex.providerKey] = block;
434
+ delete obj.openai_base_url; // migration cleanup — never mix the two mechanisms
435
+ } else {
436
+ // D51: no provider block requested → never leave a stale override behind
437
+ delete obj.openai_base_url;
428
438
  }
429
439
  obj.features = isTomlSectionValue(obj.features) ? obj.features : {};
430
440
  obj.features.hooks = true;
@@ -438,8 +448,9 @@ export function renderCodexGlobalConfig(projectDir, existingContent = '', opts =
438
448
  *
439
449
  * - Project config (<projectDir>/.codex/config.toml): headless settings,
440
450
  * features, notice suppression — required for baize unattended operation.
441
- * - Global config (~/.codex/config.toml): trust declarations, optional
442
- * base URL override.
451
+ * - Global config (~/.codex/config.toml): trust declarations + optional D51
452
+ * provider block (opts.codex). Legacy opts.openaiBaseUrl is still accepted
453
+ * for compatibility but only cleans the override key (D51 migration).
443
454
  *
444
455
  * Called by both `baize init` (Codex runtime) and `baize runtime codex` so the
445
456
  * config is always present when switching to Codex.
@@ -485,6 +496,136 @@ export function writeCodexConfig(projectDir, opts = {}) {
485
496
  }
486
497
  }
487
498
 
499
+ // ── D51: dedicated provider block writers (DeepSeek remote-compaction fix) ──
500
+
501
+ // Keys Codex treats as built-in model_providers entries — a custom provider
502
+ // must never shadow these (merge_configured_model_providers silently drops
503
+ // colliding keys). Also covers the web-console restore targets.
504
+ export const CODEX_RESERVED_PROVIDER_KEYS = new Set([
505
+ 'openai', 'azure', 'amazon-bedrock', 'amazon-bedrock-runtime',
506
+ 'ollama', 'lmstudio', 'gpt-oss', 'official', 'official-openai',
507
+ ]);
508
+
509
+ /**
510
+ * Deterministic provider slug for a provider id: ASCII slug, or
511
+ * `codex-<id-hash8>` when empty or reserved. Pure — no store access.
512
+ */
513
+ export function codexProviderKeyForId(id) {
514
+ const base = String(id || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
515
+ if (base && !CODEX_RESERVED_PROVIDER_KEYS.has(base)) return base;
516
+ const hash = crypto.createHash('sha256').update(String(id || '')).digest('hex').slice(0, 8);
517
+ return `codex-${hash}`;
518
+ }
519
+
520
+ function globalCodexConfigPath(homeDir) {
521
+ // D51 incident hardening: jest's ESM sandbox resolves os.homedir() to the
522
+ // REAL home regardless of process.env.HOME — relying on the default once
523
+ // wrote test fixtures into the user's live ~/.codex. Callers must pass
524
+ // homeDir explicitly; when they don't, honor HOME env first and only then
525
+ // fall back to os.homedir().
526
+ const base = homeDir || process.env.HOME || os.homedir();
527
+ return path.join(base, '.codex', 'config.toml');
528
+ }
529
+
530
+ function projectCodexConfigPathFor(projectDir) {
531
+ // D51: resolve the fallback lazily — BAIZE_DIR is a module-load constant in
532
+ // config.js, so under test runners that mutate HOME/BAIZE_DIR per case the
533
+ // cached value would point at the first case's temp dir. env wins here.
534
+ const base = projectDir
535
+ ? path.resolve(projectDir)
536
+ : (process.env.BAIZE_DIR || BAIZE_DIR);
537
+ return path.join(base, '.codex', 'config.toml');
538
+ }
539
+
540
+ /** Parse + stringify round-trip preserving everything smol-toml supports. */
541
+ function rewriteCodexToml(targetPath, mutate, { mode } = {}) {
542
+ const dir = path.dirname(targetPath);
543
+ fs.mkdirSync(dir, { recursive: true });
544
+ let content = '';
545
+ try { content = fs.readFileSync(targetPath, 'utf8'); } catch { /* new file */ }
546
+ const obj = parseCodexToml(content);
547
+ mutate(obj);
548
+ const out = stringify(obj);
549
+ fs.writeFileSync(targetPath, out.endsWith('\n') ? out : `${out}\n`, mode ? { mode } : 'utf8');
550
+ return targetPath;
551
+ }
552
+
553
+ /**
554
+ * D51: activate an external Codex provider as a dedicated [model_providers.<key>]
555
+ * block in the GLOBAL config + top-level model_provider, and ensure both files
556
+ * are free of the legacy openai_base_url override. The block's `name` always
557
+ * equals the slug (never "OpenAI") so Codex treats the endpoint as a generic
558
+ * responses-compatible provider → local compaction (DeepSeek-safe).
559
+ *
560
+ * @param {{ providerKey: string, baseUrl: string, token?: string, model?: string,
561
+ * homeDir?: string, projectDir?: string }} opts
562
+ * @returns {{ globalPath: string, projectPath: string }} written file paths
563
+ */
564
+ export function applyCodexProviderBlock(opts) {
565
+ const { providerKey, baseUrl, token, model } = opts || {};
566
+ if (!providerKey || !baseUrl) {
567
+ throw new Error('applyCodexProviderBlock requires providerKey and baseUrl');
568
+ }
569
+ const key = CODEX_RESERVED_PROVIDER_KEYS.has(providerKey)
570
+ ? codexProviderKeyForId(providerKey)
571
+ : providerKey;
572
+ const globalPath = rewriteCodexToml(globalCodexConfigPath(opts.homeDir), (obj) => {
573
+ if (model) obj.model = model;
574
+ obj.model_provider = key;
575
+ obj.model_providers = isTomlSectionValue(obj.model_providers) ? obj.model_providers : {};
576
+ const block = { name: key, base_url: baseUrl, wire_api: 'responses' };
577
+ if (token) block.experimental_bearer_token = token;
578
+ obj.model_providers[key] = block;
579
+ delete obj.openai_base_url;
580
+ }, { mode: 0o600 }); // token may ride the global file — write user-only from the start
581
+
582
+
583
+ // D31 semantics: codex prefers project-level values, so the provider model
584
+ // must be mirrored here — otherwise a stale project model (e.g. gpt-5.5
585
+ // backfilled by init) would shadow the just-activated provider model.
586
+ const projectPath = rewriteCodexToml(projectCodexConfigPathFor(opts.projectDir), (obj) => {
587
+ if (model) obj.model = model;
588
+ delete obj.openai_base_url; // project-level never carries endpoint/auth
589
+ });
590
+ return { globalPath, projectPath };
591
+ }
592
+
593
+ /**
594
+ * D51: restore the official OpenAI configuration — remove the top-level
595
+ * model_provider and every baize-written custom provider block, drop the
596
+ * legacy openai_base_url override, reset model to gpt-5.5. Project-level
597
+ * model resets too (mirrors the pre-D51 applyCodexOfficial behavior).
598
+ *
599
+ * @param {{ homeDir?: string, projectDir?: string }} [opts]
600
+ * @returns {{ globalPath: string, projectPath: string }}
601
+ */
602
+ export function restoreCodexOfficialBlock(opts = {}) {
603
+ const globalPath = rewriteCodexToml(globalCodexConfigPath(opts.homeDir), (obj) => {
604
+ delete obj.model_provider;
605
+ if (isTomlSectionValue(obj.model_providers)) {
606
+ for (const key of Object.keys(obj.model_providers)) {
607
+ const block = obj.model_providers[key];
608
+ // D51 review P2: only remove blocks baize itself wrote — the writer
609
+ // always stamps name === key && wire_api === 'responses'. Hand-edited
610
+ // user providers (any other shape) must survive an official restore.
611
+ const isBaizeWritten = isTomlSectionValue(block)
612
+ && block.name === key
613
+ && block.wire_api === 'responses';
614
+ if (isBaizeWritten) delete obj.model_providers[key];
615
+ }
616
+ if (Object.keys(obj.model_providers).length === 0) delete obj.model_providers;
617
+ }
618
+ delete obj.openai_base_url;
619
+ obj.model = 'gpt-5.5';
620
+ });
621
+ const projectPath = rewriteCodexToml(projectCodexConfigPathFor(opts.projectDir), (obj) => {
622
+ delete obj.openai_base_url;
623
+ obj.model = 'gpt-5.5';
624
+ });
625
+ return { globalPath, projectPath };
626
+ }
627
+
628
+
488
629
  /**
489
630
  * Persist an OpenAI API key to ~/.codex/auth.json (Codex's native credential store).
490
631
  * Also sets OPENAI_API_KEY in process.env for the current init process so that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baize-ai/core",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "type": "module",
5
5
  "description": "Baize (白泽) — autonomous AI agent infrastructure",
6
6
  "main": "cli/baize.js",
@@ -46,4 +46,4 @@
46
46
  "publishConfig": {
47
47
  "access": "public"
48
48
  }
49
- }
49
+ }
@@ -35,10 +35,6 @@ docker build \
35
35
  -t "${GHCR_IMAGE}:${VERSION}" \
36
36
  -t "${GHCR_IMAGE}:latest" \
37
37
  .
38
- "${BUILD_ARGS[@]}" \
39
- -t "${GHCR_IMAGE}:${VERSION}" \
40
- -t "${GHCR_IMAGE}:latest" \
41
- .
42
38
 
43
39
  if [ -n "${ACR_REGISTRY}" ] && [ -n "${ACR_NAMESPACE}" ]; then
44
40
  ACR_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/baize-core"