ruvnet-brain 4.5.5 → 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 +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/package.json +4 -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/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/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 +10 -2
- package/scripts/release-qualification.mjs +9 -6
|
@@ -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
|
+
}
|