@ailuntz/minilink 0.9.9 → 0.9.11

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 CHANGED
@@ -26,6 +26,10 @@ minilink on
26
26
 
27
27
  `npm install` 只安装命令;`minilink on` 才接入 Desktop。更新包后再次运行 `minilink on`。
28
28
 
29
+ 本机 Desktop 集成调用 Desktop 自带的 Codex;SSH 远程入口调用远程主机的独立 Codex。配置和会话仍使用官方的 `CODEX_HOME`(默认 `~/.codex`);MiniLink 自己的启动文件放在 `~/.local/share/minilink`。
30
+
31
+ 从 MacBook 的 Desktop 通过 SSH 使用 Mac mini 会话时,也要在 **Mac mini** 上安装并运行 `minilink on`。远程入口使用 Mac mini 的独立 Codex,并把模型列表和选择接入 MiniLink。`on` 会在远程登录 shell 中设置仅供 Codex SSH 启动器使用的 `CODEX_INSTALL_DIR`;`off` 会撤销这段设置。重新连接远程主机后生效。
32
+
29
33
  ## 命令
30
34
 
31
35
  | 命令 | 用途 |
package/bin/minilink.mjs CHANGED
@@ -4,9 +4,10 @@ import { fileURLToPath } from 'node:url';
4
4
  import { VERSION, readConfig, configPath, codexHome, atomicJSON, discoverProfiles, resolveRealCodex, childEnvironment } from '../src/config.mjs';
5
5
  import { startUpdate, updateStatus } from '../src/update.mjs';
6
6
  import { runProxy } from '../src/proxy.mjs';
7
+ import { runRemoteProxy } from '../src/remote.mjs';
7
8
  import { pickerEndpoints } from '../src/picker.mjs';
8
9
  import { selectModel, text, language } from '../src/terminal.mjs';
9
- import { installation, environmentStatus, syncLaunchEnvironment, ensureInstalled } from '../src/install.mjs';
10
+ import { installation, environmentStatus, syncLaunchEnvironment, syncRemoteEnvironment, ensureInstalled } from '../src/install.mjs';
10
11
 
11
12
  const self = fileURLToPath(import.meta.url), args = process.argv.slice(2);
12
13
  const commands = ['on', 'link', 'off', 'doctor'];
@@ -22,8 +23,14 @@ async function main() {
22
23
  if (command === 'on') { discoverProfiles(); ensureInstalled(); }
23
24
  atomicJSON(configPath(), { version: 1, enabled: command === 'on' });
24
25
  syncLaunchEnvironment({ enabled: command === 'on' });
26
+ syncRemoteEnvironment({ enabled: command === 'on' });
25
27
  return output(text(command));
26
28
  }
