ruvnet-brain 4.5.4 → 4.5.6
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 +2 -2
- package/bin/install.mjs +162 -27
- package/config/model-router/catalog.template.json +126 -52
- 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 +97 -75
- package/config/model-router/qualification-contract.json +124 -0
- package/config/model-router/routing-eval-cases.json +275 -0
- package/config/model-router/routing-policy.template.json +76 -0
- package/config/model-router/weekly-analyst-instruction.md +60 -0
- package/data/model-catalog.json +44 -49
- package/package.json +6 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +9 -4
- package/plugin/scripts/capability-claim-evidence.mjs +9 -2
- package/plugin/scripts/codex-hook-adapter.mjs +18 -9
- package/plugin/scripts/project-capture-queue.mjs +7 -1
- package/plugin/scripts/project-progression-hook.mjs +16 -7
- package/plugin/scripts/project-progression-outbox.mjs +96 -15
- package/plugin/scripts/project-progression-producer.mjs +12 -1
- package/plugin/scripts/project-progression-store.mjs +53 -1
- package/plugin/scripts/project-transition-hook.mjs +19 -8
- package/plugin/scripts/session-snapshot-hook.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +89 -0
- package/scripts/codex-hook-trust-reconcile.mjs +247 -0
- package/scripts/codex-routed.sh +3 -36
- package/scripts/goldie-weekly.sh +8 -64
- package/scripts/metaharness-router.mjs +7 -1
- package/scripts/model-analyst-sandbox.mjs +54 -0
- package/scripts/model-currency-evidence.mjs +139 -0
- package/scripts/model-currency.mjs +230 -0
- package/scripts/model-native-catalog.mjs +111 -0
- package/scripts/model-native-qualification.mjs +251 -0
- package/scripts/model-router-agent-hook.mjs +136 -0
- package/scripts/model-router-dispatch.mjs +161 -0
- package/scripts/model-router-engine.mjs +155 -104
- package/scripts/model-routing-eval.mjs +108 -0
- package/scripts/model-routing-gateway.mjs +453 -0
- package/scripts/model-routing-launchers.mjs +209 -0
- package/scripts/model-routing-policy-promotion.mjs +203 -0
- package/scripts/model-terminal-gateway.mjs +310 -0
- package/scripts/model-terminal-launchers.mjs +300 -0
- package/scripts/model-weekly-analyst.mjs +299 -0
- package/scripts/model-weekly-assessment.mjs +91 -0
- package/scripts/model-weekly-cycle.mjs +183 -0
- package/scripts/model-weekly-qualification.mjs +362 -0
- package/scripts/native-subscription-usage.mjs +57 -0
- package/scripts/release-qualification-contract.mjs +50 -1
- package/scripts/release-qualification.mjs +9 -6
- package/scripts/security-guidance-codex-compat.mjs +142 -0
- package/scripts/user-model-prompt-hook.mjs +69 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Explicit per-user terminal integration. Native binaries and authentication remain user owned.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { classifyTerminalArguments, validateUpstreamSocket, terminalTempRoot } from './model-terminal-gateway.mjs';
|
|
10
|
+
import { nativeGatewayLaunch } from './model-routing-gateway.mjs';
|
|
11
|
+
import { subscriptionEnvironment, assertSubscriptionAuth } from './model-router-dispatch.mjs';
|
|
12
|
+
|
|
13
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
14
|
+
const MARKER = '# Managed RuvNet Brain terminal launcher';
|
|
15
|
+
const START = '# >>> RuvNet Brain terminal routing >>>';
|
|
16
|
+
const END = '# <<< RuvNet Brain terminal routing <<<';
|
|
17
|
+
const quote = (v) => `'${String(v).replaceAll("'", "'\\''")}'`;
|
|
18
|
+
const digest = (v) => crypto.createHash('sha256').update(v).digest('hex');
|
|
19
|
+
|
|
20
|
+
function regular(file, { privateFile = false } = {}) {
|
|
21
|
+
if (!path.isAbsolute(file || '')) throw new Error('Absolute file path required');
|
|
22
|
+
const stat = fs.lstatSync(file);
|
|
23
|
+
const ownerAllowed = stat.uid === process.getuid?.() || (!privateFile && stat.uid === 0);
|
|
24
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !ownerAllowed ||
|
|
25
|
+
(privateFile && (stat.mode & 0o077))) throw new Error(`Owned regular file required: ${file}`);
|
|
26
|
+
return fs.realpathSync(file);
|
|
27
|
+
}
|
|
28
|
+
function executable(file) {
|
|
29
|
+
const result = regular(fs.realpathSync(file)); fs.accessSync(result, fs.constants.X_OK); return result;
|
|
30
|
+
}
|
|
31
|
+
function atomic(file, content, mode = 0o600) {
|
|
32
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
33
|
+
if (fs.existsSync(file)) regular(file);
|
|
34
|
+
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
35
|
+
try {
|
|
36
|
+
const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, mode);
|
|
37
|
+
try { fs.writeFileSync(fd, content); fs.fchmodSync(fd, mode); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
38
|
+
fs.renameSync(temp, file);
|
|
39
|
+
} finally { fs.rmSync(temp, { force: true }); }
|
|
40
|
+
}
|
|
41
|
+
function backup(file) {
|
|
42
|
+
if (!fs.existsSync(file)) return null;
|
|
43
|
+
const bytes = fs.readFileSync(regular(file)); const target = `${file}.${digest(bytes)}.original`;
|
|
44
|
+
if (fs.existsSync(target)) {
|
|
45
|
+
if (digest(fs.readFileSync(regular(target))) !== digest(bytes)) throw new Error('Backup digest mismatch');
|
|
46
|
+
} else fs.writeFileSync(target, bytes, { flag: 'wx', mode: 0o400 });
|
|
47
|
+
return target;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Follow only the native daemon's known user-owned locator, then validate the actual private endpoint. */
|
|
51
|
+
export function resolveTerminalUpstream({ codexHome = path.join(os.homedir(), '.codex'), uid = process.getuid?.(),
|
|
52
|
+
daemonRoot = terminalTempRoot() } = {}) {
|
|
53
|
+
if (!path.isAbsolute(codexHome) || !path.isAbsolute(daemonRoot) || uid == null) throw new Error('Absolute owned socket locator required');
|
|
54
|
+
const home = fs.realpathSync(codexHome);
|
|
55
|
+
const directory = path.join(home, 'app-server-control');
|
|
56
|
+
const d = fs.lstatSync(directory);
|
|
57
|
+
if (!d.isDirectory() || d.isSymbolicLink() || d.uid !== uid || (d.mode & 0o022)) throw new Error('Native socket locator directory is not owned and protected');
|
|
58
|
+
const locator = path.join(directory, 'app-server-control.sock');
|
|
59
|
+
const s = fs.lstatSync(locator);
|
|
60
|
+
if (!s.isSymbolicLink()) return validateUpstreamSocket(locator, { uid });
|
|
61
|
+
if (s.uid !== uid) throw new Error('Native socket locator is foreign');
|
|
62
|
+
const actual = fs.realpathSync(locator);
|
|
63
|
+
const expected = path.join(fs.realpathSync(daemonRoot), `codex-daemon-${uid}`);
|
|
64
|
+
if (path.dirname(actual) !== expected || !/^[a-f0-9]{64}$/.test(path.basename(actual))) throw new Error('Native socket locator escaped the known daemon endpoint');
|
|
65
|
+
return validateUpstreamSocket(actual, { uid });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unmanagedShellNames(text) {
|
|
69
|
+
return ['codex', 'claude'].filter((name) => new RegExp(`(?:^|\\n)\\s*(?:alias\\s+${name}\\s*=|(?:function\\s+)?${name}\\s*\\(\\s*\\)|function\\s+${name}\\s*(?:\\{|\\n))`).test(text));
|
|
70
|
+
}
|
|
71
|
+
export function terminalShellPlan(text, sourcePath, launchers) {
|
|
72
|
+
const starts = text.split(START).length - 1, ends = text.split(END).length - 1;
|
|
73
|
+
if (starts !== ends || starts > 1) throw new Error('Malformed managed terminal shell block');
|
|
74
|
+
const block = starts ? text.slice(text.indexOf(START), text.indexOf(END) + END.length) : null;
|
|
75
|
+
const outside = block ? text.replace(block, '') : text;
|
|
76
|
+
const conflicts = unmanagedShellNames(outside);
|
|
77
|
+
const lines = [MARKER];
|
|
78
|
+
for (const name of ['codex', 'claude']) {
|
|
79
|
+
if (launchers[name] && !conflicts.includes(name)) lines.push(`function ${name}() { ${quote(launchers[name])} "$@"; }`);
|
|
80
|
+
}
|
|
81
|
+
const managed = `${START}\nsource ${quote(sourcePath)}\n${END}`;
|
|
82
|
+
return { conflicts, source: lines.join('\n') + '\n', zshrc: block ? text.replace(block, managed) : `${text}${text && !text.endsWith('\n') ? '\n' : ''}${managed}\n` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Runtime is supplied by the snapshot installer; this module does not rebuild or mutate it. */
|
|
86
|
+
export function installTerminalLaunchers({ home = os.homedir(), nodeBinary = process.execPath, runtimeRoot,
|
|
87
|
+
runtimeDigest, realCodex = path.join(home, '.local/bin/codex'), realClaude, apply = false, manageZsh = true } = {}) {
|
|
88
|
+
if (!['darwin', 'linux'].includes(process.platform)) throw new Error('Terminal launchers require macOS or Linux');
|
|
89
|
+
if (!path.isAbsolute(home) || !path.isAbsolute(runtimeRoot || '') || !/^[a-f0-9]{64}$/.test(runtimeDigest || '')) throw new Error('Absolute home and identified runtime required');
|
|
90
|
+
const runner = regular(path.join(runtimeRoot, 'scripts/model-terminal-launchers.mjs'));
|
|
91
|
+
const config = { schemaVersion: 1, managedBy: 'ruvnet-brain-terminal-launchers', runtimeDigest, runtimeRoot: fs.realpathSync(runtimeRoot), nodeBinary: executable(nodeBinary),
|
|
92
|
+
runner, realCodex: executable(realCodex), gatewayPath: regular(path.join(runtimeRoot, 'scripts/model-terminal-gateway.mjs')) };
|
|
93
|
+
if (realClaude) {
|
|
94
|
+
config.realClaude = executable(realClaude);
|
|
95
|
+
config.claudeHelperPath = regular(path.join(runtimeRoot, 'scripts/claude-terminal-mod.mjs'));
|
|
96
|
+
config.enginePath = regular(path.join(runtimeRoot, 'scripts/model-router-engine.mjs'));
|
|
97
|
+
}
|
|
98
|
+
const configPath = path.join(home, '.cache/ruvnet-brain/model-routing/terminal-launcher-config.json');
|
|
99
|
+
if (fs.existsSync(configPath) && JSON.parse(fs.readFileSync(regular(configPath), 'utf8')).managedBy !== config.managedBy) throw new Error('Preserving unmanaged terminal configuration');
|
|
100
|
+
const launchers = { codex: path.join(home, '.local/bin/ruvnet-brain-codex-terminal') };
|
|
101
|
+
if (realClaude) launchers.claude = path.join(home, '.local/bin/ruvnet-brain-claude-terminal');
|
|
102
|
+
const contents = Object.fromEntries(Object.entries(launchers).map(([host, file]) => [file,
|
|
103
|
+
`#!/bin/sh\n${MARKER}\nexec ${quote(config.nodeBinary)} ${quote(runner)} --launch ${quote(host)} --config ${quote(configPath)} -- "$@"\n`]));
|
|
104
|
+
const shellSource = path.join(home, '.config/ruvnet-brain/terminal-routing.zsh');
|
|
105
|
+
const zshrc = path.join(home, '.zshrc');
|
|
106
|
+
const shell = terminalShellPlan(fs.existsSync(zshrc) ? fs.readFileSync(regular(zshrc), 'utf8') : '', shellSource, launchers);
|
|
107
|
+
for (const file of [...Object.keys(contents), shellSource]) {
|
|
108
|
+
if (fs.existsSync(file) && !fs.readFileSync(regular(file), 'utf8').includes(MARKER)) throw new Error(`Preserving unmanaged terminal integration: ${file}`);
|
|
109
|
+
}
|
|
110
|
+
for (const binary of [config.realCodex, config.realClaude].filter(Boolean)) {
|
|
111
|
+
if ([runner, ...Object.keys(contents)].includes(binary)) throw new Error('Native terminal launcher recursion refused');
|
|
112
|
+
}
|
|
113
|
+
const receipt = { apply, launchers, configPath, config, shellSource, shellConflicts: shell.conflicts, backups: [],
|
|
114
|
+
claudeEnforcementScope: realClaude ? 'Startup hook activation guard; continuing native worker enforcement requires separate proof' : null };
|
|
115
|
+
if (!apply) return receipt;
|
|
116
|
+
for (const file of [configPath, ...Object.keys(contents), ...(manageZsh ? [shellSource, zshrc] : [])]) {
|
|
117
|
+
const original = backup(file); if (original) receipt.backups.push(original);
|
|
118
|
+
}
|
|
119
|
+
atomic(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
120
|
+
for (const [file, content] of Object.entries(contents)) atomic(file, content, 0o755);
|
|
121
|
+
if (manageZsh) { atomic(shellSource, shell.source); atomic(zshrc, shell.zshrc, fs.existsSync(zshrc) ? fs.statSync(zshrc).mode & 0o777 : 0o600); }
|
|
122
|
+
return receipt;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Supported native start is idempotent; never stop, restart, update or own the shared daemon. */
|
|
126
|
+
export function ensureTerminalDaemon(binary, env = process.env, { exec = execFileSync } = {}) {
|
|
127
|
+
try {
|
|
128
|
+
exec(binary, ['app-server', 'daemon', 'start'], { env: subscriptionEnvironment(env), timeout: 10000,
|
|
129
|
+
stdio: 'ignore', shell: false, maxBuffer: 65536 });
|
|
130
|
+
} catch { throw new Error('Native Codex daemon start unavailable; terminal launch blocked'); }
|
|
131
|
+
}
|
|
132
|
+
export function terminalInvocation({ host, args = [], config, env = process.env, daemonExec = execFileSync }) {
|
|
133
|
+
if (config.schemaVersion !== 1 || env.RNB_TERMINAL_LAUNCH_ACTIVE) throw new Error('Invalid or recursive terminal launch');
|
|
134
|
+
const binary = executable(host === 'codex' ? config.realCodex : config.realClaude);
|
|
135
|
+
if (host !== 'codex') throw new Error('Use guarded native Claude launch');
|
|
136
|
+
const mode = classifyTerminalArguments(args);
|
|
137
|
+
if (mode === 'admin') return { command: binary, args: [...args], routed: false };
|
|
138
|
+
// Validate provider/transport overrides before native daemon startup has any side effect.
|
|
139
|
+
nativeGatewayLaunch({ harness: 'codex', realBinary: binary, args: ['app-server', ...args], env: subscriptionEnvironment(env) });
|
|
140
|
+
ensureTerminalDaemon(binary, env, { exec: daemonExec });
|
|
141
|
+
const upstream = resolveTerminalUpstream({ codexHome: env.CODEX_HOME || path.join(os.homedir(), '.codex') });
|
|
142
|
+
return { command: executable(config.nodeBinary), args: [regular(config.gatewayPath), '--real-binary', binary, '--upstream-socket', upstream, '--', ...args], routed: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function verifyNativeWorkerAncestry(workerPid, nativePid, { uid = process.getuid?.() } = {}) {
|
|
146
|
+
if (![workerPid, nativePid, uid].every(Number.isSafeInteger) || workerPid <= 0 || nativePid <= 0) return false;
|
|
147
|
+
let current = workerPid;
|
|
148
|
+
for (let depth = 0; depth < 12; depth++) {
|
|
149
|
+
let result;
|
|
150
|
+
try { result = execFileSync('/bin/ps', ['-o', 'uid=,ppid=', '-p', String(current)], { encoding: 'utf8', timeout: 1000, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return false; }
|
|
151
|
+
const match = /^(\d+)\s+(\d+)$/.exec(result);
|
|
152
|
+
if (!match || Number(match[1]) !== uid) return false;
|
|
153
|
+
if (current === nativePid) return true;
|
|
154
|
+
const parent = Number(match[2]); if (parent <= 1 || parent === current) return false;
|
|
155
|
+
current = parent;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
export function validateClaudeReadiness(file, { nonce, pluginDir, modDigest, pid, startedAt, now = Date.now() }) {
|
|
160
|
+
const receipt = JSON.parse(fs.readFileSync(regular(file, { privateFile: true }), 'utf8'));
|
|
161
|
+
const observed = Date.parse(receipt.observedAt);
|
|
162
|
+
if (receipt.schemaVersion !== 1 || receipt.nonce !== nonce || receipt.pluginRoot !== fs.realpathSync(pluginDir) ||
|
|
163
|
+
receipt.modDigest !== modDigest || !verifyNativeWorkerAncestry(receipt.pid, pid) || receipt.status !== 'ready' ||
|
|
164
|
+
!/^2\.1\.\d+$/.test(receipt.nativeVersion || '') || Number(receipt.nativeVersion.split('.')[2]) < 287 ||
|
|
165
|
+
typeof receipt.sessionId !== 'string' || !receipt.sessionId || !Number.isFinite(observed) ||
|
|
166
|
+
observed < startedAt || observed > now || now - observed > 10000) throw new Error('Native Claude hook activation receipt invalid');
|
|
167
|
+
return receipt;
|
|
168
|
+
}
|
|
169
|
+
function exitOf(child) {
|
|
170
|
+
return new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', (code, signal) => resolve({ code, signal })); });
|
|
171
|
+
}
|
|
172
|
+
function forwardSignals(child, source) {
|
|
173
|
+
const handlers = new Map(['SIGINT', 'SIGTERM', 'SIGHUP'].map((signal) => [signal, () => child.kill(signal)]));
|
|
174
|
+
handlers.forEach((handler, signal) => source.on(signal, handler));
|
|
175
|
+
return () => handlers.forEach((handler, signal) => source.removeListener(signal, handler));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function validateClaudeTerminalSettings({ env = process.env, cwd = process.cwd(), home = os.homedir() } = {}) {
|
|
179
|
+
for (const key of ['CLAUDE_CODE_SAFE_MODE', 'CLAUDE_CODE_SIMPLE', 'CLAUDE_CODE_DISABLE_HOOKS', 'DISABLE_CLAUDE_CODE_MODS']) {
|
|
180
|
+
if (env[key] && !['0', 'false'].includes(env[key])) throw new Error('Claude environment disables native routing hooks');
|
|
181
|
+
}
|
|
182
|
+
const files = new Set([path.join(env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'), 'settings.json'),
|
|
183
|
+
process.platform === 'darwin' ? '/Library/Application Support/ClaudeCode/managed-settings.json' : '/etc/claude-code/managed-settings.json']);
|
|
184
|
+
let directory = path.resolve(cwd);
|
|
185
|
+
while (true) {
|
|
186
|
+
for (const name of ['settings.json', 'settings.local.json']) files.add(path.join(directory, '.claude', name));
|
|
187
|
+
if (directory === path.dirname(directory)) break;
|
|
188
|
+
directory = path.dirname(directory);
|
|
189
|
+
}
|
|
190
|
+
const billing = /^(?:OPENAI_API_KEY|OPENAI_BASE_URL|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ANTHROPIC_BASE_URL|ANTHROPIC_CUSTOM_HEADERS|CLAUDE_CODE_USE_BEDROCK|CLAUDE_CODE_USE_VERTEX|CLAUDE_CODE_USE_FOUNDRY|CODEX_API_KEY|CLAUDE_CODE_SAFE_MODE|CLAUDE_CODE_SIMPLE|CLAUDE_CODE_DISABLE_HOOKS|DISABLE_CLAUDE_CODE_MODS)$/;
|
|
191
|
+
const unsafe = (value) => value && typeof value === 'object' && Object.entries(value).some(([key, entry]) => {
|
|
192
|
+
if (key === 'env') return !entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some((name) => billing.test(name));
|
|
193
|
+
// The user's native modelSettings are ordinary model-keyed effort settings; the mod selects each turn.
|
|
194
|
+
if (key === 'modelSettings') return !entry || typeof entry !== 'object' || Array.isArray(entry) || Object.entries(entry).some(([model, settings]) =>
|
|
195
|
+
!/^claude-[a-z0-9][a-z0-9.-]*$/.test(model) || !settings || typeof settings !== 'object' || Array.isArray(settings) ||
|
|
196
|
+
Object.entries(settings).some(([name, level]) => name !== 'effortLevel' || !['low', 'medium', 'high', 'xhigh', 'max'].includes(level)));
|
|
197
|
+
return /^(?:apiKeyHelper|api_?key|base_?url|auth_?token|customHeaders|modelProvider|model_providers|alwaysThinkingEnabled|maxEffortLevel)$/i.test(key) ||
|
|
198
|
+
key === 'fastMode' && entry !== false || unsafe(entry);
|
|
199
|
+
});
|
|
200
|
+
for (const file of files) {
|
|
201
|
+
if (!fs.existsSync(file)) continue;
|
|
202
|
+
const stat = fs.lstatSync(file);
|
|
203
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Claude settings must be regular files');
|
|
204
|
+
let settings;
|
|
205
|
+
try { settings = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { throw new Error('Claude settings unavailable or malformed'); }
|
|
206
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings) || unsafe(settings)) throw new Error('Claude settings conflict with native subscription routing');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
export function validateClaudeTerminalArguments(args) {
|
|
210
|
+
const flags = new Set(['--continue', '-c', '--resume', '-r', '--fork-session', '--no-session-persistence',
|
|
211
|
+
'--verbose', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions']);
|
|
212
|
+
const values = new Set(['--permission-mode', '--session-id', '--add-dir']);
|
|
213
|
+
const commands = new Set(['agents', 'remote-control', 'setup-token', 'serve', 'web', 'bridge', 'completion',
|
|
214
|
+
'attach', 'auto-mode', 'gateway', 'import', 'logs', 'purge', 'respawn', 'rm', 'stop', 'kill', 'ultrareview']);
|
|
215
|
+
const options = [], prompts = [];
|
|
216
|
+
for (let index = 0; index < args.length; index++) {
|
|
217
|
+
const arg = args[index];
|
|
218
|
+
if (arg === '--') { prompts.push(...args.slice(index + 1)); break; }
|
|
219
|
+
if (values.has(arg)) { if (!args[++index] || args[index].startsWith('-')) throw new Error('Claude terminal flag requires a value'); options.push(arg, args[index]); continue; }
|
|
220
|
+
if (arg.startsWith('-') && !flags.has(arg) || commands.has(arg)) throw new Error('Claude terminal arguments conflict with guarded native routing');
|
|
221
|
+
if (flags.has(arg)) {
|
|
222
|
+
options.push(arg);
|
|
223
|
+
if (['--resume', '-r'].includes(arg) && args[index + 1] && !args[index + 1].startsWith('-')) options.push(args[++index]);
|
|
224
|
+
} else prompts.push(arg);
|
|
225
|
+
}
|
|
226
|
+
return { options, prompts };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Native plugin startup only: an unloaded/crashed native worker may skip subsequent hooks. */
|
|
230
|
+
export async function runClaudeTerminal({ config, args = [], env = process.env, startupMs = 10000,
|
|
231
|
+
tempRoot = os.tmpdir(), cwd = process.cwd(), signalSource = process, diagnostics = process.stderr } = {}) {
|
|
232
|
+
if (env.RNB_TERMINAL_LAUNCH_ACTIVE) throw new Error('Native terminal launcher recursion refused');
|
|
233
|
+
if (!Number.isFinite(startupMs) || startupMs <= 0 || startupMs > 60000) throw new Error('Bounded native hook startup deadline required');
|
|
234
|
+
const binary = executable(config.realClaude), clean = subscriptionEnvironment(env);
|
|
235
|
+
const information = new Set(['--help', '-h', '--version', '-v']);
|
|
236
|
+
const ownerPermissionFlags = new Set(['--dangerously-skip-permissions', '--allow-dangerously-skip-permissions']);
|
|
237
|
+
const administrative = args.some((arg) => information.has(arg)) &&
|
|
238
|
+
args.every((arg) => information.has(arg) || ownerPermissionFlags.has(arg)) ||
|
|
239
|
+
['auth', 'mcp', 'plugin', 'plugins', 'update', 'upgrade', 'doctor', 'install'].includes(args[0]);
|
|
240
|
+
if (administrative) {
|
|
241
|
+
const child = spawn(binary, args, { env: clean, cwd, stdio: 'inherit', shell: false });
|
|
242
|
+
const unforward = forwardSignals(child, signalSource);
|
|
243
|
+
try { return await exitOf(child); } finally { unforward(); }
|
|
244
|
+
}
|
|
245
|
+
const parsed = validateClaudeTerminalArguments(args);
|
|
246
|
+
validateClaudeTerminalSettings({ env: clean, cwd });
|
|
247
|
+
assertSubscriptionAuth('claude-code', { env: clean, probe: (_name, probeArgs, options) => execFileSync(binary, probeArgs, { ...options, cwd }) });
|
|
248
|
+
const { prepareClaudeTerminalMod } = await import(pathToFileURL(regular(config.claudeHelperPath)).href);
|
|
249
|
+
const directory = fs.mkdtempSync(path.join(fs.realpathSync(tempRoot), 'rnb-claude-')); fs.chmodSync(directory, 0o700);
|
|
250
|
+
let child, unforward = () => {}, timer, killer;
|
|
251
|
+
try {
|
|
252
|
+
const prepared = prepareClaudeTerminalMod({ destination: path.join(directory, 'plugin'), nodePath: executable(config.nodeBinary),
|
|
253
|
+
helperPath: regular(config.claudeHelperPath), enginePath: regular(config.enginePath) });
|
|
254
|
+
const receiptPath = path.join(directory, 'ready.json'), nonce = crypto.randomBytes(32).toString('hex'), startedAt = Date.now();
|
|
255
|
+
Object.assign(clean, { RNB_TERMINAL_LAUNCH_ACTIVE: '1', RNB_CLAUDE_MOD_NONCE: nonce, RNB_CLAUDE_MOD_RECEIPT: receiptPath });
|
|
256
|
+
child = spawn(binary, [...parsed.options, '--plugin-dir', prepared.pluginDir, '--', ...parsed.prompts], { env: clean, cwd, stdio: 'inherit', shell: false });
|
|
257
|
+
const exited = exitOf(child); unforward = forwardSignals(child, signalSource);
|
|
258
|
+
let failure, readiness;
|
|
259
|
+
const guard = new Promise((resolve) => {
|
|
260
|
+
const check = () => {
|
|
261
|
+
try {
|
|
262
|
+
if (fs.existsSync(receiptPath)) { readiness = validateClaudeReadiness(receiptPath, { nonce, ...prepared, pid: child.pid, startedAt }); resolve(); return; }
|
|
263
|
+
if (Date.now() - startedAt >= startupMs) throw new Error('Native Claude hook activation was not acknowledged');
|
|
264
|
+
timer = setTimeout(check, 25);
|
|
265
|
+
} catch (error) { failure = error; resolve(); }
|
|
266
|
+
}; check();
|
|
267
|
+
});
|
|
268
|
+
const early = await Promise.race([guard.then(() => null), exited]);
|
|
269
|
+
if (early && !readiness) throw new Error('Native Claude exited before hook activation');
|
|
270
|
+
if (failure) {
|
|
271
|
+
child.kill('SIGTERM'); killer = setTimeout(() => child.kill('SIGKILL'), 1000);
|
|
272
|
+
await exited; throw failure;
|
|
273
|
+
}
|
|
274
|
+
diagnostics.write('[native-terminal-routing] Claude startup hooks acknowledged; continuing worker enforcement is not established by this receipt.\n');
|
|
275
|
+
return { ...(early || await exited), readinessReceipt: readiness };
|
|
276
|
+
} finally {
|
|
277
|
+
clearTimeout(timer); clearTimeout(killer); unforward();
|
|
278
|
+
if (child && child.exitCode == null && child.signalCode == null) child.kill('SIGTERM');
|
|
279
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function runTerminalLauncher({ host, args, config, env = process.env, signalSource = process } = {}) {
|
|
284
|
+
if (host === 'claude') return runClaudeTerminal({ config, args, env, signalSource });
|
|
285
|
+
if (host !== 'codex') throw new Error('Unsupported terminal host');
|
|
286
|
+
const invocation = terminalInvocation({ host, args, config, env });
|
|
287
|
+
const child = spawn(invocation.command, invocation.args, { env: { ...subscriptionEnvironment(env), RNB_TERMINAL_LAUNCH_ACTIVE: '1' }, stdio: 'inherit', shell: false });
|
|
288
|
+
const unforward = forwardSignals(child, signalSource);
|
|
289
|
+
try { return await exitOf(child); } finally { unforward(); }
|
|
290
|
+
}
|
|
291
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === SELF) {
|
|
292
|
+
const argv = process.argv.slice(2);
|
|
293
|
+
Promise.resolve().then(() => {
|
|
294
|
+
if (argv[0] !== '--launch' || argv[2] !== '--config' || argv[4] !== '--') throw new Error('Explicit host/config terminal launcher required');
|
|
295
|
+
const config = JSON.parse(fs.readFileSync(regular(argv[3], { privateFile: true }), 'utf8'));
|
|
296
|
+
return runTerminalLauncher({ host: argv[1], args: argv.slice(5), config });
|
|
297
|
+
}).then(({ code, signal }) => {
|
|
298
|
+
if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1;
|
|
299
|
+
}).catch((error) => { process.stderr.write(`[native-terminal-routing] ${error.message}\n`); process.exitCode = 1; });
|
|
300
|
+
}
|