@brunobracaioli/aibrain 0.3.0-beta.13 → 0.3.0-beta.15

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.
@@ -0,0 +1,506 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ chmod,
5
+ cp,
6
+ lstat,
7
+ mkdir,
8
+ mkdtemp,
9
+ readdir,
10
+ realpath,
11
+ rm,
12
+ writeFile,
13
+ } from 'node:fs/promises';
14
+ import { dirname, join, resolve } from 'node:path';
15
+ import { tmpdir } from 'node:os';
16
+ import { spawn } from 'node:child_process';
17
+
18
+ const MAX_OUTPUT_BYTES = 96 * 1024;
19
+ const PROBE_TIMEOUT_MS = 30_000;
20
+ const MAX_PLUGIN_ASSET_BYTES = 4 * 1024 * 1024;
21
+ const MAX_PLUGIN_TOTAL_BYTES = 16 * 1024 * 1024;
22
+ const HOST_MAJOR = 2;
23
+ const HOST_MINOR = 1;
24
+ const QUALIFIED_VERSIONS = new Set([
25
+ '2.1.294',
26
+ '2.1.295',
27
+ '2.1.296',
28
+ '2.1.299',
29
+ '2.1.300',
30
+ '2.1.301',
31
+ '2.1.302',
32
+ '2.1.303',
33
+ ]);
34
+ const REQUIRED_FLAGS = ['--settings', '--mcp-config', '--allowedTools', '--plugin-dir'];
35
+ const REQUIRED_INVENTORY = [
36
+ 'prompt.submit',
37
+ 'tool.call',
38
+ 'session.end',
39
+ 'turn.complete',
40
+ 'session.compact',
41
+ '$.mcp.call',
42
+ '$.process.run',
43
+ '$.session.id',
44
+ '$.session.messages',
45
+ '$.session.usage',
46
+ '$.session.append',
47
+ '$.session.compact',
48
+ '$.store.get',
49
+ '$.store.set',
50
+ '$.store.delete',
51
+ '$.store.keys',
52
+ '$.ui.log',
53
+ '$.ui.toast',
54
+ ];
55
+ const ALLOWED_PLUGIN_FILES = Object.freeze([
56
+ '.claude-plugin/plugin.json',
57
+ 'hooks/hooks.json',
58
+ 'hooks/context-compaction.ts',
59
+ 'hooks/provider-window.js',
60
+ 'hooks/state-fitting.mjs',
61
+ 'hooks/state-fitting.d.mts',
62
+ 'hooks/native-host-contract.test.ts',
63
+ 'hooks/native-host-contract-runtime.ts',
64
+ ]);
65
+
66
+ /** @param {string} executable */
67
+ function safePath(executable) {
68
+ return [...new Set([dirname(executable), dirname(process.execPath), '/usr/bin', '/bin'])].join(
69
+ ':',
70
+ );
71
+ }
72
+
73
+ /** @param {string} executable @returns {Promise<string>} */
74
+ async function readNativeVersion(executable) {
75
+ const root = await mkdtemp(join(tmpdir(), 'aibrain-claude-version-'));
76
+ const cwd = join(root, 'cwd');
77
+ const home = join(root, 'home');
78
+ const config = join(root, 'config');
79
+ await mkdir(cwd, { recursive: true, mode: 0o700 });
80
+ try {
81
+ /** @type {NodeJS.ProcessEnv} */
82
+ const environment = {
83
+ PATH: safePath(executable),
84
+ HOME: home,
85
+ CLAUDE_CONFIG_DIR: config,
86
+ CLAUDE_COMPAT_CWD: cwd,
87
+ NO_COLOR: '1',
88
+ CI: '1',
89
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
90
+ };
91
+ await mkdir(home, { recursive: true, mode: 0o700 });
92
+ await mkdir(config, { recursive: true, mode: 0o700 });
93
+ const result = await runBounded(executable, ['--version'], environment, {
94
+ timeoutMs: 10_000,
95
+ maxOutputBytes: 2_048,
96
+ });
97
+ if (result.code !== 0 || result.overflow || result.timedOut) return '';
98
+ return result.stdout.split(/\r?\n/u, 1)[0]?.trim() ?? '';
99
+ } finally {
100
+ try {
101
+ await rm(root, { recursive: true, force: true });
102
+ } catch {
103
+ throw new Error('probe_cleanup_failed');
104
+ }
105
+ }
106
+ }
107
+
108
+ /** @typedef {'qualified'|'host_checked'|'rejected'} ClaudeCompatibilityStatus */
109
+
110
+ /** @typedef {object} ClaudeCompatibility
111
+ * @property {ClaudeCompatibilityStatus} status
112
+ * @property {string|null} executable
113
+ * @property {string|null} version
114
+ * @property {string} reason
115
+ * @property {string[]} [checks]
116
+ */
117
+
118
+ /** @param {string} output */
119
+ export function parseClaudeStableVersion(output) {
120
+ const match = output
121
+ .trim()
122
+ .match(
123
+ /^(?:(?:claude(?:\s+code)?)\s+)?(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})(-[A-Za-z0-9.-]+)?(?:\s+\(Claude\s+Code\))?$/iu,
124
+ );
125
+ if (!match) return null;
126
+ return {
127
+ major: Number(match[1]),
128
+ minor: Number(match[2]),
129
+ patch: Number(match[3]),
130
+ prerelease: match[4] !== undefined,
131
+ text: `${match[1]}.${match[2]}.${match[3]}`,
132
+ };
133
+ }
134
+
135
+ /** @param {string} output */
136
+ function parseVersion(output) {
137
+ return parseClaudeStableVersion(output) ?? null;
138
+ }
139
+
140
+ /** @param {string} executable @param {string[]} args @param {NodeJS.ProcessEnv} environment @param {{timeoutMs?:number,maxOutputBytes?:number}} [limits] */
141
+ function runBounded(executable, args, environment, limits = {}) {
142
+ const timeoutMs = limits.timeoutMs ?? PROBE_TIMEOUT_MS;
143
+ const maxOutputBytes = limits.maxOutputBytes ?? MAX_OUTPUT_BYTES;
144
+ return new Promise((resolvePromise) => {
145
+ const child = spawn(executable, args, {
146
+ cwd: environment.CLAUDE_COMPAT_CWD,
147
+ env: environment,
148
+ stdio: ['ignore', 'pipe', 'pipe'],
149
+ detached: process.platform !== 'win32',
150
+ });
151
+ let stdout = '';
152
+ let stderr = '';
153
+ let bytes = 0;
154
+ let overflow = false;
155
+ let settled = false;
156
+ let timedOut = false;
157
+ const timer = setTimeout(() => {
158
+ timedOut = true;
159
+ if (process.platform !== 'win32' && child.pid !== undefined) {
160
+ try {
161
+ process.kill(-child.pid, 'SIGKILL');
162
+ } catch {
163
+ child.kill('SIGKILL');
164
+ }
165
+ } else child.kill('SIGKILL');
166
+ }, timeoutMs);
167
+ /** @param {string} target @param {Buffer} chunk */
168
+ const append = (target, chunk) => {
169
+ bytes += chunk.byteLength;
170
+ if (bytes > maxOutputBytes) {
171
+ overflow = true;
172
+ return target;
173
+ }
174
+ return `${target}${chunk.toString('utf8')}`;
175
+ };
176
+ /** @param {number|null} code */
177
+ const finish = (code) => {
178
+ if (settled) return;
179
+ settled = true;
180
+ clearTimeout(timer);
181
+ resolvePromise({ code, stdout, stderr, overflow, timedOut });
182
+ };
183
+ child.stdout.on('data', (chunk) => {
184
+ stdout = append(stdout, chunk);
185
+ });
186
+ child.stderr.on('data', (chunk) => {
187
+ stderr = append(stderr, chunk);
188
+ });
189
+ child.once('error', () => finish(null));
190
+ child.once('close', (code) => finish(code));
191
+ });
192
+ }
193
+
194
+ /** @param {string} root @param {string} relativePath */
195
+ async function assertNoSymlinkPath(root, relativePath) {
196
+ let current = resolve(root);
197
+ const parts = relativePath.split('/');
198
+ for (const part of parts) {
199
+ current = join(current, part);
200
+ const info = await lstat(current);
201
+ if (info.isSymbolicLink()) throw new Error('plugin_asset_symlink_rejected');
202
+ }
203
+ }
204
+
205
+ /** @param {string} source @param {string} destination */
206
+ async function copyAllowlistedPlugin(source, destination) {
207
+ await mkdir(destination, { recursive: true, mode: 0o700 });
208
+ let totalBytes = 0;
209
+ for (const file of ALLOWED_PLUGIN_FILES) {
210
+ const input = join(source, file);
211
+ await assertNoSymlinkPath(source, file);
212
+ const info = await lstat(input);
213
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('plugin_asset_invalid');
214
+ const uid = process.getuid?.();
215
+ if (uid !== undefined && info.uid !== uid) throw new Error('plugin_asset_owner_mismatch');
216
+ if (info.size > MAX_PLUGIN_ASSET_BYTES) throw new Error('plugin_asset_too_large');
217
+ totalBytes += info.size;
218
+ if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) throw new Error('plugin_assets_too_large');
219
+ const output = join(destination, file);
220
+ await mkdir(dirname(output), { recursive: true, mode: 0o700 });
221
+ await cp(input, output, { force: true, errorOnExist: true, verbatimSymlinks: true });
222
+ await chmod(output, 0o600);
223
+ }
224
+ }
225
+
226
+ /** @param {string} directory */
227
+ async function assertEmptyDirectory(directory) {
228
+ const entries = await readdir(directory);
229
+ if (entries.length !== 0) throw new Error('probe_directory_not_empty');
230
+ }
231
+
232
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
233
+ function record(value) {
234
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
235
+ }
236
+
237
+ /** @param {unknown} value @returns {value is string[]} */
238
+ function stringArray(value) {
239
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
240
+ }
241
+
242
+ /** @param {string} executable @param {string} pluginRoot @param {{timeoutMs?:number,maxOutputBytes?:number}} [limits] */
243
+ async function runHostProbe(executable, pluginRoot, limits = {}) {
244
+ const root = await mkdtemp(join(tmpdir(), 'aibrain-claude-host-'));
245
+ const home = join(root, 'home');
246
+ const config = join(root, 'config');
247
+ const cwd = join(root, 'cwd');
248
+ const copiedPlugin = join(root, 'plugin');
249
+ const contractPlugin = join(root, 'contract-plugin');
250
+ await Promise.all([
251
+ mkdir(home, { recursive: true, mode: 0o700 }),
252
+ mkdir(config, { recursive: true, mode: 0o700 }),
253
+ mkdir(cwd, { recursive: true, mode: 0o700 }),
254
+ ]);
255
+ try {
256
+ await copyAllowlistedPlugin(pluginRoot, copiedPlugin);
257
+ await copyAllowlistedPlugin(pluginRoot, contractPlugin);
258
+ await writeFile(
259
+ join(contractPlugin, 'hooks', 'hooks.json'),
260
+ JSON.stringify({ modules: ['./native-host-contract-runtime.ts'] }),
261
+ { mode: 0o600 },
262
+ );
263
+ /** @type {NodeJS.ProcessEnv} */
264
+ const environment = {
265
+ PATH: safePath(executable),
266
+ HOME: home,
267
+ CLAUDE_CONFIG_DIR: config,
268
+ CLAUDE_COMPAT_CWD: cwd,
269
+ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: '1',
270
+ AIBRAIN_NATIVE_CONTRACT: '1',
271
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1',
272
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
273
+ NO_COLOR: '1',
274
+ CI: '1',
275
+ };
276
+ const help = await runBounded(executable, ['--help'], environment, limits);
277
+ if (
278
+ help.code !== 0 ||
279
+ help.overflow ||
280
+ REQUIRED_FLAGS.some((flag) => !help.stdout.includes(flag))
281
+ )
282
+ return { ok: false, reason: 'launch_flags_missing' };
283
+ const validate = await runBounded(
284
+ executable,
285
+ ['plugin', 'validate', '--strict', '--json', copiedPlugin],
286
+ environment,
287
+ limits,
288
+ );
289
+ if (validate.code !== 0 || validate.overflow)
290
+ return { ok: false, reason: 'plugin_validation_failed' };
291
+ /** @type {Record<string, unknown>} */
292
+ let report;
293
+ try {
294
+ const parsed = JSON.parse(validate.stdout);
295
+ if (!record(parsed)) return { ok: false, reason: 'plugin_validation_malformed' };
296
+ report = parsed;
297
+ } catch {
298
+ return { ok: false, reason: 'plugin_validation_malformed' };
299
+ }
300
+ if (report.success !== true || report.strict !== true)
301
+ return { ok: false, reason: 'plugin_validation_failed' };
302
+ if (
303
+ !record(report.manifest) ||
304
+ !stringArray(report.manifest.errors) ||
305
+ !stringArray(report.manifest.warnings)
306
+ )
307
+ return { ok: false, reason: 'plugin_validation_malformed' };
308
+ if (
309
+ !Array.isArray(report.contents) ||
310
+ report.contents.length !== 1 ||
311
+ !report.contents.every(record)
312
+ )
313
+ return { ok: false, reason: 'plugin_validation_malformed' };
314
+ const contents = report.contents;
315
+ if (
316
+ !contents.every(
317
+ (item) => stringArray(item.errors) && stringArray(item.warnings) && stringArray(item.notes),
318
+ )
319
+ )
320
+ return { ok: false, reason: 'plugin_validation_malformed' };
321
+ const manifest = report.manifest;
322
+ const errors = [
323
+ ...(Array.isArray(manifest.errors) ? manifest.errors : []),
324
+ ...(Array.isArray(manifest.warnings) ? manifest.warnings : []),
325
+ ...contents.flatMap((item) => [
326
+ ...(Array.isArray(item.errors) ? item.errors : []),
327
+ ...(Array.isArray(item.warnings) ? item.warnings : []),
328
+ ]),
329
+ ];
330
+ if (errors.length > 0) return { ok: false, reason: 'plugin_validation_failed' };
331
+ const notes = contents.flatMap((item) => item.notes);
332
+ const hookNote = notes.find((note) => typeof note === 'string' && note.includes(' hooks: '));
333
+ const callNote = notes.find((note) => typeof note === 'string' && note.includes(' calls: '));
334
+ const hooks = new Set(
335
+ typeof hookNote === 'string'
336
+ ? hookNote
337
+ .split(' hooks: ', 2)[1]
338
+ .split(', ')
339
+ .map((item) => item.trim())
340
+ : [],
341
+ );
342
+ const calls = new Set(
343
+ typeof callNote === 'string'
344
+ ? callNote
345
+ .split(' calls: ', 2)[1]
346
+ .replace(/\s+\(via[^)]*\)/gu, '')
347
+ .split(', ')
348
+ .map((item) => item.trim())
349
+ : [],
350
+ );
351
+ const inventory = new Set([...hooks, ...calls]);
352
+ if (inventory.size === 0 || REQUIRED_INVENTORY.some((item) => !inventory.has(item)))
353
+ return { ok: false, reason: 'host_inventory_incomplete' };
354
+ const test = await runBounded(
355
+ executable,
356
+ ['plugin', 'test', contractPlugin],
357
+ environment,
358
+ limits,
359
+ );
360
+ const passMatch = (test.stdout + test.stderr).match(/(?:^|\s)(\d+)\s+pass(?:ed)?(?:\s|$)/iu);
361
+ const failMatch = (test.stdout + test.stderr).match(/(?:^|\s)(\d+)\s+fail(?:ed)?(?:\s|$)/iu);
362
+ if (test.timedOut) return { ok: false, reason: 'native_plugin_tests_failed' };
363
+ if (test.code !== 0) return { ok: false, reason: 'native_plugin_tests_failed' };
364
+ if (test.overflow) return { ok: false, reason: 'native_plugin_tests_failed' };
365
+ if (passMatch === null || Number(passMatch[1]) < 1)
366
+ return { ok: false, reason: 'native_plugin_tests_failed' };
367
+ if (failMatch === null || Number(failMatch[1]) !== 0)
368
+ return { ok: false, reason: 'native_plugin_tests_failed' };
369
+ if (
370
+ !test.stdout.includes(
371
+ 'AI Brain enabled host contract invokes usage and native compaction through the dispatcher',
372
+ )
373
+ )
374
+ return { ok: false, reason: 'native_plugin_tests_failed' };
375
+ await assertEmptyDirectory(cwd);
376
+ return {
377
+ ok: true,
378
+ checks: [
379
+ 'launch_flags',
380
+ 'plugin_validate_strict',
381
+ 'native_plugin_test',
382
+ 'isolated_filesystem',
383
+ ],
384
+ };
385
+ } catch (error) {
386
+ const reason = error instanceof Error ? error.message : '';
387
+ const allowedReasons = new Set([
388
+ 'plugin_asset_invalid',
389
+ 'plugin_asset_symlink_rejected',
390
+ 'plugin_asset_owner_mismatch',
391
+ 'plugin_asset_too_large',
392
+ 'plugin_assets_too_large',
393
+ 'probe_directory_not_empty',
394
+ ]);
395
+ return { ok: false, reason: allowedReasons.has(reason) ? reason : 'host_probe_failed' };
396
+ } finally {
397
+ try {
398
+ await rm(root, { recursive: true, force: true });
399
+ } catch {
400
+ throw new Error('probe_cleanup_failed');
401
+ }
402
+ }
403
+ }
404
+
405
+ /** @param {string} executable @param {string} packageRoot */
406
+ /** @param {string} executable @param {string} packageRoot @returns {Promise<ClaudeCompatibility>} */
407
+ export async function resolveClaudeCompatibility(executable, packageRoot) {
408
+ let pinnedExecutable;
409
+ try {
410
+ pinnedExecutable = await realpath(executable);
411
+ } catch {
412
+ return {
413
+ status: 'rejected',
414
+ executable: null,
415
+ version: null,
416
+ reason: 'executable_unavailable',
417
+ };
418
+ }
419
+ let raw;
420
+ try {
421
+ raw = await readNativeVersion(pinnedExecutable);
422
+ } catch {
423
+ return {
424
+ status: 'rejected',
425
+ executable: pinnedExecutable,
426
+ version: null,
427
+ reason: 'version_probe_cleanup_failed',
428
+ };
429
+ }
430
+ const version = parseVersion(raw);
431
+ if (!version)
432
+ return {
433
+ status: 'rejected',
434
+ executable: pinnedExecutable,
435
+ version: null,
436
+ reason: 'version_unavailable',
437
+ };
438
+ if (version.prerelease)
439
+ return {
440
+ status: 'rejected',
441
+ executable: pinnedExecutable,
442
+ version: version.text,
443
+ reason: 'prerelease',
444
+ };
445
+ if (version.major !== HOST_MAJOR || version.minor !== HOST_MINOR) {
446
+ return {
447
+ status: 'rejected',
448
+ executable: pinnedExecutable,
449
+ version: version.text,
450
+ reason: 'major_minor_unsupported',
451
+ };
452
+ }
453
+ if (QUALIFIED_VERSIONS.has(version.text))
454
+ return {
455
+ status: 'qualified',
456
+ executable: pinnedExecutable,
457
+ version: version.text,
458
+ reason: 'known_stable',
459
+ };
460
+ if (version.patch < 303)
461
+ return {
462
+ status: 'rejected',
463
+ executable: pinnedExecutable,
464
+ version: version.text,
465
+ reason: 'unsupported_stable_patch',
466
+ };
467
+ let probe;
468
+ try {
469
+ probe = await runHostProbe(pinnedExecutable, join(resolve(packageRoot), 'assets', 'claude'));
470
+ } catch {
471
+ return {
472
+ status: 'rejected',
473
+ executable: pinnedExecutable,
474
+ version: version.text,
475
+ reason: 'host_probe_cleanup_failed',
476
+ };
477
+ }
478
+ if (!probe.ok)
479
+ return {
480
+ status: 'rejected',
481
+ executable: pinnedExecutable,
482
+ version: version.text,
483
+ reason: probe.reason ?? 'host_probe_failed',
484
+ };
485
+ return {
486
+ status: 'host_checked',
487
+ executable: pinnedExecutable,
488
+ version: version.text,
489
+ reason: 'native_host_contract',
490
+ ...(probe.checks === undefined ? {} : { checks: probe.checks }),
491
+ };
492
+ }
493
+
494
+ /** @param {ClaudeCompatibility} decision */
495
+ export function claudeCompatibilityError(decision) {
496
+ const detected = decision.version ?? 'unavailable';
497
+ return `claude_version_unverified: Claude Code detected version ${detected}; reason ${decision.reason}; run aibrain doctor --json or install a compatible stable native version.`;
498
+ }
499
+
500
+ export const claudeCompatibilityInternals = Object.freeze({
501
+ REQUIRED_FLAGS,
502
+ REQUIRED_INVENTORY,
503
+ ALLOWED_PLUGIN_FILES,
504
+ parseVersion,
505
+ runHostProbe,
506
+ });
package/dist/cli.mjs CHANGED
@@ -15,21 +15,16 @@ import {
15
15
  stateRoot,
16
16
  tokenDigest,
17
17
  } from './state.mjs';
