@brunobracaioli/aibrain 0.2.0-beta.1 → 0.3.0-beta.13

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,467 @@
1
+ // @ts-check
2
+
3
+ import { createHash } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { mkdtemp, open, rm, lstat } from 'node:fs/promises';
6
+ import { spawn } from 'node:child_process';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import fingerprint from './codex-protocol-fingerprint.json' with { type: 'json' };
10
+
11
+ const MAX_OUTPUT_BYTES = 64 * 1024;
12
+ const MAX_SCHEMA_BYTES = 8 * 1024 * 1024;
13
+ const MAX_SCHEMA_SET_BYTES = 24 * 1024 * 1024;
14
+ const MAX_SCHEMA_DEPTH = 96;
15
+ const MAX_SCHEMA_NODES = 250_000;
16
+ const ROOTS = Object.keys(fingerprint.roots);
17
+ const PAGINATION_CONTRACT = fingerprint.consumed_pagination ?? null;
18
+ const PAGINATION_CONTRACT_VALID =
19
+ PAGINATION_CONTRACT === null ||
20
+ (PAGINATION_CONTRACT.method === 'thread/turns/list' &&
21
+ PAGINATION_CONTRACT.request_schema === 'v2/ThreadTurnsListParams.json' &&
22
+ PAGINATION_CONTRACT.response_schema === 'v2/ThreadTurnsListResponse.json' &&
23
+ PAGINATION_CONTRACT.request_schema_sha256 ===
24
+ fingerprint.roots[PAGINATION_CONTRACT.request_schema]?.sha256 &&
25
+ PAGINATION_CONTRACT.response_schema_sha256 ===
26
+ fingerprint.roots[PAGINATION_CONTRACT.response_schema]?.sha256 &&
27
+ typeof PAGINATION_CONTRACT.graph_sha256 === 'string' &&
28
+ PAGINATION_CONTRACT.graph_sha256.length === 64);
29
+ const QUALIFIED_VERSIONS = new Set(['0.161.0', '0.162.0', '0.162.1', '0.163.0']);
30
+ const ANNOTATIONS = new Set([
31
+ '$comment',
32
+ 'title',
33
+ 'description',
34
+ 'examples',
35
+ 'deprecated',
36
+ 'readOnly',
37
+ 'writeOnly',
38
+ 'externalDocs',
39
+ ]);
40
+ const SCHEMA_MAP_KEYS = new Set([
41
+ 'properties',
42
+ 'definitions',
43
+ '$defs',
44
+ 'patternProperties',
45
+ 'dependentSchemas',
46
+ ]);
47
+ const LITERAL_KEYS = new Set(['enum', 'const', 'default']);
48
+ /** @typedef {Record<string, unknown>} JsonObject */
49
+
50
+ /** @param {unknown} value @param {boolean} [map] @param {boolean} [literal] @returns {unknown} */
51
+ function normalize(value, map = false, literal = false) {
52
+ if (literal) return value;
53
+ if (Array.isArray(value)) return value.map((item) => normalize(item));
54
+ if (value === null || typeof value !== 'object') return value;
55
+ /** @type {JsonObject} */
56
+ const output = {};
57
+ for (const [key, child] of Object.entries(value)) {
58
+ if (!map && ANNOTATIONS.has(key)) continue;
59
+ if (LITERAL_KEYS.has(key)) {
60
+ output[key] = normalize(child, false, true);
61
+ } else if (
62
+ SCHEMA_MAP_KEYS.has(key) &&
63
+ child &&
64
+ typeof child === 'object' &&
65
+ !Array.isArray(child)
66
+ ) {
67
+ output[key] = Object.fromEntries(
68
+ Object.entries(child).map(([name, schema]) => [name, normalize(schema)]),
69
+ );
70
+ } else output[key] = normalize(child);
71
+ }
72
+ return output;
73
+ }
74
+
75
+ /** @param {unknown} value @returns {string} */
76
+ function canonical(value) {
77
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
78
+ if (value && typeof value === 'object') {
79
+ const object = /** @type {JsonObject} */ (value);
80
+ return `{${Object.keys(object)
81
+ .sort()
82
+ .map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`)
83
+ .join(',')}}`;
84
+ }
85
+ return JSON.stringify(value);
86
+ }
87
+
88
+ /** @param {unknown} value */
89
+ function sha(value) {
90
+ return createHash('sha256').update(canonical(value), 'utf8').digest('hex');
91
+ }
92
+
93
+ /** @param {string} output */
94
+ function parseStableVersion(output) {
95
+ const match = output
96
+ .trim()
97
+ .match(
98
+ /^(?:codex(?:-cli)?\s+)?(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})(-[A-Za-z0-9.-]+)?$/iu,
99
+ );
100
+ if (!match) return null;
101
+ return {
102
+ major: Number(match[1]),
103
+ minor: Number(match[2]),
104
+ patch: Number(match[3]),
105
+ prerelease: match[4] !== undefined,
106
+ text: `${match[1]}.${match[2]}.${match[3]}`,
107
+ };
108
+ }
109
+
110
+ /** @param {unknown} value */
111
+ function assertBoundedSchema(value) {
112
+ const state = { nodes: 0 };
113
+ /** @param {unknown} current @param {number} depth */
114
+ function visit(current, depth) {
115
+ state.nodes += 1;
116
+ if (depth > MAX_SCHEMA_DEPTH || state.nodes > MAX_SCHEMA_NODES)
117
+ throw new Error('schema_shape_too_large');
118
+ if (Array.isArray(current)) {
119
+ for (const item of current) visit(item, depth + 1);
120
+ } else if (current && typeof current === 'object') {
121
+ for (const child of Object.values(current)) visit(child, depth + 1);
122
+ }
123
+ }
124
+ visit(value, 0);
125
+ }
126
+
127
+ /** @param {JsonObject} parsed @param {string} reference @returns {unknown} */
128
+ function localDefinition(parsed, reference) {
129
+ const prefix = reference.startsWith('#/definitions/') ? '#/definitions/' : '#/$defs/';
130
+ const name = reference.slice(prefix.length);
131
+ let current = prefix === '#/definitions/' ? parsed.definitions : parsed.$defs;
132
+ for (const part of name.split('/')) {
133
+ if (part === '__proto__' || part === 'prototype' || part === 'constructor') return undefined;
134
+ if (!current || typeof current !== 'object' || Array.isArray(current)) return undefined;
135
+ current = /** @type {JsonObject} */ (current)[part];
136
+ }
137
+ return current;
138
+ }
139
+
140
+ /** @param {JsonObject} target @param {string} reference @param {unknown} value */
141
+ function setLocalDefinition(target, reference, value) {
142
+ const prefix = reference.startsWith('#/definitions/') ? '#/definitions/' : '#/$defs/';
143
+ const parts = reference.slice(prefix.length).split('/');
144
+ let current = target;
145
+ for (const part of parts.slice(0, -1)) {
146
+ if (part === '__proto__' || part === 'prototype' || part === 'constructor')
147
+ throw new Error('schema_reference_unsafe');
148
+ if (!current[part] || typeof current[part] !== 'object' || Array.isArray(current[part]))
149
+ current[part] = {};
150
+ current = /** @type {JsonObject} */ (current[part]);
151
+ }
152
+ if (
153
+ parts.at(-1) === '__proto__' ||
154
+ parts.at(-1) === 'prototype' ||
155
+ parts.at(-1) === 'constructor'
156
+ )
157
+ throw new Error('schema_reference_unsafe');
158
+ current[parts.at(-1) ?? ''] = value;
159
+ }
160
+
161
+ /** @param {string} path @param {number} maximumBytes @returns {Promise<string>} */
162
+ async function readBoundedFile(path, maximumBytes) {
163
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
164
+ try {
165
+ const info = await handle.stat();
166
+ if (!info.isFile() || info.size > maximumBytes) throw new Error('schema_file_too_large');
167
+ const buffer = Buffer.alloc(info.size);
168
+ let offset = 0;
169
+ while (offset < buffer.length) {
170
+ const result = await handle.read(buffer, offset, buffer.length - offset, offset);
171
+ if (result.bytesRead === 0) break;
172
+ offset += result.bytesRead;
173
+ }
174
+ if (offset !== info.size) throw new Error('schema_file_short_read');
175
+ return buffer.toString('utf8');
176
+ } finally {
177
+ await handle.close();
178
+ }
179
+ }
180
+
181
+ /** @param {string} path */
182
+ async function assertOwnedDirectory(path) {
183
+ const info = await lstat(path);
184
+ const uid = process.getuid?.();
185
+ if (!info.isDirectory() || info.isSymbolicLink() || (uid !== undefined && info.uid !== uid))
186
+ throw new Error('schema_directory_invalid');
187
+ }
188
+
189
+ /** @param {string} executable @param {string[]} args @param {NodeJS.ProcessEnv} environment */
190
+ function runBounded(executable, args, environment) {
191
+ return new Promise((resolvePromise) => {
192
+ const child = spawn(executable, args, {
193
+ cwd: environment.CODEX_COMPAT_CWD,
194
+ env: environment,
195
+ stdio: ['ignore', 'pipe', 'pipe'],
196
+ detached: process.platform !== 'win32',
197
+ });
198
+ let output = '';
199
+ let error = '';
200
+ const outputState = { bytes: 0, overflow: false };
201
+ const errorState = { bytes: 0, overflow: false };
202
+ let settled = false;
203
+ /** @type {NodeJS.Timeout|undefined} */
204
+ let timeoutTimer;
205
+ /** @param {number|null} code @param {boolean} [timedOut] */
206
+ const finish = (code, timedOut = false) => {
207
+ if (settled) return;
208
+ settled = true;
209
+ clearTimeout(timer);
210
+ clearTimeout(timeoutTimer);
211
+ resolvePromise({
212
+ code,
213
+ output,
214
+ error,
215
+ outputOverflow: outputState.overflow,
216
+ errorOverflow: errorState.overflow,
217
+ timedOut,
218
+ });
219
+ };
220
+ const killOwnedProcess = () => {
221
+ if (child.pid === undefined) return;
222
+ try {
223
+ if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL');
224
+ else child.kill('SIGKILL');
225
+ } catch {
226
+ try {
227
+ child.kill('SIGKILL');
228
+ } catch {
229
+ // The owned child may have exited between the group and direct kill.
230
+ }
231
+ }
232
+ };
233
+ const timer = setTimeout(() => {
234
+ killOwnedProcess();
235
+ timeoutTimer = setTimeout(() => finish(null, true), 1_000);
236
+ }, 15_000);
237
+ /** @param {string} target @param {Buffer|string} chunk @param {{bytes:number, overflow:boolean}} state */
238
+ const collect = (target, chunk, state) => {
239
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
240
+ const remaining = MAX_OUTPUT_BYTES - state.bytes;
241
+ if (remaining <= 0) {
242
+ state.overflow = true;
243
+ return target;
244
+ }
245
+ if (bytes.length > remaining) state.overflow = true;
246
+ const accepted = bytes.subarray(0, remaining);
247
+ state.bytes += accepted.length;
248
+ return `${target}${accepted.toString('utf8')}`;
249
+ };
250
+ child.stdout.on('data', (chunk) => {
251
+ output = collect(output, chunk, outputState);
252
+ });
253
+ child.stderr.on('data', (chunk) => {
254
+ error = collect(error, chunk, errorState);
255
+ });
256
+ child.once('error', () => finish(null));
257
+ child.once('close', (code) => finish(code));
258
+ });
259
+ }
260
+
261
+ /** @param {string} executable @param {NodeJS.ProcessEnv} [baseEnvironment] */
262
+ export async function resolveCodexCompatibility(executable, baseEnvironment = process.env) {
263
+ if (!PAGINATION_CONTRACT_VALID)
264
+ return { status: 'rejected', reason: 'protocol_fingerprint_invalid', version: null };
265
+ const environment = { PATH: baseEnvironment.PATH ?? '', LANG: 'C.UTF-8' };
266
+ const versionRun = await runBounded(executable, ['--version'], {
267
+ ...environment,
268
+ CODEX_COMPAT_CWD: process.cwd(),
269
+ });
270
+ if (versionRun.code !== 0 || versionRun.outputOverflow)
271
+ return { status: 'rejected', reason: 'version_unavailable', version: null };
272
+ const version = parseStableVersion(versionRun.output);
273
+ if (version === null) return { status: 'rejected', reason: 'version_unavailable', version: null };
274
+ if (version.prerelease || version.major !== 0)
275
+ return { status: 'rejected', reason: 'version_not_stable_0x', version: version.text };
276
+ if (QUALIFIED_VERSIONS.has(version.text))
277
+ return { status: 'qualified', reason: 'native_qualified', version: version.text };
278
+ if (version.minor < 163)
279
+ return { status: 'rejected', reason: 'version_unqualified', version: version.text };
280
+ const root = await mkdtemp(join(tmpdir(), 'aibrain-codex-compat-'));
281
+ const schemaDir = join(root, 'schema');
282
+ try {
283
+ const env = { ...environment, CODEX_HOME: join(root, 'codex-home'), CODEX_COMPAT_CWD: root };
284
+ const generated = await runBounded(
285
+ executable,
286
+ ['app-server', 'generate-json-schema', '--experimental', '--out', schemaDir],
287
+ env,
288
+ );
289
+ if (generated.code !== 0)
290
+ return { status: 'rejected', reason: 'schema_generation_failed', version: version.text };
291
+ if (generated.outputOverflow)
292
+ return { status: 'rejected', reason: 'schema_generation_failed', version: version.text };
293
+ /** @type {Record<string, string>} */
294
+ const hashes = {};
295
+ let aggregateBytes = 0;
296
+ await assertOwnedDirectory(schemaDir);
297
+ for (const file of ROOTS) {
298
+ const path = join(schemaDir, file);
299
+ let directory = schemaDir;
300
+ for (const part of file.split('/').slice(0, -1)) {
301
+ directory = join(directory, part);
302
+ const directoryEntry = await lstat(directory).catch(() => null);
303
+ if (directoryEntry === null)
304
+ return {
305
+ status: 'rejected',
306
+ reason: 'schema_root_missing_or_invalid',
307
+ version: version.text,
308
+ };
309
+ await assertOwnedDirectory(directory);
310
+ }
311
+ const entry = await lstat(path).catch(() => null);
312
+ if (
313
+ entry === null ||
314
+ entry.isSymbolicLink() ||
315
+ !entry.isFile() ||
316
+ (process.getuid?.() !== undefined && entry.uid !== process.getuid()) ||
317
+ entry.size > MAX_SCHEMA_BYTES
318
+ )
319
+ return {
320
+ status: 'rejected',
321
+ reason: 'schema_root_missing_or_invalid',
322
+ version: version.text,
323
+ };
324
+ aggregateBytes += entry.size;
325
+ if (aggregateBytes > MAX_SCHEMA_SET_BYTES)
326
+ return { status: 'rejected', reason: 'schema_set_too_large', version: version.text };
327
+ const parsed = JSON.parse(await readBoundedFile(path, MAX_SCHEMA_BYTES));
328
+ assertBoundedSchema(parsed);
329
+ const rootMap = /** @type {Record<string, {methods: string[]|null, sha256: string}>} */ (
330
+ fingerprint.roots
331
+ );
332
+ const expectedRoot = rootMap[file];
333
+ let normalized;
334
+ if (expectedRoot.methods === null) normalized = normalize(parsed);
335
+ else {
336
+ const oneOf = Array.isArray(parsed.oneOf) ? parsed.oneOf : [];
337
+ const selected = [];
338
+ for (const methodName of expectedRoot.methods) {
339
+ const matches = oneOf.filter((/** @type {unknown} */ rawBranch) => {
340
+ const branch = /** @type {JsonObject} */ (rawBranch);
341
+ const properties = /** @type {JsonObject} */ (branch.properties ?? {});
342
+ const method = /** @type {JsonObject} */ (properties.method ?? {});
343
+ const values = Array.isArray(method.enum) ? method.enum : [];
344
+ return values.length === 1 && values[0] === methodName;
345
+ });
346
+ if (matches.length !== 1)
347
+ return { status: 'rejected', reason: 'protocol_method_missing', version: version.text };
348
+ selected.push(matches[0]);
349
+ }
350
+ /** @type {JsonObject} */
351
+ const definitions = /** @type {JsonObject} */ ({});
352
+ const found = new Set();
353
+ /** @param {unknown} value @param {boolean} [map] */
354
+ const visit = (value, map = false) => {
355
+ if (!value || typeof value !== 'object') return;
356
+ if (Array.isArray(value)) {
357
+ value.forEach((item) => visit(item));
358
+ return;
359
+ }
360
+ const object = /** @type {JsonObject} */ (value);
361
+ if (typeof object.$ref === 'string') {
362
+ if (!object.$ref.startsWith('#/definitions/') && !object.$ref.startsWith('#/$defs/'))
363
+ throw new Error('schema_reference_nonlocal');
364
+ const prefix = object.$ref.startsWith('#/definitions/') ? '#/definitions/' : '#/$defs/';
365
+ const name = object.$ref.slice(prefix.length);
366
+ const source = localDefinition(parsed, object.$ref);
367
+ if (source === undefined) throw new Error('schema_reference_missing');
368
+ if (!found.has(`${prefix}${name}`)) {
369
+ found.add(`${prefix}${name}`);
370
+ visit(source);
371
+ }
372
+ }
373
+ for (const [key, child] of Object.entries(object)) {
374
+ if (map) {
375
+ visit(child);
376
+ } else if (
377
+ key !== 'definitions' &&
378
+ key !== '$defs' &&
379
+ !LITERAL_KEYS.has(key) &&
380
+ !ANNOTATIONS.has(key)
381
+ ) {
382
+ if (
383
+ SCHEMA_MAP_KEYS.has(key) &&
384
+ child &&
385
+ typeof child === 'object' &&
386
+ !Array.isArray(child)
387
+ ) {
388
+ for (const schema of Object.values(child)) visit(schema);
389
+ } else visit(child);
390
+ }
391
+ }
392
+ };
393
+ for (const [key, child] of Object.entries(parsed)) {
394
+ if (
395
+ key === 'oneOf' ||
396
+ key === 'definitions' ||
397
+ key === '$defs' ||
398
+ LITERAL_KEYS.has(key) ||
399
+ ANNOTATIONS.has(key)
400
+ )
401
+ continue;
402
+ if (
403
+ SCHEMA_MAP_KEYS.has(key) &&
404
+ child &&
405
+ typeof child === 'object' &&
406
+ !Array.isArray(child)
407
+ ) {
408
+ for (const schema of Object.values(child)) visit(schema);
409
+ } else visit(child);
410
+ }
411
+ visit(selected);
412
+ for (const reference of [...found].sort()) {
413
+ setLocalDefinition(definitions, reference, normalize(localDefinition(parsed, reference)));
414
+ }
415
+ const projectedRoot = /** @type {JsonObject} */ (normalize(parsed));
416
+ delete projectedRoot.oneOf;
417
+ delete projectedRoot.definitions;
418
+ delete projectedRoot.$defs;
419
+ projectedRoot.oneOf = selected.map((/** @type {unknown} */ branch) => normalize(branch));
420
+ projectedRoot.definitions = definitions;
421
+ normalized = projectedRoot;
422
+ }
423
+ hashes[file] = sha(normalized);
424
+ if (hashes[file] !== expectedRoot.sha256)
425
+ return { status: 'rejected', reason: 'protocol_schema_mismatch', version: version.text };
426
+ }
427
+ const help = await runBounded(executable, ['app-server', '--help'], env);
428
+ const terminalHelp = await runBounded(executable, ['--help'], env);
429
+ if (
430
+ help.code !== 0 ||
431
+ help.outputOverflow ||
432
+ !help.output.includes('--listen') ||
433
+ !help.output.includes('stdio://') ||
434
+ terminalHelp.code !== 0 ||
435
+ terminalHelp.outputOverflow ||
436
+ !terminalHelp.output.includes('--remote') ||
437
+ !terminalHelp.output.includes('unix://')
438
+ )
439
+ return { status: 'rejected', reason: 'transport_flags_missing', version: version.text };
440
+ const recheck = await runBounded(executable, ['--version'], {
441
+ ...environment,
442
+ CODEX_COMPAT_CWD: process.cwd(),
443
+ });
444
+ const recheckedVersion = parseStableVersion(recheck.output);
445
+ if (
446
+ recheck.code !== 0 ||
447
+ recheck.outputOverflow ||
448
+ recheckedVersion?.text !== version.text ||
449
+ recheckedVersion?.prerelease
450
+ )
451
+ return { status: 'rejected', reason: 'version_changed_during_probe', version: version.text };
452
+ return {
453
+ status: 'protocol_match',
454
+ reason: 'protocol_fingerprint_match',
455
+ version: version.text,
456
+ };
457
+ } catch {
458
+ return { status: 'rejected', reason: 'protocol_probe_failed', version: version.text };
459
+ } finally {
460
+ await rm(root, { recursive: true, force: true });
461
+ }
462
+ }
463
+
464
+ /** @param {{reason:string, version:string|null}} decision @returns {string} */
465
+ export function codexCompatibilityError(decision) {
466
+ return `codex_protocol_incompatible:${decision.reason}:detected=${decision.version ?? 'unavailable'};run aibrain doctor --json`;
467
+ }