ruvnet-brain 4.5.5 → 4.5.7
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 +1 -1
- package/bin/install.mjs +24 -6
- package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
- package/config/model-router/claude-terminal-mod/README.md +22 -0
- package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
- package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
- package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
- package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
- package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
- package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
- package/config/model-router/policy.default.mjs +4 -1
- package/kb/forge-update.mjs +50 -9
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +13 -1
- package/plugin/hooks/hook-contracts.json +1 -1
- package/plugin/mcp/managed-cli-generation.mjs +91 -0
- package/plugin/mcp/managed-cli-interface.mjs +45 -16
- package/plugin/mcp/server.mjs +3 -2
- package/plugin/scripts/capability-claim-evidence.mjs +9 -2
- package/plugin/scripts/continuation-gate.mjs +3 -1
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/hook-shim.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +4 -3
- package/plugin/scripts/nightly-scheduler.mjs +6 -2
- package/plugin/scripts/project-capture-queue.mjs +23 -8
- package/plugin/scripts/project-progression-hook.mjs +16 -7
- package/plugin/scripts/project-progression-outbox.mjs +116 -19
- package/plugin/scripts/project-progression-producer.mjs +12 -1
- package/plugin/scripts/project-progression-session-start.mjs +10 -0
- package/plugin/scripts/project-progression-store.mjs +53 -1
- package/plugin/scripts/project-progression-suspension.mjs +60 -0
- package/plugin/scripts/project-transition-hook.mjs +44 -16
- package/plugin/scripts/session-snapshot-hook.mjs +16 -8
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/scripts/claude-terminal-mod.mjs +89 -0
- package/scripts/model-routing-gateway.mjs +47 -14
- package/scripts/model-routing-launchers.mjs +36 -1
- package/scripts/model-terminal-gateway.mjs +310 -0
- package/scripts/model-terminal-launchers.mjs +300 -0
- package/scripts/release-qualification-contract.mjs +37 -6
- package/scripts/release-qualification.mjs +9 -6
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Materialize the sandboxed native mod and bridge it to the existing reviewed policy engine.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { classify } from '../config/model-router/policy.default.mjs';
|
|
9
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
export const MOD_SOURCE = path.join(ROOT, 'config/model-router/claude-terminal-mod');
|
|
11
|
+
export function terminalModDigest(pluginRoot) {
|
|
12
|
+
const hash = crypto.createHash('sha256');
|
|
13
|
+
for (const relative of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js', 'hooks/policy.default.mjs', 'hooks/runtime.js']) {
|
|
14
|
+
hash.update(relative); hash.update('\0'); hash.update(fs.readFileSync(path.join(pluginRoot, relative))); hash.update('\0');
|
|
15
|
+
}
|
|
16
|
+
return hash.digest('hex');
|
|
17
|
+
}
|
|
18
|
+
export function prepareClaudeTerminalMod({ destination, nodePath = process.execPath,
|
|
19
|
+
helperPath = fileURLToPath(import.meta.url), enginePath = path.join(ROOT, 'scripts/model-router-engine.mjs') } = {}) {
|
|
20
|
+
if (!destination || !path.isAbsolute(destination)) throw new Error('Absolute mod destination required');
|
|
21
|
+
for (const file of [nodePath, helperPath, enginePath]) if (!path.isAbsolute(file) || !fs.existsSync(file)) throw new Error('Native mod dependency unavailable');
|
|
22
|
+
fs.mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
23
|
+
for (const part of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js']) {
|
|
24
|
+
const dest = path.join(destination, part);
|
|
25
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
26
|
+
fs.copyFileSync(path.join(MOD_SOURCE, part), dest);
|
|
27
|
+
}
|
|
28
|
+
const policyPath = path.join(destination, 'hooks/policy.default.mjs');
|
|
29
|
+
fs.copyFileSync(path.join(ROOT, 'config/model-router/policy.default.mjs'), policyPath);
|
|
30
|
+
fs.writeFileSync(path.join(destination, 'hooks/runtime.js'), `export default ${JSON.stringify({ nodePath, helperPath, enginePath, policyPath })};\n`, { mode: 0o600 });
|
|
31
|
+
return { pluginDir: destination, policyPath, modDigest: terminalModDigest(destination) };
|
|
32
|
+
}
|
|
33
|
+
export function validateDecision(decision, now = Date.now()) {
|
|
34
|
+
const age = now - Date.parse(decision?.selectionReviewedAt);
|
|
35
|
+
const maxAge = decision?.selectionMaxAgeMs;
|
|
36
|
+
if (decision?.harness !== 'claude-code' || decision.provider !== 'anthropic' || decision.subscriptionCovered !== true ||
|
|
37
|
+
!/^claude-[a-z0-9][a-z0-9.-]*$/.test(decision.model || '') || !['fast', 'medium', 'hard'].includes(decision.taskClass) ||
|
|
38
|
+
!['low', 'medium', 'high', 'xhigh', 'max'].includes(decision.effort) ||
|
|
39
|
+
!Number.isSafeInteger(maxAge) || maxAge <= 0 || maxAge > 604800000 || !Number.isFinite(age) || age < 0 ||
|
|
40
|
+
!/^[a-f0-9]{64}$/.test(decision.selectionRouteDigest || '')) throw new Error('Native terminal routing decision invalid; no fallback');
|
|
41
|
+
return decision;
|
|
42
|
+
}
|
|
43
|
+
export function routeNativePrompt({ prompt, turnId, minimumClass, enginePath = path.join(ROOT, 'scripts/model-router-engine.mjs'),
|
|
44
|
+
policyPath = path.join(ROOT, 'config/model-router/policy.default.mjs'), env = process.env } = {}) {
|
|
45
|
+
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 200000) throw new Error('Native routing requires a bounded text prompt');
|
|
46
|
+
if (minimumClass !== undefined && !['fast', 'medium', 'hard'].includes(minimumClass)) throw new Error('Invalid class floor');
|
|
47
|
+
const codeFences = Math.floor((prompt.match(/```/g) || []).length / 2);
|
|
48
|
+
const hasCode = codeFences > 0 || /\b(function|const|let|def|class|import|=>|SELECT|async)\b/.test(prompt) || /[{};]\s*$/m.test(prompt);
|
|
49
|
+
const rank = { fast: 0, medium: 1, hard: 2 };
|
|
50
|
+
const canonicalFloor = classify({ taskHints: prompt, hasCode }, 'claude-code');
|
|
51
|
+
const floor = rank[minimumClass] > rank[canonicalFloor] ? minimumClass : canonicalFloor;
|
|
52
|
+
const classificationPrompt = floor === 'hard' ? 'final substantive review\n' + prompt : prompt;
|
|
53
|
+
// Let the engine honor the user's policy.mjs; the bundled copy is only the
|
|
54
|
+
// deterministic classification floor used by the sandboxed hook.
|
|
55
|
+
const result = spawnSync(process.execPath, [enginePath, '--harness', 'claude-code', '--request-json', '--policy-only', '--json'],
|
|
56
|
+
{ input: JSON.stringify({ prompt: classificationPrompt }), encoding: 'utf8', env, timeout: 8000, maxBuffer: 262144 });
|
|
57
|
+
if (result.error || result.status !== 0) throw new Error('Reviewed native routing policy unavailable; no fallback');
|
|
58
|
+
const d = validateDecision(JSON.parse(result.stdout));
|
|
59
|
+
if (rank[d.taskClass] < rank[floor]) throw new Error('Reviewed native routing policy below canonical floor; no fallback');
|
|
60
|
+
// No prompt, reasoning, credentials, or arbitrary engine output crosses back to the sandbox.
|
|
61
|
+
return { schemaVersion: 1, turnId: typeof turnId === 'string' ? turnId : null, model: d.model, effort: d.effort,
|
|
62
|
+
taskClass: d.taskClass, expiresAt: Date.parse(d.selectionReviewedAt) + d.selectionMaxAgeMs,
|
|
63
|
+
subscriptionCovered: true, routeDigest: d.selectionRouteDigest };
|
|
64
|
+
}
|
|
65
|
+
export function writeReadinessReceipt({ nonce, receiptPath, pluginRoot, version, sessionId, status = 'ready' } = {}) {
|
|
66
|
+
if (!/^[a-f0-9]{64}$/.test(nonce || '') || !path.isAbsolute(receiptPath || '') ||
|
|
67
|
+
!path.isAbsolute(pluginRoot || '') || (!/^2\.1\.\d+$/.test(version || '') || Number(version.split('.')[2]) < 287) || typeof sessionId !== 'string' ||
|
|
68
|
+
!['ready', 'health'].includes(status)) throw new Error('Invalid native mod readiness request');
|
|
69
|
+
const receipt = { schemaVersion: 1, nonce, pluginRoot: fs.realpathSync(pluginRoot), nativeVersion: version,
|
|
70
|
+
sessionId, status, modDigest: terminalModDigest(pluginRoot), observedAt: new Date().toISOString(), pid: process.ppid,
|
|
71
|
+
scope: 'hook activation only; native load failure or worker crash can skip hooks' };
|
|
72
|
+
const parent = path.dirname(receiptPath);
|
|
73
|
+
if (!fs.existsSync(parent)) throw new Error('Readiness directory must be prepared by launcher');
|
|
74
|
+
const temporary = path.join(parent, `.rnb-ready-${crypto.randomBytes(16).toString('hex')}`);
|
|
75
|
+
fs.writeFileSync(temporary, JSON.stringify(receipt), { mode: 0o600, flag: 'wx' });
|
|
76
|
+
fs.renameSync(temporary, receiptPath);
|
|
77
|
+
return receipt;
|
|
78
|
+
}
|
|
79
|
+
async function main() {
|
|
80
|
+
const mode = process.argv[2];
|
|
81
|
+
const request = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
82
|
+
if (mode === '--decision') process.stdout.write(JSON.stringify(routeNativePrompt(request)) + '\n');
|
|
83
|
+
else if (mode === '--ready' || mode === '--health') process.stdout.write(JSON.stringify(writeReadinessReceipt({ ...request, status: mode === '--ready' ? 'ready' : 'health' })) + '\n');
|
|
84
|
+
else if (mode === '--prepare') process.stdout.write(JSON.stringify(prepareClaudeTerminalMod(request)) + '\n');
|
|
85
|
+
else throw new Error('Expected --prepare, --decision, --ready, or --health');
|
|
86
|
+
}
|
|
87
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(() => {
|
|
88
|
+
process.stderr.write('claude-terminal-mod: routing or activation unavailable; no fallback\n'); process.exitCode = 1;
|
|
89
|
+
});
|
|
@@ -15,6 +15,21 @@ import { subscriptionEnvironment, assertSubscriptionAuth, validateDispatchDecisi
|
|
|
15
15
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const REFUSED = 'Current reviewed native model/effort allocation unavailable; new turn was not forwarded.';
|
|
17
17
|
const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
18
|
+
// Native inference surfaces without qualified execution-time routing are denied.
|
|
19
|
+
const UNQUALIFIED = new Set(['review/start', 'thread/queue/add', 'thread/queue/update', 'thread/queue/start',
|
|
20
|
+
'thread/realtime/start', 'thread/realtime/appendAudio', 'thread/realtime/appendSpeech', 'thread/realtime/appendText',
|
|
21
|
+
'thread/goal/set', 'thread/goal/create', 'thread/goal/resume', 'thread/compact/start', 'turn/addUserMessage', 'thread/startAeon']);
|
|
22
|
+
|
|
23
|
+
function canonicalCodexConfig(config) {
|
|
24
|
+
if (!config || (config.model_provider ?? 'openai') !== 'openai') return false;
|
|
25
|
+
// Native 0.160 gives serviceTierForTurn precedence over daemon/thread Fast defaults.
|
|
26
|
+
// routeCodexTurn supplies both standard overrides; provider/auth checks remain mandatory.
|
|
27
|
+
// A provider with the built-in name can still be replaced by a custom endpoint.
|
|
28
|
+
if (config.model_providers && Object.hasOwn(config.model_providers, 'openai') && config.model_providers.openai != null) return false;
|
|
29
|
+
if (config.chatgpt_base_url != null && config.chatgpt_base_url !== 'https://chatgpt.com/backend-api/') return false;
|
|
30
|
+
// Native Config serializes openai_base_url:null when the override is unset.
|
|
31
|
+
return !Object.entries(config).some(([key, value]) => value != null && /^(model_providers\.openai|base_url|baseUrl|openai_base_url)(\.|$)/.test(key));
|
|
32
|
+
}
|
|
18
33
|
|
|
19
34
|
/** Fresh policy-only subprocess: prompt stays on stdin, never argv, receipts or diagnostics. */
|
|
20
35
|
export function decideNativeTurn(prompt, harness, { env = process.env, spawnEngine = spawn, timeoutMs = 4000, multimodal = false } = {}) {
|
|
@@ -107,9 +122,9 @@ function unsafeSettings(value, prefix = '', routing = false) {
|
|
|
107
122
|
if (!value || typeof value !== 'object') return false;
|
|
108
123
|
return Object.entries(value).some(([key, entry]) => {
|
|
109
124
|
const name = prefix ? `${prefix}.${key}` : key;
|
|
110
|
-
if (/^(model_providers|apiKeyHelper|api_key|apiKey|base_url|baseUrl|auth_token|authToken|customHeaders|env|modelSettings|alwaysThinkingEnabled|maxEffortLevel)(\.|$)/.test(name)) return true;
|
|
125
|
+
if (/^(model_providers|apiKeyHelper|api_key|apiKey|base_url|baseUrl|openai_base_url|chatgpt_base_url|auth_token|authToken|customHeaders|env|modelSettings|alwaysThinkingEnabled|maxEffortLevel)(\.|$)/.test(name)) return true;
|
|
111
126
|
if (/^(model_provider|modelProvider)$/.test(name) && entry !== 'openai') return true;
|
|
112
|
-
if (/^(service_tier|serviceTier)$/.test(name) && entry != null && entry !== 'default') return true;
|
|
127
|
+
if (/^(service_tier|serviceTier|service_tier_for_turn|serviceTierForTurn)$/.test(name) && entry != null && entry !== 'default') return true;
|
|
113
128
|
if (/^(features\.fast_mode|fastMode)$/.test(name) && entry !== false) return true;
|
|
114
129
|
if (routing && /^(model|effort|effortLevel|reasoning_effort|collaborationMode)(\.|$)/.test(name)) return true;
|
|
115
130
|
return unsafeSettings(entry, name, routing);
|
|
@@ -123,7 +138,7 @@ export function verifyNativeVision(decision, models = loadNativeCodexModels()) {
|
|
|
123
138
|
|
|
124
139
|
/** Pure native override: preserve every context, tool, approval and collaboration instruction. */
|
|
125
140
|
export function routeCodexTurn(message, decision) {
|
|
126
|
-
const params = { ...message.params, model: decision.model, effort: decision.effort, serviceTier: 'default' };
|
|
141
|
+
const params = { ...message.params, model: decision.model, effort: decision.effort, serviceTier: 'default', serviceTierForTurn: 'default' };
|
|
127
142
|
if (params.collaborationMode) params.collaborationMode = { ...params.collaborationMode,
|
|
128
143
|
settings: { ...params.collaborationMode.settings, model: decision.model, reasoning_effort: decision.effort } };
|
|
129
144
|
return { ...message, params };
|
|
@@ -135,7 +150,7 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
135
150
|
receipt = appendGatewayReceipt, timeoutMs = 5000, maxDeferredBytes = 64 * 1024 * 1024, tempRoot = os.tmpdir(), now = () => new Date().toISOString() } = {}) {
|
|
136
151
|
const nonce = crypto.randomUUID();
|
|
137
152
|
const pending = new Map(), initIds = new Set(), ownIds = new Set(), held = new Set();
|
|
138
|
-
const accepted = new Map(), starts = new Map(), inactiveWaiters = new Set();
|
|
153
|
+
const accepted = new Map(), starts = new Map(), inactiveWaiters = new Set(), boundedWaiters = new Set();
|
|
139
154
|
let serial = Promise.resolve(), ready = harness === 'codex', active = false, closed = false, serialNumber = 0;
|
|
140
155
|
let readiness = null, resolveReady;
|
|
141
156
|
let queuedBytes = 0, spoolDirectory = null, spoolSequence = 0, declineUsers = false;
|
|
@@ -218,9 +233,10 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
218
233
|
};
|
|
219
234
|
function bounded(work) {
|
|
220
235
|
return new Promise((resolve, reject) => {
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
236
|
+
const finish = (error, value) => { clearTimeout(timer); boundedWaiters.delete(cancel); error ? reject(error) : resolve(value); };
|
|
237
|
+
const cancel = () => finish(new Error(REFUSED));
|
|
238
|
+
const timer = setTimeout(cancel, timeoutMs); boundedWaiters.add(cancel);
|
|
239
|
+
Promise.resolve(work).then((value) => finish(null, value), (error) => finish(error));
|
|
224
240
|
});
|
|
225
241
|
}
|
|
226
242
|
const control = (request) => new Promise((resolve, reject) => {
|
|
@@ -262,7 +278,7 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
262
278
|
if (harness === 'codex') {
|
|
263
279
|
if (message.method !== 'turn/start') {
|
|
264
280
|
const pair = accepted.get(message.params?.threadId);
|
|
265
|
-
if (!pair || pair.model !== decision.model || pair.effort !== decision.effort) {
|
|
281
|
+
if (!pair || pair.turnId !== message.params?.expectedTurnId || pair.model !== decision.model || pair.effort !== decision.effort) {
|
|
266
282
|
fail(message, 'Input not submitted: requires a new turn with reviewed allocation; current work continues.'); return;
|
|
267
283
|
}
|
|
268
284
|
record('active-input-approved', decision, false, { evidence: 'native-turn-start.accepted-configured-pair' });
|
|
@@ -271,6 +287,14 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
271
287
|
const account = await control({ method: 'account/read', params: { refreshToken: false } });
|
|
272
288
|
assertLive();
|
|
273
289
|
if (account?.account?.type !== 'chatgpt') throw new Error(REFUSED);
|
|
290
|
+
const thread = await control({ method: 'thread/read', params: { threadId: message.params?.threadId, includeTurns: false } });
|
|
291
|
+
assertLive();
|
|
292
|
+
if (thread?.thread?.id !== message.params?.threadId || thread.thread.modelProvider !== 'openai') throw new Error(REFUSED);
|
|
293
|
+
const cwd = message.params?.cwd ?? thread.thread.cwd;
|
|
294
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) throw new Error(REFUSED);
|
|
295
|
+
const effective = await control({ method: 'config/read', params: { includeLayers: false, cwd } });
|
|
296
|
+
assertLive();
|
|
297
|
+
if (!canonicalCodexConfig(effective?.config)) throw new Error(REFUSED);
|
|
274
298
|
const allowance = await control({ method: 'account/rateLimits/read', params: { excludeResetCreditDetails: true, supportsLunaReserve: false } });
|
|
275
299
|
assertLive();
|
|
276
300
|
if (allowance?.ordinaryUsageAllowed !== true) throw new Error(REFUSED);
|
|
@@ -310,10 +334,14 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
310
334
|
stream.on('end', () => { buffer += decoder.end(); if (buffer) handler(buffer); });
|
|
311
335
|
};
|
|
312
336
|
listenLines(input, (line) => {
|
|
337
|
+
if (closed) return;
|
|
313
338
|
let message;
|
|
314
339
|
try { message = JSON.parse(line); } catch { if (!closed) writeHost(line); return; }
|
|
315
340
|
if (harness === 'claude-code' && message.type === 'control_request' && message.request?.subtype === 'initialize') initIds.add(message.request_id);
|
|
316
|
-
|
|
341
|
+
if (harness === 'codex' && UNQUALIFIED.has(message.method)) {
|
|
342
|
+
fail(message, 'Native inference method is not qualified for gateway routing; request not forwarded.'); return;
|
|
343
|
+
}
|
|
344
|
+
const routable = harness === 'codex' ? ['turn/start', 'turn/steer'].includes(message.method) : message.type === 'user';
|
|
317
345
|
if (routable && harness === 'claude-code' && (declineUsers || queuedBytes + Buffer.byteLength(line) > maxDeferredBytes)) {
|
|
318
346
|
declineUsers = true;
|
|
319
347
|
const reason = 'Deferred input capacity (64 MiB) exceeded; new user packet not submitted; current work continues.';
|
|
@@ -334,9 +362,11 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
334
362
|
let unsafe = false;
|
|
335
363
|
if (harness === 'codex') {
|
|
336
364
|
const params = message.params || {};
|
|
337
|
-
unsafe = unsafeSettings(params.config) || unsafeSettings({ modelProvider: params.modelProvider ?? 'openai',
|
|
365
|
+
unsafe = unsafeSettings(params.config) || unsafeSettings({ modelProvider: params.modelProvider ?? 'openai',
|
|
366
|
+
serviceTier: params.serviceTier, service_tier: params.service_tier,
|
|
367
|
+
serviceTierForTurn: params.serviceTierForTurn, service_tier_for_turn: params.service_tier_for_turn });
|
|
338
368
|
if (message.method === 'account/login/start' && !['chatgpt', 'chatgptDeviceCode', 'chatgptAuthTokens'].includes(params.type)) unsafe = true;
|
|
339
|
-
if (
|
|
369
|
+
if (['thread/settings/update', 'turn/settings/update'].includes(message.method)) unsafe ||= unsafeSettings(params, '', true);
|
|
340
370
|
const edits = message.method === 'config/value/write' ? [params] : message.method === 'config/batchWrite' ? params.edits || [] : [];
|
|
341
371
|
if (message.method === 'config/batchWrite' && params.reloadUserConfig === true) unsafe = true;
|
|
342
372
|
unsafe ||= edits.some((edit) => unsafeSettings({ [edit.keyPath]: edit.value }, '', true));
|
|
@@ -366,9 +396,9 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
366
396
|
}
|
|
367
397
|
if (starts.has(id)) {
|
|
368
398
|
const pair = starts.get(id); starts.delete(id);
|
|
369
|
-
if (!message.error && message.result?.turn) accepted.set(pair.threadId, pair);
|
|
399
|
+
if (!message.error && typeof message.result?.turn?.id === 'string' && message.result.turn.id) accepted.set(pair.threadId, { ...pair, turnId: message.result.turn.id });
|
|
370
400
|
}
|
|
371
|
-
if (message.method === 'turn/completed') accepted.delete(message.params
|
|
401
|
+
if (message.method === 'turn/completed' && accepted.get(message.params?.threadId)?.turnId === message.params?.turn?.id) accepted.delete(message.params.threadId);
|
|
372
402
|
if (ownIds.has(id)) return; // late own-control replies never escape into the host client
|
|
373
403
|
if (initIds.has(id) && message.response?.subtype === 'success') {
|
|
374
404
|
ready = true; active = message.response.response?.session_state && message.response.response.session_state !== 'idle'; resolveReady?.();
|
|
@@ -379,7 +409,9 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
379
409
|
});
|
|
380
410
|
child.stderr?.on('data', (chunk) => diagnostics.write(chunk));
|
|
381
411
|
const close = () => {
|
|
412
|
+
if (closed) return;
|
|
382
413
|
closed = true; hostWriter.close(); inactiveWaiters.forEach((resolve) => resolve()); inactiveWaiters.clear();
|
|
414
|
+
boundedWaiters.forEach((cancel) => cancel()); boundedWaiters.clear(); accepted.clear(); starts.clear();
|
|
383
415
|
for (const token of [...held]) { token.cancelled = true; retire(token); }
|
|
384
416
|
if (spoolDirectory) fs.rmSync(spoolDirectory, { recursive: true, force: true });
|
|
385
417
|
spoolKey.fill(0);
|
|
@@ -387,7 +419,8 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
387
419
|
pending.clear();
|
|
388
420
|
};
|
|
389
421
|
child.once('error', close); child.once('exit', close); child.stdin.on('error', close); child.stdout.on('error', close);
|
|
390
|
-
|
|
422
|
+
const disconnect = () => { close(); if (!child.stdin.destroyed) child.stdin.end(); };
|
|
423
|
+
input.on('end', disconnect); input.on('error', disconnect); input.on('close', disconnect);
|
|
391
424
|
return { idle: () => serial, close, spoolState: () => ({ directory: spoolDirectory, queuedBytes }) };
|
|
392
425
|
}
|
|
393
426
|
|
|
@@ -9,6 +9,7 @@ import os from 'node:os';
|
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
12
13
|
|
|
13
14
|
const SELF = fileURLToPath(import.meta.url);
|
|
14
15
|
const sha = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
@@ -90,6 +91,27 @@ export function runtimeSnapshot(sourceRoot) {
|
|
|
90
91
|
}
|
|
91
92
|
};
|
|
92
93
|
for (const relative of ['scripts/model-routing-gateway.mjs', 'scripts/model-router-engine.mjs', 'scripts/metaharness-router.mjs']) visit(relative);
|
|
94
|
+
for (const relative of ['scripts/model-terminal-gateway.mjs', 'scripts/claude-terminal-mod.mjs', 'scripts/model-terminal-launchers.mjs']) {
|
|
95
|
+
if (fs.existsSync(path.join(root, relative))) visit(relative);
|
|
96
|
+
}
|
|
97
|
+
const mod = 'config/model-router/claude-terminal-mod';
|
|
98
|
+
if (fs.existsSync(path.join(root, mod))) {
|
|
99
|
+
for (const relative of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js', 'hooks/runtime.js']) visit(`${mod}/${relative}`);
|
|
100
|
+
}
|
|
101
|
+
// The socket adapter must resolve its transport from the same immutable closure.
|
|
102
|
+
// Optional native accelerators are deliberately excluded; ws supports pure JS.
|
|
103
|
+
if (files.has('scripts/model-terminal-gateway.mjs')) {
|
|
104
|
+
const require = createRequire(path.join(root, 'package.json'));
|
|
105
|
+
const packageRoot = path.dirname(regular(require.resolve('ws/package.json')));
|
|
106
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json')));
|
|
107
|
+
if (packageJson.name !== 'ws') throw new Error('Unexpected WebSocket dependency identity');
|
|
108
|
+
for (const relative of ['package.json', 'LICENSE', 'index.js', 'wrapper.mjs', 'browser.js',
|
|
109
|
+
...fs.readdirSync(path.join(packageRoot, 'lib')).filter((name) => name.endsWith('.js')).map((name) => `lib/${name}`)]) {
|
|
110
|
+
const file = regular(path.join(packageRoot, relative));
|
|
111
|
+
if (!file.startsWith(`${packageRoot}${path.sep}`)) throw new Error('WebSocket dependency escaped package root');
|
|
112
|
+
files.set(`node_modules/ws/${relative}`, fs.readFileSync(file));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
93
115
|
for (const name of fs.readdirSync(path.join(root, 'config/model-router'))) {
|
|
94
116
|
if (/\.(?:json|mjs)$/.test(name)) visit(`config/model-router/${name}`);
|
|
95
117
|
}
|
|
@@ -104,6 +126,19 @@ function stableNode() {
|
|
|
104
126
|
return process.execPath;
|
|
105
127
|
}
|
|
106
128
|
|
|
129
|
+
/** Terminal routing does not require a VS Code extension installation. */
|
|
130
|
+
export function installRoutingRuntime({ sourceRoot, home = os.homedir(), apply = false } = {}) {
|
|
131
|
+
const snapshot = runtimeSnapshot(sourceRoot);
|
|
132
|
+
const runtimeRoot = path.join(home, '.cache/ruvnet-brain/model-routing/versions', snapshot.digest);
|
|
133
|
+
if (apply) for (const [relative, bytes] of snapshot.files) {
|
|
134
|
+
const file = path.join(runtimeRoot, relative);
|
|
135
|
+
if (fs.existsSync(file)) {
|
|
136
|
+
if (sha(fs.readFileSync(regular(file))) !== sha(bytes)) throw new Error('Immutable runtime snapshot mismatch');
|
|
137
|
+
} else atomic(file, bytes);
|
|
138
|
+
}
|
|
139
|
+
return { apply, runtimeRoot, runtimeDigest: snapshot.digest, runtimeFiles: snapshot.identity };
|
|
140
|
+
}
|
|
141
|
+
|
|
107
142
|
export function installNativeLaunchers({ sourceRoot, home = os.homedir(), extensionsRoot = path.join(home, '.vscode-server/extensions'), nodeBinary = stableNode(), apply = false } = {}) {
|
|
108
143
|
// VS Code spawns its executable directly. A .cmd/.sh surrogate is not a proved
|
|
109
144
|
// Windows executable adapter; refuse before writing a misleading registration.
|
|
@@ -113,7 +148,7 @@ export function installNativeLaunchers({ sourceRoot, home = os.homedir(), extens
|
|
|
113
148
|
const runtime = path.join(base, 'versions', snapshot.digest);
|
|
114
149
|
const runner = path.join(base, 'model-routing-launchers.mjs');
|
|
115
150
|
const configPath = path.join(base, 'launcher-config.json');
|
|
116
|
-
const config = { extensionsRoot: fs.realpathSync(extensionsRoot), nodeBinary: (regular(fs.realpathSync(nodeBinary)), nodeBinary), gatewayPath: path.join(runtime, 'scripts/model-routing-gateway.mjs'), runtimeDigest: snapshot.digest };
|
|
151
|
+
const config = { extensionsRoot: fs.realpathSync(extensionsRoot), nodeBinary: (regular(fs.realpathSync(nodeBinary)), nodeBinary), gatewayPath: path.join(runtime, 'scripts/model-routing-gateway.mjs'), terminalGatewayPath: path.join(runtime, 'scripts/model-terminal-gateway.mjs'), runtimeRoot: runtime, runtimeDigest: snapshot.digest };
|
|
117
152
|
const codexBinary = discoverNativeCodex(config.extensionsRoot);
|
|
118
153
|
const launchers = Object.fromEntries(Object.keys(KEYS).map((host) => [host, path.join(home, '.local/bin', `ruvnet-brain-${host === 'codex' ? 'codex' : 'claude'}-gateway`)]));
|
|
119
154
|
const texts = Object.fromEntries(Object.entries(launchers).map(([host]) => [host, `#!/bin/sh\n# Managed RuvNet Brain native routing launcher\nexec ${shellQuote(config.nodeBinary)} ${shellQuote(runner)} --launch ${shellQuote(host)} --config ${shellQuote(configPath)} --launcher "$0" -- "$@"\n`]));
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Per-launch Unix WebSocket adapter for the native subscription TUI. No daemon lifecycle ownership.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { Duplex, PassThrough, Writable } from 'node:stream';
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { connectNativeGateway, decideNativeTurn, nativeGatewayLaunch } from './model-routing-gateway.mjs';
|
|
13
|
+
import { subscriptionEnvironment, assertSubscriptionAuth } from './model-router-dispatch.mjs';
|
|
14
|
+
|
|
15
|
+
export const MAX_TERMINAL_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
const REFUSED = 'Native terminal routing unavailable; request was not sent.';
|
|
17
|
+
const COMMANDS = new Set(['agents', 'exec', 'e', 'review', 'login', 'logout', 'mcp', 'plugin', 'app-server',
|
|
18
|
+
'remote-control', 'app', 'completion', 'update', 'doctor', 'sandbox', 'debug', 'apply', 'a', 'resume',
|
|
19
|
+
'queue', 'archive', 'delete', 'migrate-rollouts', 'unarchive', 'fork', 'cloud', 'exec-server', 'features', 'help']);
|
|
20
|
+
const ADMIN = new Set(['login', 'logout', 'mcp', 'plugin', 'completion', 'update', 'doctor', 'features', 'help']);
|
|
21
|
+
const VALUES = new Set(['-c', '--config', '--enable', '--disable', '-i', '--image', '-m', '--model',
|
|
22
|
+
'-p', '--profile', '-s', '--sandbox', '-C', '--cd', '--add-dir', '-a', '--ask-for-approval']);
|
|
23
|
+
|
|
24
|
+
/** Short Unix roots avoid long environment-provided temp paths; Windows has no qualified transport. */
|
|
25
|
+
export function terminalTempRoot(platform = process.platform) {
|
|
26
|
+
if (!['darwin', 'linux'].includes(platform)) throw new Error('Native Unix terminal routing requires macOS or Linux');
|
|
27
|
+
return platform === 'darwin' ? '/private/tmp' : '/tmp';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Keep native arguments verbatim; only proved interactive paths may enter the routed transport. */
|
|
31
|
+
export function classifyTerminalArguments(args = []) {
|
|
32
|
+
if (args.some((arg) => /^--(?:remote(?:-auth-token-env)?|no-daemon)(?:=|$)/.test(arg))) {
|
|
33
|
+
throw new Error('Explicit remote or no-daemon transport conflicts with terminal routing');
|
|
34
|
+
}
|
|
35
|
+
let first;
|
|
36
|
+
for (let index = 0; index < args.length; index++) {
|
|
37
|
+
const arg = args[index];
|
|
38
|
+
if (arg === '--') return 'interactive'; // Native '--' ends subcommand parsing: following words are prompts.
|
|
39
|
+
if (VALUES.has(arg)) { index++; continue; }
|
|
40
|
+
if (arg.startsWith('-')) continue;
|
|
41
|
+
first = arg; break;
|
|
42
|
+
}
|
|
43
|
+
if (ADMIN.has(first) || (!first && args.some((arg) => ['--help', '-h', '--version', '-V'].includes(arg)))) return 'admin';
|
|
44
|
+
if (COMMANDS.has(first) && !['resume', 'fork'].includes(first)) {
|
|
45
|
+
throw new Error(`Native command ${first} has no proved terminal routing transport`);
|
|
46
|
+
}
|
|
47
|
+
return 'interactive';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Reject symlinks and foreign/group-accessible endpoints, including their containing directory. */
|
|
51
|
+
export function validateUpstreamSocket(socketPath, { uid = process.getuid?.() } = {}) {
|
|
52
|
+
if (!path.isAbsolute(socketPath || '') || uid == null) throw new Error('Owned absolute Unix socket required');
|
|
53
|
+
const normalized = path.normalize(socketPath);
|
|
54
|
+
let current = normalized;
|
|
55
|
+
while (true) {
|
|
56
|
+
const entry = fs.lstatSync(current);
|
|
57
|
+
if (entry.isSymbolicLink()) throw new Error('Symlink in upstream socket path refused');
|
|
58
|
+
if (current === normalized && (!entry.isSocket() || entry.uid !== uid || (entry.mode & 0o077))) {
|
|
59
|
+
throw new Error('Upstream must be an owned private Unix socket');
|
|
60
|
+
}
|
|
61
|
+
if (current === path.dirname(normalized) && (!entry.isDirectory() || entry.uid !== uid || (entry.mode & 0o077))) {
|
|
62
|
+
throw new Error('Upstream socket directory must be owned and private');
|
|
63
|
+
}
|
|
64
|
+
if (current === path.dirname(current)) break;
|
|
65
|
+
current = path.dirname(current);
|
|
66
|
+
}
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function jsonPacket(text) {
|
|
71
|
+
const packet = JSON.parse(text);
|
|
72
|
+
if (!packet || typeof packet !== 'object' || Array.isArray(packet)
|
|
73
|
+
|| !(typeof packet.method === 'string' || (Object.hasOwn(packet, 'id')
|
|
74
|
+
&& (Object.hasOwn(packet, 'result') || Object.hasOwn(packet, 'error'))))) throw new Error(REFUSED);
|
|
75
|
+
if (Object.hasOwn(packet, 'id') && !(typeof packet.id === 'string' || Number.isSafeInteger(packet.id))) throw new Error(REFUSED);
|
|
76
|
+
return packet;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Official proxy is a byte tunnel: perform the daemon's /rpc WebSocket handshake over its stdio. */
|
|
80
|
+
export async function connectProxyWebSocket(proxy, { startupMs = 10000, maxBytes = MAX_TERMINAL_BYTES } = {}) {
|
|
81
|
+
const { WebSocket } = await import('ws');
|
|
82
|
+
const tunnel = Duplex.from({ readable: proxy.stdout, writable: proxy.stdin });
|
|
83
|
+
// createConnection always returns the owned tunnel; even the placeholder endpoint is Unix-only.
|
|
84
|
+
const ws = new WebSocket('ws+unix:///native-proxy:/rpc', { createConnection: () => tunnel,
|
|
85
|
+
perMessageDeflate: false, maxPayload: maxBytes, handshakeTimeout: startupMs, followRedirects: false });
|
|
86
|
+
const child = new EventEmitter(); child.on('error', () => {});
|
|
87
|
+
child.stdout = new PassThrough({ highWaterMark: 65536 }); child.stderr = proxy.stderr;
|
|
88
|
+
child.exitCode = null; child.signalCode = null;
|
|
89
|
+
let closed = false, ready = false, buffer = '';
|
|
90
|
+
const decoder = new StringDecoder('utf8');
|
|
91
|
+
const fail = () => { if (!closed) { child.emit('error', new Error(REFUSED)); child.kill('SIGTERM'); } };
|
|
92
|
+
child.kill = (signal) => {
|
|
93
|
+
if (closed && signal !== 'SIGKILL') return;
|
|
94
|
+
if (!closed) { closed = true; ws.terminate(); tunnel.destroy(); }
|
|
95
|
+
const sent = proxy.kill(signal);
|
|
96
|
+
if (signal !== 'SIGKILL') {
|
|
97
|
+
const killer = setTimeout(() => { if (proxy.exitCode == null && proxy.signalCode == null) proxy.kill('SIGKILL'); }, 1000);
|
|
98
|
+
killer.unref(); proxy.once('exit', () => clearTimeout(killer));
|
|
99
|
+
} child.exitCode = proxy.exitCode; child.signalCode = proxy.signalCode;
|
|
100
|
+
return sent;
|
|
101
|
+
};
|
|
102
|
+
child.stdin = new Writable({ highWaterMark: 65536, write(chunk, _encoding, callback) {
|
|
103
|
+
try {
|
|
104
|
+
buffer += decoder.write(chunk); if (Buffer.byteLength(buffer) > maxBytes) throw new Error(REFUSED);
|
|
105
|
+
const lines = buffer.split('\n'); buffer = lines.pop(); let index = 0;
|
|
106
|
+
const send = () => {
|
|
107
|
+
if (index === lines.length) { callback(); return; }
|
|
108
|
+
const line = lines[index++]; jsonPacket(line);
|
|
109
|
+
if (closed || ws.readyState !== WebSocket.OPEN || ws.bufferedAmount + Buffer.byteLength(line) > maxBytes) throw new Error(REFUSED);
|
|
110
|
+
ws.send(line, { binary: false }, (error) => {
|
|
111
|
+
if (error) { callback(error); fail(); return; }
|
|
112
|
+
try { send(); } catch (error) { callback(error); fail(); }
|
|
113
|
+
});
|
|
114
|
+
}; send();
|
|
115
|
+
} catch (error) { callback(error); fail(); }
|
|
116
|
+
} });
|
|
117
|
+
child.stdin.on('error', fail); child.stdout.on('error', fail);
|
|
118
|
+
ws.on('message', (bytes, binary) => {
|
|
119
|
+
try {
|
|
120
|
+
if (binary || bytes.length > maxBytes || child.stdout.writableLength + bytes.length + 1 > maxBytes) throw new Error(REFUSED);
|
|
121
|
+
const line = new TextDecoder('utf-8', { fatal: true }).decode(bytes); jsonPacket(line);
|
|
122
|
+
if (!child.stdout.write(line + '\n')) ws.pause();
|
|
123
|
+
} catch { fail(); }
|
|
124
|
+
});
|
|
125
|
+
child.stdout.on('drain', () => { if (!closed) ws.resume(); });
|
|
126
|
+
proxy.once('error', (error) => child.emit('error', error));
|
|
127
|
+
proxy.once('exit', (code, signal) => {
|
|
128
|
+
child.exitCode = code; child.signalCode = signal; child.emit('exit', code, signal);
|
|
129
|
+
if (!closed) { closed = true; ws.terminate(); tunnel.destroy(); }
|
|
130
|
+
});
|
|
131
|
+
ws.on('error', () => { if (ready) fail(); });
|
|
132
|
+
ws.once('close', () => { if (ready && !closed) fail(); });
|
|
133
|
+
try {
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
const rejectProxy = () => reject(new Error(REFUSED));
|
|
136
|
+
proxy.once('exit', rejectProxy); proxy.once('error', rejectProxy);
|
|
137
|
+
ws.once('error', rejectProxy); ws.once('close', rejectProxy);
|
|
138
|
+
ws.once('open', () => { proxy.removeListener('exit', rejectProxy); proxy.removeListener('error', rejectProxy);
|
|
139
|
+
ws.removeListener('error', rejectProxy); ws.removeListener('close', rejectProxy); ready = true; resolve(); });
|
|
140
|
+
});
|
|
141
|
+
} catch (error) { child.kill('SIGTERM'); throw error; }
|
|
142
|
+
return child;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Real HTTP/WebSocket framing over one private Unix socket, converted to native JSONL streams. */
|
|
146
|
+
export async function createTerminalTransport({ child, diagnostics = process.stderr, gatewayOptions = {},
|
|
147
|
+
tempRoot, maxBytes = MAX_TERMINAL_BYTES, startupMs = 10000, onFailure = () => {} } = {}) {
|
|
148
|
+
const defaultTempRoot = terminalTempRoot();
|
|
149
|
+
const { WebSocketServer, WebSocket } = await import('ws');
|
|
150
|
+
if (!child?.stdin || !child?.stdout) throw new Error('Native proxy child required');
|
|
151
|
+
if (!(maxBytes > 0 && maxBytes <= MAX_TERMINAL_BYTES)) throw new Error('Invalid transport byte bound');
|
|
152
|
+
const directory = fs.mkdtempSync(path.join(fs.realpathSync(tempRoot ?? defaultTempRoot), 'crt-'));
|
|
153
|
+
fs.chmodSync(directory, 0o700);
|
|
154
|
+
const socketPath = path.join(directory, 'r.sock');
|
|
155
|
+
if (Buffer.byteLength(socketPath) > 100) { fs.rmSync(directory, { recursive: true }); throw new Error('Unix socket path too long'); }
|
|
156
|
+
const server = http.createServer((_request, response) => { response.writeHead(403); response.end(); });
|
|
157
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: maxBytes, perMessageDeflate: false });
|
|
158
|
+
const input = new PassThrough({ highWaterMark: 65536 });
|
|
159
|
+
let client, claimed = false, gateway, closed = false, outputBuffer = '', failed = false;
|
|
160
|
+
const decoder = new StringDecoder('utf8'), initializations = new Set();
|
|
161
|
+
let resolveConnected, resolveReady;
|
|
162
|
+
const connected = new Promise((resolve) => { resolveConnected = resolve; });
|
|
163
|
+
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
|
164
|
+
const close = () => {
|
|
165
|
+
if (closed) return;
|
|
166
|
+
closed = true; clearTimeout(timer); gateway?.close(); input.destroy(); output.destroy();
|
|
167
|
+
client?.terminate(); wss.close(); server.close();
|
|
168
|
+
// This is the only child and filesystem tree owned by this launch.
|
|
169
|
+
child.kill?.('SIGTERM');
|
|
170
|
+
const killer = setTimeout(() => { if (child.exitCode == null && child.signalCode == null) child.kill?.('SIGKILL'); }, 1000);
|
|
171
|
+
killer.unref(); child.once?.('exit', () => clearTimeout(killer));
|
|
172
|
+
fs.rmSync(directory, { recursive: true, force: true }); resolveConnected(false); resolveReady(false);
|
|
173
|
+
};
|
|
174
|
+
const fail = () => {
|
|
175
|
+
if (closed) return;
|
|
176
|
+
failed = true; diagnostics.write(`[native-terminal-routing] ${REFUSED}\n`); close(); onFailure();
|
|
177
|
+
};
|
|
178
|
+
const output = new Writable({ highWaterMark: 65536, write(chunk, _encoding, callback) {
|
|
179
|
+
try {
|
|
180
|
+
outputBuffer += decoder.write(chunk);
|
|
181
|
+
if (Buffer.byteLength(outputBuffer) > maxBytes) throw new Error(REFUSED);
|
|
182
|
+
const lines = outputBuffer.split('\n'); outputBuffer = lines.pop();
|
|
183
|
+
let index = 0;
|
|
184
|
+
const send = () => {
|
|
185
|
+
if (index >= lines.length) { callback(); return; }
|
|
186
|
+
const line = lines[index++], packet = jsonPacket(line);
|
|
187
|
+
if (initializations.has(packet.id)) {
|
|
188
|
+
if (packet.error) throw new Error(REFUSED);
|
|
189
|
+
initializations.delete(packet.id); clearTimeout(timer); resolveReady(true);
|
|
190
|
+
}
|
|
191
|
+
if (closed || client?.readyState !== WebSocket.OPEN || client.bufferedAmount + Buffer.byteLength(line) > maxBytes) throw new Error(REFUSED);
|
|
192
|
+
client.send(line, { binary: false }, (error) => {
|
|
193
|
+
if (error) { callback(error); fail(); return; }
|
|
194
|
+
try { send(); } catch (error) { callback(error); fail(); }
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
send();
|
|
198
|
+
} catch (error) { callback(error); fail(); }
|
|
199
|
+
} });
|
|
200
|
+
output.on('error', fail); input.on('error', fail);
|
|
201
|
+
const timer = setTimeout(fail, startupMs);
|
|
202
|
+
server.on('error', fail); wss.on('error', fail);
|
|
203
|
+
server.on('upgrade', (request, socket, head) => {
|
|
204
|
+
// Refuse a second client for this launch, including reconnect after disconnect.
|
|
205
|
+
if (closed || claimed || request.url !== '/rpc' || request.headers.origin) {
|
|
206
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
|
|
207
|
+
}
|
|
208
|
+
claimed = true;
|
|
209
|
+
wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws));
|
|
210
|
+
});
|
|
211
|
+
wss.on('connection', (ws) => {
|
|
212
|
+
client = ws;
|
|
213
|
+
gateway = connectNativeGateway({ ...gatewayOptions, harness: 'codex', child, input, output, diagnostics });
|
|
214
|
+
ws.on('message', (bytes, binary) => {
|
|
215
|
+
try {
|
|
216
|
+
if (binary || bytes.length > maxBytes || closed) throw new Error(REFUSED);
|
|
217
|
+
const line = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
218
|
+
const packet = jsonPacket(line);
|
|
219
|
+
if (packet.method === 'initialize' && Object.hasOwn(packet, 'id')) initializations.add(packet.id);
|
|
220
|
+
// Shared daemon ownership never crosses this per-client boundary.
|
|
221
|
+
if (['shutdown', 'exit'].includes(packet.method)) {
|
|
222
|
+
if (Object.hasOwn(packet, 'id')) ws.send(JSON.stringify({ id: packet.id, error: { code: -32001, message: 'Shared daemon shutdown refused' } }));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (gateway.spoolState().queuedBytes + input.writableLength + Buffer.byteLength(line) + 1 > maxBytes) throw new Error(REFUSED);
|
|
226
|
+
if (!input.write(JSON.stringify(packet) + '\n')) ws.pause();
|
|
227
|
+
} catch { fail(); }
|
|
228
|
+
});
|
|
229
|
+
input.on('drain', () => { if (!closed) ws.resume(); });
|
|
230
|
+
ws.once('close', close); ws.on('error', fail); resolveConnected(true);
|
|
231
|
+
});
|
|
232
|
+
child.once('error', fail); child.once('exit', fail);
|
|
233
|
+
child.stdin.on('error', fail); child.stdout.on('error', fail);
|
|
234
|
+
try {
|
|
235
|
+
await new Promise((resolve, reject) => {
|
|
236
|
+
server.once('error', reject); server.listen(socketPath, () => { server.removeListener('error', reject); resolve(); });
|
|
237
|
+
});
|
|
238
|
+
if (closed) throw new Error(REFUSED);
|
|
239
|
+
fs.chmodSync(socketPath, 0o600);
|
|
240
|
+
} catch (error) { close(); throw error; }
|
|
241
|
+
return { socketPath, directory, remote: `unix://${socketPath}`, connected, ready, close,
|
|
242
|
+
failed: () => failed, gateway: () => gateway };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function parseTerminalInvocation(argv, env = process.env) {
|
|
246
|
+
const args = [...argv]; let realBinary = env.MODEL_ROUTER_REAL_CODEX, upstreamSocket;
|
|
247
|
+
while (args.length && args[0] !== '--') {
|
|
248
|
+
const flag = args.shift();
|
|
249
|
+
if (!['--real-binary', '--upstream-socket'].includes(flag) || !args.length) throw new Error('Explicit terminal gateway options required');
|
|
250
|
+
if (flag === '--real-binary') realBinary = args.shift(); else upstreamSocket = args.shift();
|
|
251
|
+
}
|
|
252
|
+
if (args[0] === '--') args.shift();
|
|
253
|
+
return { realBinary, upstreamSocket, args, env };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function runTerminalGateway({ realBinary, upstreamSocket, args = [], env = process.env,
|
|
257
|
+
spawnNative = spawn, adaptProxy = connectProxyWebSocket, gatewayOptions = {}, startupMs = 10000, tempRoot,
|
|
258
|
+
signalSource = process, diagnostics = process.stderr } = {}) {
|
|
259
|
+
if (!path.isAbsolute(realBinary || '')) throw new Error('Explicit absolute native executable required');
|
|
260
|
+
const binary = fs.realpathSync(realBinary), clean = subscriptionEnvironment(env);
|
|
261
|
+
if (env.MODEL_ROUTER_TERMINAL_ACTIVE || env.MODEL_ROUTER_GATEWAY_ACTIVE) throw new Error('Terminal routing recursion refused');
|
|
262
|
+
const mode = classifyTerminalArguments(args);
|
|
263
|
+
if (mode === 'admin') {
|
|
264
|
+
const child = spawnNative(binary, args, { env: clean, shell: false, stdio: 'inherit' });
|
|
265
|
+
return await exitOf(child);
|
|
266
|
+
}
|
|
267
|
+
const defaultTempRoot = terminalTempRoot();
|
|
268
|
+
tempRoot ??= defaultTempRoot;
|
|
269
|
+
// Reuse the established provider/config guards; this does not spawn app-server.
|
|
270
|
+
const normalized = nativeGatewayLaunch({ harness: 'codex', realBinary: binary, args: ['app-server', ...args], env: clean });
|
|
271
|
+
// Native global options precede the original subcommand/prompt, including a literal '--'.
|
|
272
|
+
const standardOverrides = normalized.args.slice(args.length + 1);
|
|
273
|
+
const socketPath = validateUpstreamSocket(upstreamSocket || path.join(clean.CODEX_HOME || path.join(os.homedir(), '.codex'), 'app-server-control', 'app-server-control.sock'));
|
|
274
|
+
const auth = () => assertSubscriptionAuth('codex', { env: clean }); auth();
|
|
275
|
+
clean.MODEL_ROUTER_TERMINAL_ACTIVE = '1';
|
|
276
|
+
const proxy = spawnNative(binary, ['app-server', 'proxy', '--sock', socketPath], { env: clean, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
277
|
+
let tui, transport, forcedFailure = false;
|
|
278
|
+
const handlers = new Map();
|
|
279
|
+
try {
|
|
280
|
+
const backend = await adaptProxy(proxy, { startupMs });
|
|
281
|
+
transport = await createTerminalTransport({ child: backend, diagnostics, startupMs, tempRoot,
|
|
282
|
+
onFailure: () => { forcedFailure = true; tui?.kill('SIGTERM'); },
|
|
283
|
+
gatewayOptions: { ...gatewayOptions, checkAuth: auth,
|
|
284
|
+
decide: gatewayOptions.decide || ((prompt, harness, metadata) => decideNativeTurn(prompt, harness, { env: clean, ...metadata })) } });
|
|
285
|
+
if (transport.failed()) throw new Error(REFUSED);
|
|
286
|
+
tui = spawnNative(binary, ['--remote', transport.remote, ...standardOverrides, ...args], { env: clean, shell: false, stdio: 'inherit' });
|
|
287
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
288
|
+
const handler = () => { transport.close(); tui.kill(signal); };
|
|
289
|
+
handlers.set(signal, handler); signalSource.on(signal, handler);
|
|
290
|
+
}
|
|
291
|
+
const result = await exitOf(tui);
|
|
292
|
+
return forcedFailure ? { code: 1, signal: null } : result;
|
|
293
|
+
} finally {
|
|
294
|
+
handlers.forEach((handler, signal) => signalSource.removeListener(signal, handler));
|
|
295
|
+
if (transport) transport.close(); else proxy.kill('SIGTERM');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function exitOf(child) {
|
|
300
|
+
return new Promise((resolve, reject) => {
|
|
301
|
+
child.once('error', reject); child.once('exit', (code, signal) => resolve({ code, signal }));
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
306
|
+
runTerminalGateway(parseTerminalInvocation(process.argv.slice(2))).then(({ code, signal }) => {
|
|
307
|
+
if (signal) { process.removeAllListeners(signal); process.kill(process.pid, signal); }
|
|
308
|
+
else process.exitCode = code ?? 1;
|
|
309
|
+
}).catch((error) => { process.stderr.write(`[native-terminal-routing] ${error.message}\n`); process.exitCode = 1; });
|
|
310
|
+
}
|