18
- import {
19
- isTestedNativeVersion,
20
- nativeVersion,
21
- nativeVersionDiagnostic,
22
- platformCheck,
23
- resolveNative,
24
- } from './platform.mjs';
18
+ import { nativeVersion, platformCheck, resolveNative } from './platform.mjs';
25
19
  import { launchClaude, launchCodex } from './launch.mjs';
20
+ import { claudeCompatibilityError, resolveClaudeCompatibility } from './claude-compatibility.mjs';
26
21
  import { codexCompatibilityError, resolveCodexCompatibility } from './codex-compatibility.mjs';
27
22
  import { providerFreeProbe } from './mcp.mjs';
28
23
  import { codexPolicyJson, makePolicy, publicPolicy, isProfile } from './policy.mjs';
29
24
  import { readDiagnostics } from './claude-observation.mjs';
30
25
  import { readCodexDiagnostics } from './codex-diagnostics.mjs';
31
26
 
32
- const VERSION = '0.3.0-beta.13';
27
+ const VERSION = '0.3.0-beta.15';
33
28
  const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
34
29
  const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url));
35
30
  const LEGACY_DISCLOSURE_VERSION = 'aibrain-paid-context-v1-2026-10-09';
@@ -444,10 +439,13 @@ async function setup(args) {
444
439
  if ((clients === 'claude' || clients === 'both') && !claude) throw new Error('claude_not_found');
445
440
  if ((clients === 'codex' || clients === 'both') && (!codex || !node))
446
441
  throw new Error('codex_or_node_not_found');
447
- const claudeVersion = claude ? nativeVersion(claude) : null;
448
- const claudeSupported = claudeVersion !== null && isTestedNativeVersion('claude', claudeVersion);
449
- if ((clients === 'claude' || clients === 'both') && !claudeSupported)
450
- throw new Error(nativeVersionDiagnostic('claude', claudeVersion ?? ''));
442
+ const claudeCompatibility =
443
+ (clients === 'claude' || clients === 'both') && claude !== null
444
+ ? await resolveClaudeCompatibility(claude, PACKAGE_ROOT)
445
+ : null;
446
+ if (claudeCompatibility?.status === 'rejected')
447
+ throw new Error(claudeCompatibilityError(claudeCompatibility));
448
+ const claudeVersion = claudeCompatibility?.version ?? (claude ? nativeVersion(claude) : null);
451
449
  let codexCompatibility = null;
452
450
  if (clients === 'codex' || clients === 'both') {
453
451
  codexCompatibility = codex ? await resolveCodexCompatibility(codex) : null;
@@ -519,12 +517,14 @@ async function doctor(json) {
519
517
  codex: resolveNative('codex'),
520
518
  node: resolveNative('node'),
521
519
  };
520
+ const claudeCompatibility =
521
+ native.claude !== null ? await resolveClaudeCompatibility(native.claude, PACKAGE_ROOT) : null;
522
+ const codexCompatibility =
523
+ native.codex !== null ? await resolveCodexCompatibility(native.codex) : null;
522
524
  const rawNativeVersions = {
523
- claude: native.claude ? nativeVersion(native.claude) : '',
525
+ claude: claudeCompatibility?.version ?? '',
524
526
  codex: native.codex ? nativeVersion(native.codex) : '',
525
527
  };
526
- const codexCompatibility =
527
- native.codex !== null ? await resolveCodexCompatibility(native.codex) : null;
528
528
  const nativeVersions = {
529
529
  ...rawNativeVersions,
530
530
  codex: codexCompatibility?.version ?? rawNativeVersions.codex,
@@ -533,7 +533,9 @@ async function doctor(json) {
533
533
  const needsCodex = config?.clientSelection === 'codex' || config?.clientSelection === 'both';
534
534
  const nativeReady =
535
535
  (!needsClaude ||
536
- (native.claude !== null && isTestedNativeVersion('claude', nativeVersions.claude))) &&
536
+ (native.claude !== null &&
537
+ (claudeCompatibility?.status === 'qualified' ||
538
+ claudeCompatibility?.status === 'host_checked'))) &&
537
539
  (!needsCodex ||
538
540
  (native.codex !== null &&
539
541
  native.node !== null &&
@@ -552,6 +554,9 @@ async function doctor(json) {
552
554
  node: native.node !== null,
553
555
  },
554
556
  nativeVersions,
557
+ claudeCompatibility: claudeCompatibility?.status ?? 'unavailable',
558
+ claudeCompatibilityReason: claudeCompatibility?.reason ?? 'claude_not_found',
559
+ claudeCompatibilityVersion: claudeCompatibility?.version ?? null,
555
560
  codexCompatibility: codexCompatibility?.status ?? 'unavailable',
556
561
  codexCompatibilityReason: codexCompatibility?.reason ?? 'codex_not_found',
557
562
  codexCompatibilityVersion: codexCompatibility?.version ?? null,
@@ -663,17 +668,8 @@ async function main(argv) {
663
668
  throw new Error('disclosure_outdated');
664
669
  if (config.clientSelection !== command && config.clientSelection !== 'both')
665
670
  throw new Error('client_not_configured');
666
- if (
667
- command === 'claude' &&
668
- (typeof config.claudeVersion !== 'string' ||
669
- !isTestedNativeVersion('claude', config.claudeVersion))
670
- )
671
- throw new Error(
672
- nativeVersionDiagnostic(
673
- 'claude',
674
- typeof config.claudeVersion === 'string' ? config.claudeVersion : '',
675
- ),
676
- );
671
+ // The native executable is authoritative. The stored setup version is
672
+ // metadata only, so patch updates do not block a fresh compatibility check.
677
673
  if (command === 'codex' && typeof config.codexVersion !== 'string')
678
674
  throw new Error('codex_version_unverified');
679
675
  const root = stateRoot();
@@ -2,11 +2,28 @@
2
2
 
3
3
  import { createHash } from 'node:crypto';
4
4
  import { chmod, lstat, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises';
5
- import { constants } from 'node:fs';
5
+ import { constants, readFileSync } from 'node:fs';
6
6
  import { join, resolve } from 'node:path';
7
7
 
8
8
  export const CODEX_DIAGNOSTIC_SCHEMA_VERSION = 1;
9
- export const CODEX_DIAGNOSTIC_ADAPTER_VERSION = '0.3.0-beta.13';
9
+ const PACKAGE_VERSION_PATTERN =
10
+ /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u;
11
+ function packageVersion() {
12
+ for (const packageUrl of [
13
+ new URL('./package.json', import.meta.url),
14
+ new URL('../package.json', import.meta.url),
15
+ ]) {
16
+ try {
17
+ const value = JSON.parse(readFileSync(packageUrl, 'utf8'));
18
+ if (typeof value?.version === 'string' && PACKAGE_VERSION_PATTERN.test(value.version))
19
+ return value.version;
20
+ } catch {
21
+ // Source and packaged dist use different relative package metadata locations.
22
+ }
23
+ }
24
+ throw new Error('codex_diagnostics_package_version_unavailable');
25
+ }
26
+ export const CODEX_DIAGNOSTIC_ADAPTER_VERSION = packageVersion();
10
27
  export const CODEX_DIAGNOSTIC_MAX_LOG_BYTES = 512 * 1024;
11
28
  export const CODEX_DIAGNOSTIC_MAX_LOG_FILES = 3;
12
29
  export const CODEX_DIAGNOSTIC_MAX_RECORDS = 128;
package/dist/launch.mjs CHANGED
@@ -4,7 +4,8 @@ import { access, chmod, lstat, mkdir, mkdtemp, open, rm, writeFile } from 'node:
4
4
  import { spawn } from 'node:child_process';
5
5
  import { constants } from 'node:fs';
6
6
  import { isAbsolute, join, resolve, sep } from 'node:path';
7
- import { assertTestedNativeVersion, nativeVersion, resolveNative } from './platform.mjs';
7
+ import { nativeVersion, resolveNative } from './platform.mjs';
8
+ import { claudeCompatibilityError, resolveClaudeCompatibility } from './claude-compatibility.mjs';
8
9
  import { codexCompatibilityError, resolveCodexCompatibility } from './codex-compatibility.mjs';
9
10
  import {
10
11
  claudeCheckpointConfigBinding,
@@ -407,8 +408,14 @@ export async function launchClaude(config, root, args) {
407
408
  assertClaudeArgs(launchArgs.args);
408
409
  const executable = resolveNative('claude');
409
410
  if (!executable) throw new Error('claude_not_found');
410
- const currentVersion = nativeVersion(executable);
411
- assertTestedNativeVersion('claude', currentVersion);
411
+ const compatibility = await resolveClaudeCompatibility(executable, validated.packageRoot);
412
+ if (compatibility.status === 'rejected') throw new Error(claudeCompatibilityError(compatibility));
413
+ const pinnedExecutable = compatibility.executable ?? executable;
414
+ if (compatibility.status === 'host_checked')
415
+ process.stderr.write(
416
+ `aibrain: Claude ${compatibility.version ?? 'unknown'} accepted by the local native host contract; end-to-end recovery remains separately qualified.\n`,
417
+ );
418
+ const currentVersion = compatibility.version ?? nativeVersion(pinnedExecutable);
412
419
  const pluginRoot = await assertClaudeAssets(validated.packageRoot);
413
420
  const telemetryActive =
414
421
  validated.telemetry?.enabled === true && validated.telemetry?.disclosedAt != null;
@@ -533,7 +540,7 @@ export async function launchClaude(config, root, args) {
533
540
  let child;
534
541
  try {
535
542
  child = spawn(
536
- executable,
543
+ pinnedExecutable,
537
544
  [
538
545
  '--settings',
539
546
  settingsPath,
package/dist/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.3.0-beta.13",
3
+ "version": "0.3.0-beta.15",
4
4
  "type": "module"
5
5
  }