@ailuntz/minilink 0.9.10 → 0.9.12
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 +3 -1
- package/bin/minilink.mjs +8 -1
- package/package.json +1 -1
- package/src/history.mjs +102 -0
- package/src/install.mjs +22 -3
- package/src/provider-switch.mjs +10 -2
- package/src/proxy.mjs +4 -2
- package/src/remote.mjs +125 -0
package/README.md
CHANGED
|
@@ -26,7 +26,9 @@ minilink on
|
|
|
26
26
|
|
|
27
27
|
`npm install` 只安装命令;`minilink on` 才接入 Desktop。更新包后再次运行 `minilink on`。
|
|
28
28
|
|
|
29
|
-
|
|
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` 会撤销这段设置。重新连接远程主机后生效。
|
|
30
32
|
|
|
31
33
|
## 命令
|
|
32
34
|
|
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
package/src/history.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, lstatSync, realpathSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync, copyFileSync, chmodSync, constants } from 'node:fs';
|
|
2
|
+
import { basename, join, sep } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { codexHome } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
const defaultBackups = home => join(home, 'minilink-history-backups');
|
|
8
|
+
|
|
9
|
+
function atomicBytes(path, data, mode) {
|
|
10
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
11
|
+
try { writeFileSync(temporary, data, { mode, flag: 'wx' }); renameSync(temporary, path); }
|
|
12
|
+
finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function checkedPath(path, home, threadId) {
|
|
16
|
+
if (!existsSync(path)) return false;
|
|
17
|
+
if (!lstatSync(path).isFile()) throw new Error('任务历史不是普通文件,无法安全切换提供商。');
|
|
18
|
+
const actual = realpathSync(path), root = realpathSync(home);
|
|
19
|
+
if (![join(root, 'sessions') + sep, join(root, 'archived_sessions') + sep].some(prefix => actual.startsWith(prefix))) throw new Error('任务历史不在 CODEX_HOME 中,无法安全切换提供商。');
|
|
20
|
+
if (!basename(actual).endsWith(`${threadId}.jsonl`)) throw new Error('任务历史与任务 ID 不匹配,无法安全切换提供商。');
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Native Codex replays provider reasoning items from its JSONL journal. Some
|
|
25
|
+
// Responses-compatible providers persist reasoning_text and opaque encrypted
|
|
26
|
+
// content that another provider rejects. Normalize only those items, keeping
|
|
27
|
+
// every line's byte length so the native journal offsets remain valid.
|
|
28
|
+
export function repairThreadHistory(path, { threadId, home = codexHome(), backupDir = defaultBackups(home) } = {}) {
|
|
29
|
+
if (!existsSync(path)) return null;
|
|
30
|
+
if (!/^[0-9a-f-]{36}$/.test(threadId || '')) throw new Error('任务 ID 无效,无法备份历史。');
|
|
31
|
+
if (!checkedPath(path, home, threadId)) return null;
|
|
32
|
+
const before = readFileSync(path), after = Buffer.from(before), patches = [];
|
|
33
|
+
let start = 0;
|
|
34
|
+
while (start < before.length) {
|
|
35
|
+
const newline = before.indexOf(10, start);
|
|
36
|
+
const end = newline < 0 ? before.length : newline;
|
|
37
|
+
const original = before.subarray(start, end);
|
|
38
|
+
if (original.includes(Buffer.from('"type":"reasoning"'))) {
|
|
39
|
+
let record;
|
|
40
|
+
try { record = JSON.parse(original.toString('utf8')); }
|
|
41
|
+
catch { throw new Error('任务历史包含无法解析的记录,未修改原文件。'); }
|
|
42
|
+
if (record.type === 'response_item' && record.payload?.type === 'reasoning' && Array.isArray(record.payload.content) && record.payload.content.length) {
|
|
43
|
+
record.payload.content = [];
|
|
44
|
+
delete record.payload.encrypted_content;
|
|
45
|
+
const compact = Buffer.from(JSON.stringify(record));
|
|
46
|
+
if (compact.length > original.length) throw new Error('任务历史记录无法等长修复,未修改原文件。');
|
|
47
|
+
const replacement = Buffer.alloc(original.length, 32);
|
|
48
|
+
compact.copy(replacement);
|
|
49
|
+
replacement.copy(after, start);
|
|
50
|
+
patches.push({ start, original: Buffer.from(original), replacement });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
start = end + 1;
|
|
54
|
+
}
|
|
55
|
+
const databasePath = join(home, 'thread_history_1.sqlite');
|
|
56
|
+
const database = existsSync(databasePath) ? new DatabaseSync(databasePath) : null;
|
|
57
|
+
let rows = [];
|
|
58
|
+
try {
|
|
59
|
+
if (database) rows = database.prepare("SELECT rowid, item_json FROM thread_items WHERE thread_id = ? AND item_type = 'reasoning'").all(threadId).flatMap(row => {
|
|
60
|
+
const item = JSON.parse(row.item_json);
|
|
61
|
+
if (!Array.isArray(item.content) || !item.content.length) return [];
|
|
62
|
+
item.content = [];
|
|
63
|
+
delete item.encrypted_content;
|
|
64
|
+
return [{ rowid: row.rowid, original: row.item_json, replacement: JSON.stringify(item) }];
|
|
65
|
+
});
|
|
66
|
+
if (!patches.length && !rows.length) return null;
|
|
67
|
+
mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
68
|
+
const stem = `${threadId}-${randomUUID()}`;
|
|
69
|
+
const backup = join(backupDir, `${stem}.jsonl`);
|
|
70
|
+
const databaseBackup = join(backupDir, `${stem}.sqlite-reasoning.json`);
|
|
71
|
+
copyFileSync(path, backup, constants.COPYFILE_EXCL);
|
|
72
|
+
chmodSync(backup, 0o600);
|
|
73
|
+
writeFileSync(databaseBackup, JSON.stringify({ databasePath, rows: rows.map(({ rowid, original }) => ({ rowid, original })) }), { mode: 0o600, flag: 'wx' });
|
|
74
|
+
const mode = lstatSync(path).mode & 0o777;
|
|
75
|
+
const updateRows = values => {
|
|
76
|
+
if (!existsSync(databasePath) || !values.length) return;
|
|
77
|
+
const connection = new DatabaseSync(databasePath);
|
|
78
|
+
connection.exec('BEGIN IMMEDIATE');
|
|
79
|
+
try {
|
|
80
|
+
const update = connection.prepare('UPDATE thread_items SET item_json = ? WHERE rowid = ?');
|
|
81
|
+
for (const row of values) update.run(row.replacement ?? row.original, row.rowid);
|
|
82
|
+
connection.exec('COMMIT');
|
|
83
|
+
} catch (error) { connection.exec('ROLLBACK'); throw error; }
|
|
84
|
+
finally { connection.close(); }
|
|
85
|
+
};
|
|
86
|
+
try { updateRows(rows); atomicBytes(path, after, mode); }
|
|
87
|
+
catch (error) { updateRows(rows.map(row => ({ rowid: row.rowid, original: row.original }))); throw error; }
|
|
88
|
+
return {
|
|
89
|
+
backup, databaseBackup,
|
|
90
|
+
count: Math.max(patches.length, rows.length),
|
|
91
|
+
restore() {
|
|
92
|
+
const current = readFileSync(path), restored = Buffer.from(current);
|
|
93
|
+
for (const patch of patches) {
|
|
94
|
+
if (!current.subarray(patch.start, patch.start + patch.replacement.length).equals(patch.replacement)) throw new Error('任务历史已被其他进程修改,请使用备份恢复。');
|
|
95
|
+
patch.original.copy(restored, patch.start);
|
|
96
|
+
}
|
|
97
|
+
updateRows(rows.map(row => ({ rowid: row.rowid, original: row.original })));
|
|
98
|
+
atomicBytes(path, restored, mode);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
} finally { database?.close(); }
|
|
102
|
+
}
|
package/src/install.mjs
CHANGED
|
@@ -14,6 +14,8 @@ export const xml = value => value.replaceAll('&', '&').replaceAll('<', '<
|
|
|
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:
|
|
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/provider-switch.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { decodeAlias } from './model-menu.mjs';
|
|
4
4
|
import { isRoutedProvider, findRoute, isChatProvider } from './config.mjs';
|
|
5
|
+
import { repairThreadHistory } from './history.mjs';
|
|
5
6
|
|
|
6
7
|
// Private requests use the same initialized app-server connection. Their replies
|
|
7
8
|
// and temporary archive lifecycle events must not appear as Desktop requests.
|
|
@@ -64,7 +65,12 @@ export class ProviderSwitch {
|
|
|
64
65
|
change = { effort: effort.reasoningEffort }; restore = { effort: loaded.reasoningEffort };
|
|
65
66
|
} else if (model?.serviceTiers?.length) {
|
|
66
67
|
change = { serviceTier: model.serviceTiers[0].id }; restore = { serviceTier: null };
|
|
67
|
-
} else
|
|
68
|
+
} else {
|
|
69
|
+
// A one-effort catalog (for example Doubao Lite) has no alternate
|
|
70
|
+
// reasoning level. A temporary service tier setting still elicits the
|
|
71
|
+
// complete native settings notification without running a model turn.
|
|
72
|
+
change = { serviceTier: 'fast' }; restore = { serviceTier: null };
|
|
73
|
+
}
|
|
68
74
|
}
|
|
69
75
|
let waiter;
|
|
70
76
|
const notification = new Promise((resolve, reject) => {
|
|
@@ -154,7 +160,7 @@ export class ProviderSwitch {
|
|
|
154
160
|
}
|
|
155
161
|
async switchThread(thread, provider, model, requested = {}) {
|
|
156
162
|
const id = thread.id;
|
|
157
|
-
let snapshot, loaded, archived = false, closed = false, targetLoaded = false;
|
|
163
|
+
let snapshot, loaded, archived = false, closed = false, targetLoaded = false, historyRepair;
|
|
158
164
|
this.hidden.add(id);
|
|
159
165
|
try {
|
|
160
166
|
loaded = await this.rpc('thread/resume', { threadId: id, excludeTurns: true });
|
|
@@ -171,6 +177,7 @@ export class ProviderSwitch {
|
|
|
171
177
|
// immediately restores the same thread before resuming with the new provider.
|
|
172
178
|
await this.rpc('thread/archive', { threadId: id }); archived = true; closed = true;
|
|
173
179
|
await this.rpc('thread/unarchive', { threadId: id }); archived = false;
|
|
180
|
+
historyRepair = repairThreadHistory(thread.path, { threadId: id });
|
|
174
181
|
const resumed = await this.rpc('thread/resume', this.resumeParams(id, desired, loaded, provider, model));
|
|
175
182
|
targetLoaded = true;
|
|
176
183
|
if (resumed.thread.id !== id || resumed.modelProvider !== provider) throw new Error('恢复后的提供商不匹配。');
|
|
@@ -183,6 +190,7 @@ export class ProviderSwitch {
|
|
|
183
190
|
try {
|
|
184
191
|
if (targetLoaded) { await this.rpc('thread/archive', { threadId: id }); archived = true; }
|
|
185
192
|
if (archived) { await this.rpc('thread/unarchive', { threadId: id }); archived = false; }
|
|
193
|
+
historyRepair?.restore();
|
|
186
194
|
const original = await this.rpc('thread/resume', this.resumeParams(id, snapshot, loaded, snapshot.modelProvider, snapshot.model));
|
|
187
195
|
this.settings.delete(id);
|
|
188
196
|
await this.snapshot(id, original);
|
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';
|
|
@@ -36,7 +36,9 @@ export async function runProxy(args, selfPath) {
|
|
|
36
36
|
const path = configPath();
|
|
37
37
|
const enabled = readConfig(path).enabled;
|
|
38
38
|
const hadConfig = existsSync(path);
|
|
39
|
-
const executable =
|
|
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
|
+
}
|