29
+ if (command === 'remote-proxy') {
30
+ if (args.length !== 1) throw new Error('remote-proxy 不接受额外参数。');
31
+ process.exitCode = await runRemoteProxy();
32
+ return;
33
+ }
27
34
  if (command === 'link') {
28
35
  if (!readConfig().enabled) throw new Error(text('disabled'));
29
36
  return output(await selectModel(pickerEndpoints(codexHome())));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ailuntz/minilink",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "description": "Use your Codex profiles in the Desktop model menu.",
5
5
  "type": "module",
6
6
  "license": "MIT AND Apache-2.0",
package/src/config.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync, rmSync, existsSync, realpathSync, accessSync, constants, statSync, copyFileSync, mkdtempSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { resolve, join, dirname, isAbsolute } from 'node:path';
3
+ import { resolve, join, dirname } from 'node:path';
4
4
  import { randomUUID, createHash } from 'node:crypto';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { parseEnv } from 'node:util';
@@ -104,8 +104,8 @@ export function findApp() {
104
104
  }
105
105
 
106
106
  export function resolveRealCodex(selfPath) {
107
- const candidate = setting('REAL_CODEX') || officialCodex();
108
- if (!candidate || !isAbsolute(candidate)) throw new Error('找不到内置 Codex;可用 MINILINK_REAL_CODEX 指定绝对路径。');
107
+ const candidate = officialCodex();
108
+ if (!candidate) throw new Error('找不到 ChatGPT 内置 Codex。');
109
109
  let actual;
110
110
  try { actual = realpathSync(candidate); accessSync(actual, constants.X_OK); } catch { throw new Error('真正的 Codex 不存在或不可执行。'); }
111
111
  if (actual === realpathSync(selfPath) || actual === realpathSync(process.execPath)) throw new Error('检测到路由递归调用。');
package/src/install.mjs CHANGED
@@ -14,6 +14,8 @@ export const xml = value => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;
14
14
  const launchctl = (args, required = true) => { try { return execFileSync('/bin/launchctl', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } catch { if (required) throw new Error(`launchctl ${args[0]} 失败。`); return null; } };
15
15
  const defaultPlist = () => join(homedir(), 'Library/LaunchAgents', `${label}.plist`);
16
16
  const sourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
+ const remoteStart = '# >>> minilink remote';
18
+ const remoteEnd = '# <<< minilink remote';
17
19
  export function installation(root = installRoot()) {
18
20
  try { return JSON.parse(readFileSync(join(root, 'installation.json'), 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw new Error('安装状态损坏,请检查 installation.json。'); }
19
21
  }
@@ -51,6 +53,19 @@ export function syncLaunchEnvironment({ state = installation(), run = launchctl,
51
53
  }
52
54
  }
53
55
 
56
+ // The Desktop SSH launcher places CODEX_INSTALL_DIR first in PATH. It starts
57
+ // an interactive login shell with CODEX_REMOTE_PAYLOAD set before doing so.
58
+ // Keep the change scoped to that launcher and preserve the user's shell file.
59
+ export function syncRemoteEnvironment({ state = installation(), enabled, rc = join(homedir(), '.zshrc') } = {}) {
60
+ if (process.platform !== 'darwin' || !state?.remoteDir) return;
61
+ const existing = existsSync(rc) ? readFileSync(rc, 'utf8') : '';
62
+ const pattern = /(?:\n|^)# >>> minilink remote\n[\s\S]*?# <<< minilink remote\n?/g;
63
+ const clean = existing.replace(pattern, '\n').replace(/\n+$/, '');
64
+ const block = `${remoteStart}\nif [ -n "\${CODEX_REMOTE_PAYLOAD:-}" ] && [ -z "\${CODEX_INSTALL_DIR:-}" ]; then\n export CODEX_INSTALL_DIR=${shellQuote(state.remoteDir)}\nfi\n${remoteEnd}`;
65
+ const updated = (clean ? clean + '\n' : '') + (enabled ? block + '\n' : '');
66
+ if (updated !== existing) atomicFile(rc, updated, existsSync(rc) ? statSync(rc).mode & 0o777 : 0o644);
67
+ }
68
+
54
69
  function atomicFile(path, text, mode) {
55
70
  const temporary = `${path}.${process.pid}.tmp`;
56
71
  try { writeFileSync(temporary, text, { mode }); renameSync(temporary, path); }
@@ -130,6 +145,8 @@ function installWindows({ root = installRoot(), source = sourceRoot, home = code
130
145
  export function install({ root = installRoot(), plist = defaultPlist(), source = sourceRoot, home = codexHome(), config = configPath(), node = process.execPath, npm = process.env.npm_execpath, prefix = process.env.npm_config_prefix, run = launchctl, legacy = root === installRoot() ? legacyInstallation() : null } = {}) {
131
146
  if (process.platform !== 'darwin') throw new Error('Desktop 接入目前仅支持 macOS。');
132
147
  const previous = installation(root), wrapper = join(root, 'minilink');
148
+ const remoteDir = join(root, 'remote/bin'), remoteWrapper = join(remoteDir, 'codex');
149
+ const remoteCodex = join(home, 'packages/standalone/current/bin/codex');
133
150
  const packageManager = npm && prefix ? { npm, prefix } : previous?.npm && previous?.prefix ? { npm: previous.npm, prefix: previous.prefix } : npmDetails();
134
151
  if (existsSync(plist) && !previous) throw new Error('同名 LaunchAgent 已存在且不属于本次安装。');
135
152
  const env = run(['getenv', 'CODEX_CLI_PATH'], false) || null;
@@ -138,7 +155,7 @@ export function install({ root = installRoot(), plist = defaultPlist(), source =
138
155
  const runtime = mkdtempSync(join(root, 'releases', 'runtime-'));
139
156
  const activate = join(root, 'activate.sh'), stateFile = join(root, 'installation.json');
140
157
  const migrationFiles = legacy ? [legacy.state.wrapper, legacy.stateFile, legacy.plist, legacy.activate, config, `${config}.selection.json`] : [];
141
- const snapshots = [...new Set([wrapper, activate, plist, stateFile, ...migrationFiles])].map(path => ({ path, data: existsSync(path) ? readFileSync(path) : null }));
158
+ const snapshots = [...new Set([wrapper, remoteWrapper, activate, plist, stateFile, ...migrationFiles])].map(path => ({ path, data: existsSync(path) ? readFileSync(path) : null }));
142
159
  let migrationLocked = false, retired = false, touched = false;
143
160
  try {
144
161
  if (legacy) {
@@ -161,10 +178,12 @@ export function install({ root = installRoot(), plist = defaultPlist(), source =
161
178
  }
162
179
  if (JSON.parse(readFileSync(join(dependency, 'package.json'), 'utf8')).name !== 'smol-toml') throw new Error('smol-toml 依赖路径无效。');
163
180
  cpSync(dependency, join(runtime, 'node_modules/smol-toml'), { recursive: true });
164
- const state = { version: 2, packageVersion: VERSION, source, runtime, wrapper, node, npm: packageManager.npm || previous?.npm, prefix: packageManager.prefix || previous?.prefix, codexHome: home, configPath: config, previousEnv: previous?.previousEnv || legacy?.state.previousEnv || null, installedAt: new Date().toISOString() };
181
+ const state = { version: 3, packageVersion: VERSION, source, runtime, wrapper, remoteDir, node, npm: packageManager.npm || previous?.npm, prefix: packageManager.prefix || previous?.prefix, codexHome: home, configPath: config, previousEnv: previous?.previousEnv || legacy?.state.previousEnv || null, installedAt: new Date().toISOString() };
165
182
  // A Desktop process can retain this launcher after npm removes the state.
166
183
  const fallback = `if [ ! -f ${shellQuote(stateFile)} ]; then\n exec "\${CODEX_DESKTOP_APP:-/Applications/ChatGPT.app}/${bundledCodexRelativePath}" "$@"\nfi`;
167
184
  atomicFile(wrapper, `#!/bin/sh\n${fallback}\nexport CODEX_HOME=${shellQuote(home)}\nexport MINILINK_CONFIG=${shellQuote(config)}\nexec ${shellQuote(node)} ${shellQuote(join(runtime, 'bin/minilink.mjs'))} "$@"\n`, 0o755);
185
+ mkdirSync(remoteDir, { recursive: true, mode: 0o700 });
186
+ atomicFile(remoteWrapper, `#!/bin/sh\nif [ "$1" = app-server ] && [ "$2" = proxy ] && [ "$#" -eq 2 ] && [ -f ${shellQuote(join(runtime, 'bin/minilink.mjs'))} ]; then\n export CODEX_HOME=${shellQuote(home)}\n export MINILINK_CONFIG=${shellQuote(config)}\n export MINILINK_REMOTE_CODEX=${shellQuote(remoteCodex)}\n exec ${shellQuote(node)} ${shellQuote(join(runtime, 'bin/minilink.mjs'))} remote-proxy\nfi\nexec ${shellQuote(remoteCodex)} "$@"\n`, 0o755);
168
187
  atomicFile(activate, `#!/bin/sh\nexec ${shellQuote(node)} ${shellQuote(join(runtime, 'src/lifecycle.mjs'))}\n`, 0o700);
169
188
  mkdirSync(dirname(plist), { recursive: true });
170
189
  atomicFile(plist, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array><string>/bin/sh</string><string>${xml(activate)}</string></array><key>RunAtLoad</key><true/><key>StartInterval</key><integer>30</integer><key>LimitLoadToSessionType</key><string>Aqua</string></dict></plist>\n`, 0o600);
@@ -183,7 +202,7 @@ export function install({ root = installRoot(), plist = defaultPlist(), source =
183
202
  return state;
184
203
  } catch (error) {
185
204
  if (!touched) { rmSync(runtime, { recursive: true, force: true }); throw error; }
186
- for (const { path, data } of snapshots) { if (data === null) rmSync(path, { force: true }); else atomicFile(path, data, [wrapper, legacy?.state.wrapper].includes(path) ? 0o755 : [activate, legacy?.activate].includes(path) ? 0o700 : 0o600); }
205
+ for (const { path, data } of snapshots) { if (data === null) rmSync(path, { force: true }); else atomicFile(path, data, [wrapper, remoteWrapper, legacy?.state.wrapper].includes(path) ? 0o755 : [activate, legacy?.activate].includes(path) ? 0o700 : 0o600); }
187
206
  run(['bootout', `gui/${process.getuid()}/${label}`], false);
188
207
  if (previous) run(['bootstrap', `gui/${process.getuid()}`, plist], false);
189
208
  if (retired) run(['bootstrap', `gui/${process.getuid()}`, legacy.plist], false);
package/src/proxy.mjs CHANGED
@@ -3,7 +3,7 @@ import { runtimeDirectory } from './runtime.mjs';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { join } from 'node:path';
6
- import { rmSync, existsSync, readFileSync } from 'node:fs';
6
+ import { rmSync, existsSync, readFileSync, realpathSync, accessSync, constants } from 'node:fs';
7
7
  import { JsonLineRouter } from './protocol.mjs';
8
8
  import { ModelMenu } from './model-menu.mjs';
9
9
  import { ProviderSwitch } from './provider-switch.mjs';
@@ -32,11 +32,13 @@ export function isStdioServer(args) {
32
32
  }
33
33
 
34
34
  export async function runProxy(args, selfPath) {
35
- if ((Number.parseInt(setting('ACTIVE'), 10) || 0) >= 8) throw new Error('检测到 minilink 递归调用;MINILINK_REAL_CODEX 必须指向真正的 codex。');
35
+ if ((Number.parseInt(setting('ACTIVE'), 10) || 0) >= 8) throw new Error('检测到 minilink 递归调用。');
36
36
  const path = configPath();
37
37
  const enabled = readConfig(path).enabled;
38
38
  const hadConfig = existsSync(path);
39
- const executable = resolveRealCodex(selfPath);
39
+ const executable = process.env.MINILINK_REMOTE_BRIDGE === '1' && process.env.MINILINK_REMOTE_CODEX
40
+ ? realpathSync(process.env.MINILINK_REMOTE_CODEX) : resolveRealCodex(selfPath);
41
+ accessSync(executable, constants.X_OK);
40
42
  const routed = enabled && isStdioServer(args);
41
43
  const routes = routed ? discoverProfiles() : [];
42
44
  const env = enabled ? childEnvironment(routes) : { ...process.env, MINILINK_ACTIVE: String((Number.parseInt(setting('ACTIVE'), 10) || 0) + 1) };
package/src/remote.mjs ADDED
@@ -0,0 +1,125 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { TextDecoder } from 'node:util';
5
+ import { readConfig } from './config.mjs';
6
+
7
+ const MAX_MESSAGE = 64 * 1024 * 1024;
8
+ const MAX_HEADER = 16 * 1024;
9
+ const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
10
+
11
+ function frame(opcode, payload = Buffer.alloc(0)) {
12
+ const size = payload.length;
13
+ const header = size < 126 ? Buffer.from([0x80 | opcode, size])
14
+ : size < 65536 ? Buffer.from([0x80 | opcode, 126, size >> 8, size & 255])
15
+ : Buffer.from([0x80 | opcode, 127, 0, 0, 0, 0, (size / 0x1000000) & 255, (size >>> 16) & 255, (size >>> 8) & 255, size & 255]);
16
+ return Buffer.concat([header, payload]);
17
+ }
18
+
19
+ // The Codex SSH launcher prints its own eight-byte marker, then runs
20
+ // `codex app-server proxy`. That proxy carries a WebSocket handshake and
21
+ // frames over stdio. Bridge those frames to MiniLink's existing JSONL router.
22
+ export class RemoteBridge {
23
+ constructor({ input, output, backend }) {
24
+ Object.assign(this, { input, output, backend });
25
+ this.buffer = Buffer.alloc(0);
26
+ this.lines = Buffer.alloc(0);
27
+ this.handshaken = false;
28
+ this.fragment = null;
29
+ this.fragmentSize = 0;
30
+ this.closed = false;
31
+ input.on('data', chunk => this.receive(chunk));
32
+ input.on('end', () => backend.stdin.end());
33
+ input.on('error', () => backend.kill());
34
+ backend.stdout.on('data', chunk => this.backendOutput(chunk));
35
+ backend.stdout.on('error', () => backend.kill());
36
+ backend.stderr.pipe(process.stderr, { end: false });
37
+ backend.on('close', () => { this.closed = true; output.end(); });
38
+ backend.on('error', () => { this.close(1011); });
39
+ }
40
+ close(code = 1002) {
41
+ if (this.closed) return;
42
+ this.closed = true;
43
+ this.output.write(frame(8, Buffer.from([code >> 8, code & 255])));
44
+ this.backend.kill();
45
+ this.output.end();
46
+ }
47
+ receive(chunk) {
48
+ if (this.closed) return;
49
+ this.buffer = Buffer.concat([this.buffer, chunk]);
50
+ if (!this.handshaken) {
51
+ if (this.buffer.length > MAX_HEADER) return this.close();
52
+ const end = this.buffer.indexOf('\r\n\r\n');
53
+ if (end < 0) return;
54
+ const header = this.buffer.subarray(0, end + 4).toString('latin1');
55
+ const key = /^sec-websocket-key:\s*([^\r\n]+)\s*$/im.exec(header)?.[1]?.trim();
56
+ if (!/^GET [^\r\n]+ HTTP\/1\.1\r\n/i.test(header) || !/^upgrade:\s*websocket\s*$/im.test(header) || !/^connection:.*\bupgrade\b/im.test(header) || !key || Buffer.from(key, 'base64').length !== 16) return this.close();
57
+ const accept = createHash('sha1').update(key + GUID).digest('base64');
58
+ this.output.write(`HTTP/1.1 101 Switching Protocols\r\nconnection: Upgrade\r\nupgrade: websocket\r\nsec-websocket-accept: ${accept}\r\nx-codex-websocket-max-unfragmented-message-bytes: 16777216\r\n\r\n`);
59
+ this.buffer = this.buffer.subarray(end + 4);
60
+ this.handshaken = true;
61
+ }
62
+ while (this.buffer.length >= 2 && !this.closed) {
63
+ const first = this.buffer[0], second = this.buffer[1];
64
+ const opcode = first & 15, fin = Boolean(first & 0x80);
65
+ if (first & 0x70 || !(second & 0x80)) return this.close();
66
+ let length = second & 127, offset = 2;
67
+ if (length === 126) {
68
+ if (this.buffer.length < 4) return;
69
+ length = this.buffer.readUInt16BE(2); offset = 4;
70
+ } else if (length === 127) {
71
+ if (this.buffer.length < 10) return;
72
+ length = Number(this.buffer.readBigUInt64BE(2)); offset = 10;
73
+ }
74
+ if (!Number.isSafeInteger(length) || length > MAX_MESSAGE || this.buffer.length > MAX_MESSAGE + MAX_HEADER) return this.close(1009);
75
+ if (this.buffer.length < offset + 4 + length) return;
76
+ if (opcode >= 8 && (!fin || length > 125)) return this.close();
77
+ const mask = this.buffer.subarray(offset, offset + 4), payload = Buffer.from(this.buffer.subarray(offset + 4, offset + 4 + length));
78
+ for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4];
79
+ this.buffer = this.buffer.subarray(offset + 4 + length);
80
+ if (opcode === 8) { this.close(1000); return; }
81
+ if (opcode === 9) { this.output.write(frame(10, payload)); continue; }
82
+ if (opcode === 10) continue;
83
+ if (opcode === 1) {
84
+ if (this.fragment) return this.close();
85
+ this.fragment = [payload]; this.fragmentSize = payload.length;
86
+ } else if (opcode === 0 && this.fragment) this.fragment.push(payload);
87
+ else return this.close();
88
+ if (opcode === 0) this.fragmentSize += payload.length;
89
+ if (this.fragmentSize > MAX_MESSAGE) return this.close(1009);
90
+ if (fin) {
91
+ const message = Buffer.concat(this.fragment);
92
+ this.fragment = null; this.fragmentSize = 0;
93
+ let normalized;
94
+ try { normalized = JSON.stringify(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(message))); }
95
+ catch { return this.close(1007); }
96
+ this.backend.stdin.write(normalized);
97
+ this.backend.stdin.write('\n');
98
+ }
99
+ }
100
+ }
101
+ backendOutput(chunk) {
102
+ this.lines = Buffer.concat([this.lines, chunk]);
103
+ if (this.lines.length > MAX_MESSAGE) return this.close(1009);
104
+ let end;
105
+ while ((end = this.lines.indexOf(10)) >= 0) {
106
+ const line = this.lines.subarray(0, end);
107
+ this.lines = this.lines.subarray(end + 1);
108
+ if (line.length) this.output.write(frame(1, line));
109
+ }
110
+ }
111
+ }
112
+
113
+ export function runRemoteProxy() {
114
+ if (!readConfig().enabled) {
115
+ const standalone = process.env.MINILINK_REMOTE_CODEX;
116
+ if (!standalone) throw new Error('找不到远程 Codex。');
117
+ const child = spawn(standalone, ['app-server', 'proxy'], { stdio: 'inherit', env: process.env });
118
+ return new Promise(resolve => { child.on('close', code => resolve(code ?? 1)); child.on('error', () => resolve(1)); });
119
+ }
120
+ const self = fileURLToPath(new URL('../bin/minilink.mjs', import.meta.url));
121
+ const backend = spawn(process.execPath, [self, 'app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, MINILINK_REMOTE_BRIDGE: '1' } });
122
+ new RemoteBridge({ input: process.stdin, output: process.stdout, backend });
123
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => backend.kill(signal));
124
+ return new Promise(resolve => backend.on('close', code => resolve(code ?? 1)));
125
+ }