@baize-ai/core 0.3.14 → 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.
@@ -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
@@ -21,31 +21,35 @@ import { copyTree, syncTree } from './fs-utils.js';
21
21
  import { applyCaddyRoutes } from './caddy.js';
22
22
  import { smartSync, formatMergeResult } from './smart-merge.js';
23
23
  import { restartFromEcosystem, restartManagedProcess } from './pm2.js';
24
+ import { a2aCliPath } from './a2a.js';
24
25
 
25
26
  // ---------------------------------------------------------------------------
26
27
  // Version helpers
27
28
  // ---------------------------------------------------------------------------
28
29
 
30
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
31
+
29
32
  /**
30
- * Read the local version from SKILL.md frontmatter, falling back to package.json.
33
+ * Read the local version of an installed component (K6).
34
+ * Primary: skillDir/package.json `version` (valid semver only — SKILL.md
35
+ * frontmatter historically stayed at 0.1.0 while package.json advanced, which
36
+ * broke upgrade version gating). Fallback: SKILL.md frontmatter.
31
37
  */
32
- function getLocalVersion(skillDir) {
33
- // Primary: SKILL.md frontmatter
34
- const parsed = parseSkillMd(skillDir);
35
- if (parsed?.frontmatter?.version) {
36
- return { success: true, version: String(parsed.frontmatter.version) };
37
- }
38
- // Fallback: package.json
38
+ export function getLocalVersion(skillDir) {
39
39
  const pkgPath = path.join(skillDir, 'package.json');
40
40
  try {
41
41
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
42
- if (pkg.version) {
43
- return { success: true, version: String(pkg.version) };
42
+ if (typeof pkg.version === 'string' && SEMVER_RE.test(pkg.version.trim())) {
43
+ return { success: true, version: pkg.version.trim() };
44
44
  }
45
45
  } catch {
46
- // package.json doesn't exist or is invalid
46
+ // package.json missing or invalid — fall through to SKILL.md
47
+ }
48
+ const parsed = parseSkillMd(skillDir);
49
+ if (parsed?.frontmatter?.version) {
50
+ return { success: true, version: String(parsed.frontmatter.version) };
47
51
  }
48
- return { success: false, error: 'Version not found in SKILL.md or package.json' };
52
+ return { success: false, error: 'Version not found in package.json or SKILL.md' };
49
53
  }
50
54
 
51
55
  /**
@@ -645,8 +649,27 @@ function truncateHookOutput(value, maxLength = 1000) {
645
649
  return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
646
650
  }
647
651
 
652
+ const A2A_CLI_RESTART_TIMEOUT_MS = 120000;
653
+
654
+ /**
655
+ * Resolve the baize-a2a scripts/cli.js for the K5 restart hook. The component's
656
+ * own skill dir wins (that is where `baize add` / web install put it); the
657
+ * fallback reuses cli/lib/a2a.js resolution (env override included).
658
+ */
659
+ function defaultResolveA2aCli(skillDir) {
660
+ const local = path.join(skillDir, 'scripts', 'cli.js');
661
+ if (fs.existsSync(local)) return local;
662
+ return a2aCliPath();
663
+ }
664
+
648
665
  /**
649
- * Step 8: restart PM2 service (if it was running before upgrade)
666
+ * Step 8: restart the component service (if it was running before upgrade).
667
+ *
668
+ * K5 restart hook: a nohup-started a2a daemon (Docker entrypoint) is invisible
669
+ * to pm2, so the pm2-only restart below never refreshed its code (C6). For
670
+ * service name 'baize-a2a' the a2a CLI `restart` (daemon-ctl: nohup pid +
671
+ * pm2 + waitReady) is preferred — it covers both lifecycles — falling back to
672
+ * the existing pm2 path when the CLI is unavailable or fails.
650
673
  */
651
674
  export function step8_startService(ctx, deps = {}) {
652
675
  const startTime = Date.now();
@@ -654,13 +677,30 @@ export function step8_startService(ctx, deps = {}) {
654
677
  const exists = deps.existsSync ?? fs.existsSync;
655
678
  const restartManaged = deps.restartManagedProcess ?? restartManagedProcess;
656
679
  const restartViaEcosystem = deps.restartFromEcosystem ?? restartFromEcosystem;
680
+ const spawnSyncFn = deps.spawnSyncFn ?? spawnSync;
681
+ const resolveA2aCli = deps.resolveA2aCli ?? defaultResolveA2aCli;
682
+
683
+ const parsed = parseSkillMd(ctx.skillDir);
684
+ const serviceName = parsed?.frontmatter?.lifecycle?.service?.name || `baize-${ctx.component}`;
685
+
686
+ if (serviceName === 'baize-a2a') {
687
+ const cli = resolveA2aCli(ctx.skillDir);
688
+ if (cli) {
689
+ const res = spawnSyncFn(process.execPath, [cli, 'restart', '--json'], {
690
+ input: '',
691
+ encoding: 'utf8',
692
+ timeout: A2A_CLI_RESTART_TIMEOUT_MS,
693
+ });
694
+ if (!res.error && res.status === 0) {
695
+ return { step: 8, name: 'start_service', status: 'done', message: `${serviceName} (a2a cli restart)`, duration: Date.now() - startTime };
696
+ }
697
+ }
698
+ }
657
699
 
658
700
  if (!ctx.serviceWasRunning) {
659
701
  return { step: 8, name: 'start_service', status: 'skipped', message: 'was not running', duration: Date.now() - startTime };
660
702
  }
661
703
 
662
- const parsed = parseSkillMd(ctx.skillDir);
663
- const serviceName = parsed?.frontmatter?.lifecycle?.service?.name || `baize-${ctx.component}`;
664
704
  const ecosystemPath = path.join(ctx.skillDir, 'ecosystem.config.cjs');
665
705
 
666
706
  try {
@@ -97,7 +97,6 @@ mkdir -p "${BAIZE_DIR}/.baize"
97
97
  printf '{"status":"ok","at":"%s"}\n' "$(date -Iseconds)" > "${BAIZE_DIR}/.baize/init-state.json"
98
98
 
99
99
 
100
- ok "Workspace ready"
101
100
 
102
101
  # ── Pass through channel env vars to .env ─────────────────────────────────────
103
102
  # baize init doesn't write channel tokens — those come from component installs.
@@ -153,25 +152,6 @@ PM2_PID=$!
153
152
  sleep 3
154
153
  ok "Services started"
155
154
 
156
- # ── Step 3b: A2A self-heal (D48) ─────────────────────────────────────────────
157
- # After a container restart, a previously-enabled A2A component must come back
158
- # up by itself (zero manual ops). If the A2A config says enabled, start the
159
- # baize-a2a daemon — it registers/renews its certificate with the admin and
160
- # serves the advertise port. Idempotent: pm2 start restarts an existing entry.
161
- if [ -f "${BAIZE_DIR}/components/a2a/config.json" ]; then
162
- A2A_ENABLED=$(node -e "
163
- try {
164
- const c = JSON.parse(require('fs').readFileSync('${BAIZE_DIR}/components/a2a/config.json','utf8'));
165
- process.stdout.write(c.enabled ? '1' : '0');
166
- } catch { process.stdout.write('0'); }
167
- " 2>/dev/null || echo "0")
168
- if [ "$A2A_ENABLED" = "1" ]; then
169
- ok "A2A enabled — starting baize-a2a daemon"
170
- ( cd "${BAIZE_DIR}/.claude/skills/a2a" && pm2 start ecosystem.config.cjs >/dev/null 2>&1 ) || \
171
- warn "A2A daemon start failed — check 'docker exec <c> pm2 logs baize-a2a'"
172
- fi
173
- fi
174
-
175
155
  # ── Step 4: Start the configured agent runtime in tmux ───────────────────────
176
156
 
177
157
  # ── Step 4: Start the configured agent runtime in tmux ───────────────────────
@@ -57,9 +57,27 @@ services:
57
57
 
58
58
  # ── Health ────────────────────────────────────────────────────────────────
59
59
  healthcheck:
60
- test: ["CMD", "pm2", "list"]
60
+ test:
61
+ - CMD
62
+ - node
63
+ - -e
64
+ - |
65
+ const fs = require('fs'), net = require('net');
66
+ // Core health = pm2 reports online. A2A (commercial component, D49):
67
+ // when installed and enabled, the container is only healthy if its
68
+ // daemon actually LISTENS (pm2 status alone masked the KI-015
69
+ // silent-idle shape); otherwise the check is skipped.
70
+ try { require('child_process').execSync('pm2 list', { stdio: 'ignore' }); } catch { process.exit(1); }
71
+ try {
72
+ const cfg = JSON.parse(fs.readFileSync('/home/baize/baize/components/a2a/config.json', 'utf8'));
73
+ if (cfg.enabled !== true) process.exit(0);
74
+ const s = net.connect({ host: '127.0.0.1', port: cfg.listenPort || 8443, timeout: 3000 });
75
+ s.on('connect', () => { s.destroy(); process.exit(0); });
76
+ s.on('error', () => process.exit(1));
77
+ s.on('timeout', () => { s.destroy(); process.exit(1); });
78
+ } catch { process.exit(0); }
61
79
  interval: 30s
62
- timeout: 10s
80
+ timeout: 15s
63
81
  retries: 3
64
82
  start_period: 600s
65
83
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@baize-ai/core",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "type": "module",
5
- "description": "Baize (\u767d\u6cfd) \u2014 autonomous AI agent infrastructure",
5
+ "description": "Baize (白泽) autonomous AI agent infrastructure",
6
6
  "main": "cli/baize.js",
7
7
  "bin": {
8
8
  "baize": "./cli/baize.js"
@@ -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"