@brunobracaioli/aibrain 0.3.0-beta.13 → 0.3.0-beta.14
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/README.md +14 -3
- package/dist/assets/claude/.claude-plugin/plugin.json +1 -1
- package/dist/assets/claude/hooks/native-host-contract-runtime.ts +18 -0
- package/dist/assets/claude/hooks/native-host-contract.test.ts +42 -0
- package/dist/assets/jev-paid-pilot-terminal.mjs +189 -14
- package/dist/claude-compatibility.mjs +506 -0
- package/dist/cli.mjs +23 -27
- package/dist/launch.mjs +11 -4
- package/dist/package.json +1 -1
- package/dist/platform.mjs +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
Assisted AI Brain context beta client for Linux/WSL with Node.js 22 or newer.
|
|
4
4
|
|
|
5
5
|
The reviewed native compatibility set is Claude Code 2.1.294, 2.1.295, 2.1.296,
|
|
6
|
-
2.1.299, 2.1.300, 2.1.301 or 2.1.
|
|
6
|
+
2.1.299, 2.1.300, 2.1.301, 2.1.302 or 2.1.303, and Codex CLI 0.161.0,
|
|
7
7
|
0.162.0, 0.162.1 or 0.163.0. A newer stable Codex 0.x release may continue only
|
|
8
8
|
after its locally generated experimental protocol schemas and transport flags
|
|
9
9
|
match the bundled adapter contract; prereleases, older releases and new majors
|
|
10
|
-
remain rejected.
|
|
10
|
+
remain rejected. Starting with beta14, newer stable Claude 2.1 patches can
|
|
11
|
+
continue after isolated strict plugin validation, required hook/API checks and
|
|
12
|
+
an enabled-hook test in the native offline runner. Doctor distinguishes
|
|
13
|
+
`host_checked` from `qualified`; this check does not establish full behavioral
|
|
14
|
+
equivalence with a tested native release. Unknown older patches, prereleases
|
|
15
|
+
and other major/minor families remain rejected. No credentials, conversations,
|
|
16
|
+
model requests or JEV calls are used by these compatibility probes.
|
|
17
|
+
|
|
18
|
+
The Codex terminal bridge permits the native session picker's separate read-only
|
|
19
|
+
connection while keeping the primary conversation and monitor intact. A picker
|
|
20
|
+
cannot compact, start a turn, inject history or take ownership of the primary
|
|
21
|
+
connection. Its failure or timeout closes only that auxiliary connection.
|
|
11
22
|
|
|
12
23
|
```sh
|
|
13
24
|
# Install the published beta:
|
|
@@ -57,7 +68,7 @@ text; they do not claim that a live session is being monitored. Codex keeps nati
|
|
|
57
68
|
compaction and recovery notes in the same thread. The backend checks paid access
|
|
58
69
|
and unchanged shared quotas before any JEV request.
|
|
59
70
|
|
|
60
|
-
The `0.3.0-beta.
|
|
71
|
+
The `0.3.0-beta.14` Claude launcher uses the durable context strategy and
|
|
61
72
|
records bounded recovery reasons when native summary proof cannot be confirmed:
|
|
62
73
|
JEV selects compatible historical tool evidence, bounded private notes retain
|
|
63
74
|
the selected text, and Claude core summarizes the reduced input. The next
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-brain-context-compaction",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.14",
|
|
4
4
|
"author": { "name": "AISAC" },
|
|
5
5
|
"description": "AI Brain paid-plan context monitoring with conservative Claude compaction fallback.",
|
|
6
6
|
"hooks": "./hooks/hooks.json",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { register as registerContext } from './context-compaction.ts';
|
|
2
|
+
|
|
3
|
+
export function register(on: Parameters<typeof registerContext>[0]): void {
|
|
4
|
+
return registerContext(on, {
|
|
5
|
+
enabled: true,
|
|
6
|
+
disclosed: true,
|
|
7
|
+
policyVersion: 2,
|
|
8
|
+
compactAtPercent: 60,
|
|
9
|
+
keepThreshold: 0.5,
|
|
10
|
+
preserveRecentMessages: 6,
|
|
11
|
+
minReductionRatio: 0.25,
|
|
12
|
+
truncateHeadChars: 300,
|
|
13
|
+
maxStateTokens: 25_000,
|
|
14
|
+
maxRequestTokens: 30_000,
|
|
15
|
+
compactionStrategy: 'native_summary_notes',
|
|
16
|
+
preservationMode: 'upstream_compatible',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing';
|
|
2
|
+
|
|
3
|
+
const contractTest: Parameters<typeof test>[2] = async ($, on) => {
|
|
4
|
+
on('prompt.submit', () => ({ text: 'native-host-contract-ok' }));
|
|
5
|
+
on('tool.call', () => ({ result: { content: 'native-host-tool-ok' } }));
|
|
6
|
+
on('session.messages', () => ({ value: [] }));
|
|
7
|
+
let usageCalls = 0;
|
|
8
|
+
on('session.usage', () => ({
|
|
9
|
+
value: (() => {
|
|
10
|
+
usageCalls += 1;
|
|
11
|
+
return { context: { percent: 90, tokens: 900, window: 1_000 } };
|
|
12
|
+
})(),
|
|
13
|
+
}));
|
|
14
|
+
on('ui.log', () => ({ value: undefined }));
|
|
15
|
+
on('ui.toast', () => ({ value: undefined }));
|
|
16
|
+
let compactCalls = 0;
|
|
17
|
+
on('session.compact', () => {
|
|
18
|
+
compactCalls += 1;
|
|
19
|
+
return {
|
|
20
|
+
messages: [{ role: 'assistant', text: 'native-summary-contract-ok', toolUses: [] }],
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
on('turn.complete', () => ({ text: '' }));
|
|
24
|
+
const prompt = await $.prompt.submit({ text: 'native-host-contract-input' });
|
|
25
|
+
const tool = await $.tool.call({ tool: 'Read', file_path: 'native-host-contract.txt' });
|
|
26
|
+
await $.turn.complete({
|
|
27
|
+
turnId: 'native-contract-turn',
|
|
28
|
+
answer: 'native-turn-contract-ok',
|
|
29
|
+
durationMs: 1,
|
|
30
|
+
isAborted: false,
|
|
31
|
+
usage: null,
|
|
32
|
+
});
|
|
33
|
+
expect(prompt.text).toBe('native-host-contract-ok');
|
|
34
|
+
expect(tool.result.content).toBe('native-host-tool-ok');
|
|
35
|
+
expect(usageCalls).toBe(1);
|
|
36
|
+
expect(compactCalls).toBe(1);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
test(
|
|
40
|
+
'AI Brain enabled host contract invokes usage and native compaction through the dispatcher',
|
|
41
|
+
contractTest,
|
|
42
|
+
);
|
|
@@ -12,6 +12,11 @@ const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
|
12
12
|
const MAX_QUEUED_FRAMES = 64;
|
|
13
13
|
const MAX_QUEUED_BYTES = 32 * 1024 * 1024;
|
|
14
14
|
const STARTUP_HANDOFF_GRACE_MS = 5_000;
|
|
15
|
+
const MAX_AUXILIARY_CONNECTIONS = 1;
|
|
16
|
+
const MAX_AUXILIARY_MESSAGES = 64;
|
|
17
|
+
const MAX_AUXILIARY_IN_FLIGHT = 32;
|
|
18
|
+
const MAX_AUXILIARY_NOTIFICATIONS = 256;
|
|
19
|
+
const DEFAULT_AUXILIARY_IDLE_TIMEOUT_MS = 120_000;
|
|
15
20
|
const NORMAL_DISCOVERY_CLOSE_CODES = new Set([1000, 1001, 1005]);
|
|
16
21
|
|
|
17
22
|
function repoRoot() {
|
|
@@ -60,7 +65,13 @@ async function main() {
|
|
|
60
65
|
const codexBinary = process.env.CODEX_BIN ?? 'codex';
|
|
61
66
|
const nativeArgs =
|
|
62
67
|
process.argv.slice(2)[0] === '--' ? process.argv.slice(3) : process.argv.slice(2);
|
|
63
|
-
|
|
68
|
+
// The bridge owns an interactive Codex TUI transport. Any native command
|
|
69
|
+
// that reaches this bridge may perform a short-lived read-only bootstrap
|
|
70
|
+
// before attaching its conversation transport. The protocol boundary below
|
|
71
|
+
// is the authority: initialize, same-client identity, successful allowlisted
|
|
72
|
+
// discovery, no started operation, one candidate, and a five-second deadline.
|
|
73
|
+
// Non-interactive Codex subcommands never open the socket and exit normally.
|
|
74
|
+
const allowStartupHandoff = true;
|
|
64
75
|
const childEnv = { ...process.env };
|
|
65
76
|
delete childEnv.JEV_API_KEY;
|
|
66
77
|
delete childEnv.TYPESAFE_API_KEY;
|
|
@@ -71,11 +82,19 @@ async function main() {
|
|
|
71
82
|
const generations = new Set();
|
|
72
83
|
let activeConnection = null;
|
|
73
84
|
let pendingCandidate = null;
|
|
85
|
+
const auxiliaryConnections = new Set();
|
|
74
86
|
let handoffCount = 0;
|
|
75
87
|
let handoffAttemptCount = 0;
|
|
76
88
|
let socketUpgradeCount = 0;
|
|
77
89
|
let secondConnectionRejectCount = 0;
|
|
78
90
|
let candidateRejectCount = 0;
|
|
91
|
+
let auxiliaryConnectionCount = 0;
|
|
92
|
+
let auxiliaryRejectCount = 0;
|
|
93
|
+
let auxiliaryMutationRejectCount = 0;
|
|
94
|
+
let auxiliaryUnknownResponseCount = 0;
|
|
95
|
+
let auxiliaryIdleTimeoutCount = 0;
|
|
96
|
+
let auxiliaryMessageCount = 0;
|
|
97
|
+
let auxiliaryNotificationCount = 0;
|
|
79
98
|
let shuttingDown = false;
|
|
80
99
|
const reportPath = process.env.CODEX_TERMINAL_BRIDGE_REPORT;
|
|
81
100
|
let initializeReceived = false;
|
|
@@ -110,10 +129,12 @@ async function main() {
|
|
|
110
129
|
};
|
|
111
130
|
const READ_ONLY_DISCOVERY = new Set([
|
|
112
131
|
'account/read',
|
|
132
|
+
'account/bedrock/checkGovCloudRequirements',
|
|
133
|
+
'config/read',
|
|
134
|
+
'configRequirements/read',
|
|
113
135
|
'thread/list',
|
|
114
136
|
'thread/read',
|
|
115
137
|
'model/list',
|
|
116
|
-
'configRequirements/read',
|
|
117
138
|
'collaborationMode/list',
|
|
118
139
|
'hooks/list',
|
|
119
140
|
'thread/turns/list',
|
|
@@ -131,6 +152,8 @@ async function main() {
|
|
|
131
152
|
'initialize',
|
|
132
153
|
'initialized',
|
|
133
154
|
'account/read',
|
|
155
|
+
'account/bedrock/checkGovCloudRequirements',
|
|
156
|
+
'config/read',
|
|
134
157
|
'thread/list',
|
|
135
158
|
'thread/read',
|
|
136
159
|
'model/list',
|
|
@@ -192,6 +215,10 @@ async function main() {
|
|
|
192
215
|
rejectCandidate(generation.connection, 1011, 'candidate_monitor_failed');
|
|
193
216
|
return;
|
|
194
217
|
}
|
|
218
|
+
if (generation.connection?.isAuxiliary) {
|
|
219
|
+
rejectAuxiliary(generation.connection, 'auxiliary_monitor_failed');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
195
222
|
monitorExitCode = code;
|
|
196
223
|
void cleanup().finally(() => process.exit(code));
|
|
197
224
|
};
|
|
@@ -270,6 +297,10 @@ async function main() {
|
|
|
270
297
|
rejectCandidate(connection, 1013, reason);
|
|
271
298
|
return;
|
|
272
299
|
}
|
|
300
|
+
if (connection?.isAuxiliary) {
|
|
301
|
+
rejectAuxiliary(connection, reason);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
273
304
|
if (connection) connection.retiring = true;
|
|
274
305
|
cleanupGeneration(generation);
|
|
275
306
|
connection?.socket.close(1013, reason);
|
|
@@ -310,7 +341,7 @@ async function main() {
|
|
|
310
341
|
),
|
|
311
342
|
new Promise((resolve) => setTimeout(resolve, 500)),
|
|
312
343
|
]);
|
|
313
|
-
for (const connection of [activeConnection, pendingCandidate]) {
|
|
344
|
+
for (const connection of [activeConnection, pendingCandidate, ...auxiliaryConnections]) {
|
|
314
345
|
if (connection?.socket.readyState === WebSocket.OPEN)
|
|
315
346
|
connection.socket.close(1001, 'bridge stopped');
|
|
316
347
|
connection?.socket.terminate();
|
|
@@ -350,6 +381,13 @@ async function main() {
|
|
|
350
381
|
socket_upgrade_count: socketUpgradeCount,
|
|
351
382
|
second_connection_reject_count: secondConnectionRejectCount,
|
|
352
383
|
candidate_reject_count: candidateRejectCount,
|
|
384
|
+
auxiliary_connection_count: auxiliaryConnectionCount,
|
|
385
|
+
auxiliary_reject_count: auxiliaryRejectCount,
|
|
386
|
+
auxiliary_mutation_reject_count: auxiliaryMutationRejectCount,
|
|
387
|
+
auxiliary_unknown_response_count: auxiliaryUnknownResponseCount,
|
|
388
|
+
auxiliary_idle_timeout_count: auxiliaryIdleTimeoutCount,
|
|
389
|
+
auxiliary_message_count: auxiliaryMessageCount,
|
|
390
|
+
auxiliary_notification_count: auxiliaryNotificationCount,
|
|
353
391
|
cleanup_initiator: cleanupInitiator,
|
|
354
392
|
last_socket_close_code: lastSocketCloseCode,
|
|
355
393
|
terminal_alive_at_socket_close: terminalAliveAtSocketClose,
|
|
@@ -410,6 +448,24 @@ async function main() {
|
|
|
410
448
|
connection.initialized === true &&
|
|
411
449
|
connection.initializeAck === true &&
|
|
412
450
|
(connection.socket.readyState === WebSocket.OPEN || connection.discoveryClosed === true);
|
|
451
|
+
const auxiliaryIdleTimeoutMs = (() => {
|
|
452
|
+
const value = Number.parseInt(process.env.CODEX_AUXILIARY_IDLE_TIMEOUT_MS ?? '', 10);
|
|
453
|
+
return Number.isSafeInteger(value) && value >= 1_000 && value <= 300_000
|
|
454
|
+
? value
|
|
455
|
+
: DEFAULT_AUXILIARY_IDLE_TIMEOUT_MS;
|
|
456
|
+
})();
|
|
457
|
+
const rejectAuxiliary = (connection, reason = 'auxiliary_rejected') => {
|
|
458
|
+
if (!connection || connection.retiring || !connection.isAuxiliary) return;
|
|
459
|
+
connection.retiring = true;
|
|
460
|
+
if (connection.auxiliaryTimer !== null) clearTimeout(connection.auxiliaryTimer);
|
|
461
|
+
auxiliaryConnections.delete(connection);
|
|
462
|
+
auxiliaryRejectCount += 1;
|
|
463
|
+
if (reason === 'auxiliary_idle_timeout') auxiliaryIdleTimeoutCount += 1;
|
|
464
|
+
if (connection.socket.readyState === WebSocket.OPEN)
|
|
465
|
+
connection.socket.close(1008, reason);
|
|
466
|
+
else connection.socket.terminate();
|
|
467
|
+
cleanupGeneration(connection.generation);
|
|
468
|
+
};
|
|
413
469
|
const rejectCandidate = (connection, statusCode = 1008, reason = 'handoff_rejected') => {
|
|
414
470
|
if (!connection || connection.retiring) return;
|
|
415
471
|
if (connection?.candidateTimer) clearTimeout(connection.candidateTimer);
|
|
@@ -474,7 +530,12 @@ async function main() {
|
|
|
474
530
|
let parsed;
|
|
475
531
|
try {
|
|
476
532
|
parsed = JSON.parse(line.toString('utf8'));
|
|
477
|
-
|
|
533
|
+
const isInitializeResponse =
|
|
534
|
+
connection.initializeId !== null &&
|
|
535
|
+
parsed.id === connection.initializeId &&
|
|
536
|
+
connection.initializeResponseConsumed === false;
|
|
537
|
+
if (isInitializeResponse) {
|
|
538
|
+
connection.initializeResponseConsumed = true;
|
|
478
539
|
initializeResponseSeen = true;
|
|
479
540
|
initializeErrorCode = parsed.error?.code ?? null;
|
|
480
541
|
if (parsed.error === undefined && parsed.result !== undefined) {
|
|
@@ -501,6 +562,25 @@ async function main() {
|
|
|
501
562
|
const isJsonRpcResponse =
|
|
502
563
|
Object.prototype.hasOwnProperty.call(parsed, 'result') ||
|
|
503
564
|
Object.prototype.hasOwnProperty.call(parsed, 'error');
|
|
565
|
+
if (connection.isAuxiliary && parsed.method !== undefined && parsed.id === undefined) {
|
|
566
|
+
auxiliaryNotificationCount += 1;
|
|
567
|
+
connection.auxiliaryNotificationCount += 1;
|
|
568
|
+
if (connection.auxiliaryNotificationCount > MAX_AUXILIARY_NOTIFICATIONS) {
|
|
569
|
+
rejectAuxiliary(connection, 'auxiliary_notification_limit');
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (
|
|
574
|
+
connection.isAuxiliary &&
|
|
575
|
+
(parsed.method !== undefined ||
|
|
576
|
+
(isJsonRpcResponse &&
|
|
577
|
+
!isInitializeResponse &&
|
|
578
|
+
!connection.pendingRequests.has(parsed.id)))
|
|
579
|
+
) {
|
|
580
|
+
auxiliaryUnknownResponseCount += 1;
|
|
581
|
+
rejectAuxiliary(connection, 'auxiliary_response_rejected');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
504
584
|
if (
|
|
505
585
|
isJsonRpcResponse &&
|
|
506
586
|
parsed.id !== undefined &&
|
|
@@ -540,6 +620,11 @@ async function main() {
|
|
|
540
620
|
} catch {
|
|
541
621
|
parsed = null;
|
|
542
622
|
}
|
|
623
|
+
if (connection.isAuxiliary && parsed === null) {
|
|
624
|
+
auxiliaryUnknownResponseCount += 1;
|
|
625
|
+
rejectAuxiliary(connection, 'auxiliary_response_rejected');
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
543
628
|
if (connection.socket.readyState !== WebSocket.OPEN) return;
|
|
544
629
|
if (connection.socket.bufferedAmount + line.length > MAX_QUEUED_BYTES) {
|
|
545
630
|
abortGeneration(generation, 'bridge_queue_limit');
|
|
@@ -555,18 +640,29 @@ async function main() {
|
|
|
555
640
|
const parsed = JSON.parse(frame.toString('utf8'));
|
|
556
641
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
557
642
|
throw new Error('bridge_message_not_object');
|
|
643
|
+
if (
|
|
644
|
+
connection.isAuxiliary &&
|
|
645
|
+
(typeof parsed.method !== 'string' ||
|
|
646
|
+
Object.prototype.hasOwnProperty.call(parsed, 'result') ||
|
|
647
|
+
Object.prototype.hasOwnProperty.call(parsed, 'error'))
|
|
648
|
+
) {
|
|
649
|
+
auxiliaryMutationRejectCount += 1;
|
|
650
|
+
rejectAuxiliary(connection, 'auxiliary_client_frame_rejected');
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
558
653
|
const normalizedFrame = Buffer.from(JSON.stringify(parsed), 'utf8');
|
|
654
|
+
const wasInitialized = connection.initialized;
|
|
559
655
|
if (!connection.initialized) {
|
|
560
656
|
const initialize = parseInitialize(normalizedFrame.toString('utf8'));
|
|
561
657
|
if (
|
|
562
|
-
connection.isCandidate &&
|
|
658
|
+
(connection.isCandidate || connection.isAuxiliary) &&
|
|
563
659
|
!sameClient(activeConnection?.clientInfo, initialize.params.clientInfo)
|
|
564
660
|
)
|
|
565
661
|
throw new Error('handoff_client_mismatch');
|
|
566
662
|
connection.initialized = true;
|
|
567
663
|
connection.initializeId = initialize.id;
|
|
568
664
|
connection.clientInfo = initialize.params.clientInfo;
|
|
569
|
-
if (!connection.isCandidate) {
|
|
665
|
+
if (!connection.isCandidate && !connection.isAuxiliary) {
|
|
570
666
|
initializeReceived = true;
|
|
571
667
|
experimentalApi = initialize.params.capabilities.experimentalApi === true;
|
|
572
668
|
}
|
|
@@ -575,8 +671,30 @@ async function main() {
|
|
|
575
671
|
connection.generation = spawnMonitor();
|
|
576
672
|
connection.generation.connection = connection;
|
|
577
673
|
}
|
|
674
|
+
if (connection.isAuxiliary && connection.generation === null) {
|
|
675
|
+
connection.generation = spawnMonitor();
|
|
676
|
+
connection.generation.connection = connection;
|
|
677
|
+
}
|
|
578
678
|
}
|
|
579
|
-
if (
|
|
679
|
+
if (connection.isAuxiliary) {
|
|
680
|
+
auxiliaryMessageCount += 1;
|
|
681
|
+
connection.auxiliaryMessageCount += 1;
|
|
682
|
+
if (connection.auxiliaryMessageCount > MAX_AUXILIARY_MESSAGES) {
|
|
683
|
+
rejectAuxiliary(connection, 'auxiliary_message_limit');
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (connection.auxiliaryTimer !== null) clearTimeout(connection.auxiliaryTimer);
|
|
687
|
+
connection.auxiliaryTimer = setTimeout(
|
|
688
|
+
() => rejectAuxiliary(connection, 'auxiliary_idle_timeout'),
|
|
689
|
+
auxiliaryIdleTimeoutMs,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (connection.isAuxiliary && wasInitialized && parsed.method === 'initialize') {
|
|
693
|
+
auxiliaryMutationRejectCount += 1;
|
|
694
|
+
rejectAuxiliary(connection, 'auxiliary_duplicate_initialize');
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (parsed.method === 'turn/start' && !connection.isAuxiliary) turnStartCount += 1;
|
|
580
698
|
if (typeof parsed.method === 'string') {
|
|
581
699
|
if (DIAGNOSTIC_METHODS.has(parsed.method)) {
|
|
582
700
|
if (observedMethods.size < MAX_DIAGNOSTIC_ENTRIES) observedMethods.add(parsed.method);
|
|
@@ -585,7 +703,33 @@ async function main() {
|
|
|
585
703
|
}
|
|
586
704
|
if (parsed.method === 'thread/resume') threadResumeCount += 1;
|
|
587
705
|
if (parsed.method === 'initialize' || parsed.method === 'initialized') {
|
|
706
|
+
if (connection.isAuxiliary && parsed.method === 'initialized') {
|
|
707
|
+
if (connection.initializedNotificationSeen) {
|
|
708
|
+
auxiliaryMutationRejectCount += 1;
|
|
709
|
+
rejectAuxiliary(connection, 'auxiliary_duplicate_initialized');
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
connection.initializedNotificationSeen = true;
|
|
713
|
+
}
|
|
588
714
|
// The notification is part of the handshake and does not affect startup eligibility.
|
|
715
|
+
} else if (connection.isAuxiliary) {
|
|
716
|
+
if (!READ_ONLY_DISCOVERY.has(parsed.method)) {
|
|
717
|
+
auxiliaryMutationRejectCount += 1;
|
|
718
|
+
rejectAuxiliary(connection, 'auxiliary_mutation_rejected');
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const validRequestId =
|
|
722
|
+
(typeof parsed.id === 'string' && parsed.id.length > 0 && parsed.id.length <= 128) ||
|
|
723
|
+
(Number.isSafeInteger(parsed.id) && parsed.id >= 0);
|
|
724
|
+
if (
|
|
725
|
+
!validRequestId ||
|
|
726
|
+
connection.pendingRequests.has(parsed.id) ||
|
|
727
|
+
connection.pendingRequests.size >= MAX_AUXILIARY_IN_FLIGHT
|
|
728
|
+
) {
|
|
729
|
+
rejectAuxiliary(connection, 'auxiliary_request_limit');
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
connection.pendingRequests.set(parsed.id, parsed.method);
|
|
589
733
|
} else if (READ_ONLY_DISCOVERY.has(parsed.method)) {
|
|
590
734
|
const validRequestId =
|
|
591
735
|
(typeof parsed.id === 'string' && parsed.id.length > 0 && parsed.id.length <= 128) ||
|
|
@@ -627,18 +771,22 @@ async function main() {
|
|
|
627
771
|
}
|
|
628
772
|
} catch (error) {
|
|
629
773
|
if (connection.isCandidate) rejectCandidate(connection, 1008, safeError(error));
|
|
774
|
+
else if (connection.isAuxiliary) rejectAuxiliary(connection, safeError(error));
|
|
630
775
|
else connection.socket.close(1008, safeError(error));
|
|
631
776
|
}
|
|
632
777
|
};
|
|
633
|
-
const attachConnection = (webSocket, isCandidate) => {
|
|
778
|
+
const attachConnection = (webSocket, isCandidate, isAuxiliary = false) => {
|
|
634
779
|
const connection = {
|
|
635
780
|
socket: webSocket,
|
|
636
|
-
generation: isCandidate ? null : initialGeneration,
|
|
781
|
+
generation: isCandidate || isAuxiliary ? null : initialGeneration,
|
|
637
782
|
isCandidate,
|
|
638
|
-
|
|
783
|
+
isAuxiliary,
|
|
784
|
+
isActive: !isCandidate && !isAuxiliary,
|
|
639
785
|
retiring: false,
|
|
640
786
|
initialized: false,
|
|
641
787
|
initializeId: null,
|
|
788
|
+
initializeResponseConsumed: false,
|
|
789
|
+
initializedNotificationSeen: false,
|
|
642
790
|
initializeAck: false,
|
|
643
791
|
clientInfo: null,
|
|
644
792
|
pendingRequests: new Map(),
|
|
@@ -651,15 +799,27 @@ async function main() {
|
|
|
651
799
|
handoffComplete: false,
|
|
652
800
|
discoveryClosed: false,
|
|
653
801
|
candidateTimer: null,
|
|
802
|
+
auxiliaryTimer: null,
|
|
803
|
+
auxiliaryMessageCount: 0,
|
|
804
|
+
auxiliaryNotificationCount: 0,
|
|
654
805
|
};
|
|
655
|
-
if (!isCandidate) {
|
|
806
|
+
if (!isCandidate && !isAuxiliary) {
|
|
656
807
|
activeConnection = connection;
|
|
657
808
|
initialGeneration.connection = connection;
|
|
658
809
|
}
|
|
810
|
+
if (isAuxiliary) {
|
|
811
|
+
auxiliaryConnections.add(connection);
|
|
812
|
+
auxiliaryConnectionCount += 1;
|
|
813
|
+
connection.auxiliaryTimer = setTimeout(
|
|
814
|
+
() => rejectAuxiliary(connection, 'auxiliary_idle_timeout'),
|
|
815
|
+
auxiliaryIdleTimeoutMs,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
659
818
|
webSocket.on('message', (data, isBinary) => handleClientMessage(connection, data, isBinary));
|
|
660
819
|
webSocket.on('error', () => {
|
|
661
820
|
if (connection.retiring || shuttingDown) return;
|
|
662
821
|
if (connection.isCandidate) rejectCandidate(connection, 1011, 'candidate_socket_error');
|
|
822
|
+
else if (connection.isAuxiliary) rejectAuxiliary(connection, 'auxiliary_socket_error');
|
|
663
823
|
else {
|
|
664
824
|
cleanupInitiator = 'socket_error';
|
|
665
825
|
recordLifecycle('socket_error');
|
|
@@ -672,6 +832,12 @@ async function main() {
|
|
|
672
832
|
lastSocketCloseCode = code;
|
|
673
833
|
terminalAliveAtSocketClose = terminal?.exitCode === null;
|
|
674
834
|
recordLifecycle('socket_close');
|
|
835
|
+
if (connection.isAuxiliary) {
|
|
836
|
+
if (connection.auxiliaryTimer !== null) clearTimeout(connection.auxiliaryTimer);
|
|
837
|
+
auxiliaryConnections.delete(connection);
|
|
838
|
+
cleanupGeneration(connection.generation);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
675
841
|
if (connection.isCandidate) {
|
|
676
842
|
rejectCandidate(connection);
|
|
677
843
|
return;
|
|
@@ -725,14 +891,23 @@ async function main() {
|
|
|
725
891
|
closeSocket(socket, 403, 'origin_rejected');
|
|
726
892
|
return;
|
|
727
893
|
}
|
|
728
|
-
const isCandidate = activeConnection !== null;
|
|
729
|
-
|
|
894
|
+
const isCandidate = activeConnection !== null && canHandoff(activeConnection);
|
|
895
|
+
const isAuxiliary =
|
|
896
|
+
activeConnection !== null &&
|
|
897
|
+
!isCandidate &&
|
|
898
|
+
activeConnection.initialized === true &&
|
|
899
|
+
activeConnection.startedOperation === true;
|
|
900
|
+
if (
|
|
901
|
+
activeConnection !== null &&
|
|
902
|
+
!isCandidate &&
|
|
903
|
+
(!isAuxiliary || auxiliaryConnections.size >= MAX_AUXILIARY_CONNECTIONS)
|
|
904
|
+
) {
|
|
730
905
|
secondConnectionRejectCount += 1;
|
|
731
906
|
closeSocket(socket, 409, 'second_connection_rejected');
|
|
732
907
|
return;
|
|
733
908
|
}
|
|
734
909
|
webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
|
|
735
|
-
const connection = attachConnection(webSocket, isCandidate);
|
|
910
|
+
const connection = attachConnection(webSocket, isCandidate, isAuxiliary);
|
|
736
911
|
if (isCandidate) {
|
|
737
912
|
pendingCandidate = connection;
|
|
738
913
|
connection.candidateTimer = setTimeout(
|
|
@@ -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.
|
|
27
|
+
const VERSION = '0.3.0-beta.14';
|
|
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
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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:
|
|
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 &&
|
|
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
|
-
|
|
667
|
-
|
|
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();
|
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 {
|
|
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
|
|
411
|
-
|
|
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
|
-
|
|
543
|
+
pinnedExecutable,
|
|
537
544
|
[
|
|
538
545
|
'--settings',
|
|
539
546
|
settingsPath,
|
package/dist/package.json
CHANGED
package/dist/platform.mjs
CHANGED
|
@@ -70,7 +70,7 @@ export function nativeVersion(executable, environment = process.env) {
|
|
|
70
70
|
export function isTestedNativeVersion(client, version) {
|
|
71
71
|
const value = version.trim();
|
|
72
72
|
if (client === 'claude')
|
|
73
|
-
return /^(?:(?:claude(?:\s+code)?)\s+)?2\.1\.(?:294|295|296|299|300|301|302)(?:\s+\(Claude\s+Code\))?$/iu.test(
|
|
73
|
+
return /^(?:(?:claude(?:\s+code)?)\s+)?2\.1\.(?:294|295|296|299|300|301|302|303)(?:\s+\(Claude\s+Code\))?$/iu.test(
|
|
74
74
|
value,
|
|
75
75
|
);
|
|
76
76
|
return /^(?:codex(?:-cli)?\s+)?0\.16(?:1\.0|2\.0|2\.1|3\.0)$/iu.test(value);
|
|
@@ -85,6 +85,7 @@ const TESTED_NATIVE_VERSIONS = Object.freeze({
|
|
|
85
85
|
'2.1.300',
|
|
86
86
|
'2.1.301',
|
|
87
87
|
'2.1.302',
|
|
88
|
+
'2.1.303',
|
|
88
89
|
]),
|
|
89
90
|
codex: Object.freeze(['0.161.0', '0.162.0', '0.162.1', '0.163.0']),
|
|
90
91
|
});
|