aiterm-mcp 0.37.0 → 0.37.2
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/CHANGELOG.md +18 -1
- package/README.ja.md +6 -4
- package/README.md +6 -4
- package/dist/codex-relay-arguments.js +36 -0
- package/dist/codex-relay-launcher.js +24 -61
- package/dist/codex-relay-setup.js +44 -0
- package/dist/codex-relay-stdio.js +81 -0
- package/dist/codex-stdio-relay.js +25 -92
- package/dist/core.js +15 -2
- package/dist/harnesses/codex.js +2 -2
- package/dist/setup-codex-relay.js +20 -53
- package/dist/windows-codex-connection.js +9 -17
- package/dist/windows-codex-launcher.js +61 -0
- package/dist/windows-codex-native.js +118 -0
- package/dist/windows-codex-relay.js +67 -127
- package/dist/windows-codex-setup.js +26 -107
- package/docs/DESIGN.md +13 -8
- package/docs/RELEASE.md +1 -1
- package/package.json +1 -1
|
@@ -7,12 +7,13 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import { SetupError } from "./setup-platform.js";
|
|
9
9
|
import { CodexDeliveryError } from "./codex-delivery-error.js";
|
|
10
|
-
import {
|
|
10
|
+
import { relayConfigDirectory, prepareRelayDirectory } from "./codex-relay-config.js";
|
|
11
11
|
import { codexRelayLauncher } from "./codex-relay-launcher.js";
|
|
12
12
|
import { withCodexRelay } from "./codex-relay-client.js";
|
|
13
13
|
import { readRuntimeProcesses } from "./process-runtime.js";
|
|
14
14
|
import { installRelayLogin, removeRelayLogin } from "./codex-relay-login.js";
|
|
15
|
-
import {
|
|
15
|
+
import { windowsCodexRuntime } from "./windows-codex-setup.js";
|
|
16
|
+
import { configureRelay } from "./codex-relay-setup.js";
|
|
16
17
|
function command(executable, args) {
|
|
17
18
|
const result = spawnSync(executable, args, { encoding: "utf8", timeout: 15_000 });
|
|
18
19
|
if (result.error || result.status !== 0)
|
|
@@ -123,65 +124,31 @@ function writeConfig(directory, config) {
|
|
|
123
124
|
}
|
|
124
125
|
export async function configureCodexSteer(action = "status", overrides = {}) {
|
|
125
126
|
if ((overrides.platform ?? process.platform) === "win32")
|
|
126
|
-
return
|
|
127
|
+
return configureRelay(action, windowsCodexRuntime(overrides));
|
|
127
128
|
const runtime = {
|
|
128
129
|
platform: process.platform, directory: relayConfigDirectory(), socket_root: `/tmp/aiterm-codex-${process.getuid?.() ?? 0}`,
|
|
129
130
|
node: process.execPath, relay: fileURLToPath(new URL("./codex-stdio-relay.js", import.meta.url)),
|
|
130
131
|
findBinary: findDesktopBinary, getGui,
|
|
131
132
|
setGui: (key, value) => { command("/bin/launchctl", value === null ? ["unsetenv", key] : ["setenv", key, value]); },
|
|
132
133
|
persist: installRelayLogin, unpersist: removeRelayLogin,
|
|
133
|
-
verify: verifyRelayLauncher, live: liveRelay,
|
|
134
|
+
verify: verifyRelayLauncher, live: liveRelay, prepare: prepareRelayDirectory, save: writeConfig,
|
|
135
|
+
build: async (binary) => {
|
|
136
|
+
const launcher = path.join(runtime.directory, "codex");
|
|
137
|
+
const candidate = path.join(runtime.directory, `codex-${randomUUID()}`);
|
|
138
|
+
fs.writeFileSync(candidate, codexRelayLauncher({ binary, node: runtime.node, relay: runtime.relay, socket_root: runtime.socket_root }), { mode: 0o700, flag: "wx" });
|
|
139
|
+
try {
|
|
140
|
+
await runtime.verify(candidate);
|
|
141
|
+
fs.renameSync(candidate, launcher);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
if (fs.existsSync(candidate))
|
|
145
|
+
fs.unlinkSync(candidate);
|
|
146
|
+
}
|
|
147
|
+
return launcher;
|
|
148
|
+
}, ...overrides,
|
|
134
149
|
};
|
|
135
150
|
if (runtime.platform !== "darwin") {
|
|
136
151
|
return action === "enable" ? { status: "unsupported", reason_code: "codex_steer_platform_unsupported" } : { status: "disabled" };
|
|
137
152
|
}
|
|
138
|
-
|
|
139
|
-
if (action === "status") {
|
|
140
|
-
if (!previous?.enabled)
|
|
141
|
-
return { status: "disabled" };
|
|
142
|
-
if (runtime.getGui("CODEX_CLI_PATH") !== previous.launcher)
|
|
143
|
-
return { status: "failed", reason_code: "codex_steer_configuration_changed" };
|
|
144
|
-
return await runtime.live(previous) ? { status: "ready" } : { status: "restart_required", reason_code: "codex_restart_required" };
|
|
145
|
-
}
|
|
146
|
-
if (action === "disable") {
|
|
147
|
-
if (!previous?.enabled)
|
|
148
|
-
return { status: "disabled" };
|
|
149
|
-
if (![previous.launcher, previous.previous_cli_path].includes(runtime.getGui("CODEX_CLI_PATH")))
|
|
150
|
-
throw new SetupError("codex_steer_configuration_changed", "起動設定が他から変更されています。所有外の値は上書きしません");
|
|
151
|
-
runtime.unpersist(previous.launcher);
|
|
152
|
-
runtime.setGui("CODEX_CLI_PATH", previous.previous_cli_path);
|
|
153
|
-
if (runtime.getGui("CODEX_CLI_PATH") !== previous.previous_cli_path)
|
|
154
|
-
throw new SetupError("codex_steer_readback_failed", "元の起動設定を確認できません");
|
|
155
|
-
writeConfig(runtime.directory, { ...previous, enabled: false });
|
|
156
|
-
return { status: "restart_required", reason_code: "codex_restart_required" };
|
|
157
|
-
}
|
|
158
|
-
for (const key of ["CODEX_APP_SERVER_WS_URL", "CODEX_APP_SERVER_USE_LOCAL_DAEMON", "CODEX_APP_SERVER_FORCE_CLI"]) {
|
|
159
|
-
if (runtime.getGui(key))
|
|
160
|
-
throw new SetupError("codex_steer_configuration_conflict", `${key}が設定されています。既存の接続設定は上書きしません`);
|
|
161
|
-
}
|
|
162
|
-
const binary = runtime.findBinary();
|
|
163
|
-
const current = runtime.getGui("CODEX_CLI_PATH");
|
|
164
|
-
if (previous?.enabled && ![previous.launcher, previous.previous_cli_path].includes(current))
|
|
165
|
-
throw new SetupError("codex_steer_configuration_changed", "起動設定が他から変更されています");
|
|
166
|
-
prepareRelayDirectory(runtime.directory);
|
|
167
|
-
const launcher = path.join(runtime.directory, "codex");
|
|
168
|
-
const config = { schema: "aiterm.codex-relay.v1", enabled: true, binary, node: runtime.node,
|
|
169
|
-
launcher, socket_root: runtime.socket_root, previous_cli_path: previous?.enabled ? previous.previous_cli_path : current };
|
|
170
|
-
const candidate = path.join(runtime.directory, `codex-${randomUUID()}`);
|
|
171
|
-
fs.writeFileSync(candidate, codexRelayLauncher({ ...config, relay: runtime.relay }), { mode: 0o700, flag: "wx" });
|
|
172
|
-
try {
|
|
173
|
-
await runtime.verify(candidate);
|
|
174
|
-
fs.renameSync(candidate, launcher);
|
|
175
|
-
}
|
|
176
|
-
finally {
|
|
177
|
-
if (fs.existsSync(candidate))
|
|
178
|
-
fs.unlinkSync(candidate);
|
|
179
|
-
}
|
|
180
|
-
// 起動設定の変更前に復元値を保存する。読戻し不一致を成功扱いしない。
|
|
181
|
-
writeConfig(runtime.directory, config);
|
|
182
|
-
runtime.persist(launcher);
|
|
183
|
-
runtime.setGui("CODEX_CLI_PATH", launcher);
|
|
184
|
-
if (runtime.getGui("CODEX_CLI_PATH") !== launcher)
|
|
185
|
-
throw new SetupError("codex_steer_readback_failed", "Steerの起動設定を確認できません");
|
|
186
|
-
return await runtime.live(config) ? { status: "ready" } : { status: "restart_required", reason_code: "codex_restart_required" };
|
|
153
|
+
return configureRelay(action, runtime);
|
|
187
154
|
}
|
|
@@ -76,33 +76,25 @@ export function windowsSocketConnection(file) {
|
|
|
76
76
|
invalid("Steer認証情報が不正です。");
|
|
77
77
|
return { url: record.endpoint, options: { headers: { Authorization: `Bearer ${token}` } } };
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
80
|
-
export function windowsDesktopRelay(launcher, rows = readWindowsProcesses()) {
|
|
79
|
+
/** 公式CLIの直接の親がDesktopであり、選択したlauncherの接続先を使っていることを確認する。 */
|
|
80
|
+
export function windowsDesktopRelay(launcher, rows = readWindowsProcesses(), root = join(dirname(launcher), "sessions")) {
|
|
81
81
|
const parents = new Map(rows.map(row => [row.pid, row]));
|
|
82
82
|
for (const server of rows) {
|
|
83
83
|
if (basename(server.executable).toLowerCase() !== "codex.exe" || !server.command.includes("--ws-token-file"))
|
|
84
84
|
continue;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
while (row && !seen.has(row.pid)) {
|
|
90
|
-
seen.add(row.pid);
|
|
91
|
-
if (row.executable.toLowerCase() === launcher.toLowerCase())
|
|
92
|
-
matchedLauncher = true;
|
|
93
|
-
if (/\\WindowsApps\\OpenAI\.Codex_[^\\]+\\app\\(?:ChatGPT|Codex)\.exe$/i.test(row.executable)) {
|
|
94
|
-
matchedDesktop = true;
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
row = parents.get(row.parent_pid);
|
|
98
|
-
}
|
|
99
|
-
if (!matchedLauncher || !matchedDesktop)
|
|
85
|
+
const desktop = parents.get(server.parent_pid);
|
|
86
|
+
if (!desktop || !/\\WindowsApps\\OpenAI\.Codex_[^\\]+\\app\\(?:ChatGPT|Codex)\.exe$/i.test(desktop.executable))
|
|
87
|
+
continue;
|
|
88
|
+
if (!rows.some(row => row.parent_pid === desktop.pid && row.executable.toLowerCase() === launcher.toLowerCase()))
|
|
100
89
|
continue;
|
|
101
90
|
const token = /(?:^|\s)--ws-token-file\s+(?:"([^"]+)"|(\S+))/.exec(server.command);
|
|
102
91
|
const tokenFile = token?.[1] ?? token?.[2];
|
|
103
92
|
if (!tokenFile || !isAbsolute(tokenFile) || basename(tokenFile) !== "token")
|
|
104
93
|
continue;
|
|
105
94
|
const file = join(dirname(tokenFile), "connection.json");
|
|
95
|
+
const location = relative(root, file);
|
|
96
|
+
if (isAbsolute(location) || location === ".." || location.startsWith("..\\") || location.startsWith("../"))
|
|
97
|
+
continue;
|
|
106
98
|
if (readWindowsRelay(file, rows).serverPid !== server.pid)
|
|
107
99
|
invalid("Steer接続記録と親Codexが一致しません。");
|
|
108
100
|
return file;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { windowsNativeProcessSource } from "./windows-codex-native.js";
|
|
2
|
+
// POSIXのexecに対応するWindows adapter。公式CLIを起動元の直接の子、中継を公式CLIの子として起動する。
|
|
3
|
+
export function windowsLauncherSource(node, relay, binary, root) {
|
|
4
|
+
const literal = (value) => JSON.stringify(value);
|
|
5
|
+
return `using System;
|
|
6
|
+
using System.Diagnostics;
|
|
7
|
+
using System.Text;
|
|
8
|
+
using System.IO;
|
|
9
|
+
using System.ComponentModel;
|
|
10
|
+
using System.Runtime.InteropServices;
|
|
11
|
+
using System.Runtime.Serialization;
|
|
12
|
+
using System.Runtime.Serialization.Json;
|
|
13
|
+
${windowsNativeProcessSource}
|
|
14
|
+
[DataContract] public sealed class LaunchPlan {
|
|
15
|
+
[DataMember] public string directory;
|
|
16
|
+
[DataMember] public string endpoint;
|
|
17
|
+
[DataMember] public string[] arguments;
|
|
18
|
+
}
|
|
19
|
+
public static class AitermLauncher {
|
|
20
|
+
static LaunchPlan Prepare(string[] args) {
|
|
21
|
+
var arguments=new StringBuilder();
|
|
22
|
+
foreach(var value in new string[] { ${[relay, "--prepare", root].map(literal).join(", ")} }) arguments.Append(AitermNativeProcess.Quote(value)).Append(' ');
|
|
23
|
+
foreach(var value in args) arguments.Append(AitermNativeProcess.Quote(value)).Append(' ');
|
|
24
|
+
var start=new ProcessStartInfo(${literal(node)},arguments.ToString());
|
|
25
|
+
start.UseShellExecute=false; start.CreateNoWindow=true;
|
|
26
|
+
start.RedirectStandardInput=true; start.RedirectStandardOutput=true; start.RedirectStandardError=true;
|
|
27
|
+
start.StandardOutputEncoding=new UTF8Encoding(false);
|
|
28
|
+
using(var child=Process.Start(start)) {
|
|
29
|
+
child.StandardInput.Close();
|
|
30
|
+
var output=child.StandardOutput.ReadToEndAsync();
|
|
31
|
+
var error=child.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError());
|
|
32
|
+
child.WaitForExit(); System.Threading.Tasks.Task.WaitAll(output,error);
|
|
33
|
+
if(child.ExitCode!=0) throw new InvalidOperationException("中継の起動準備に失敗しました");
|
|
34
|
+
using(var stream=new MemoryStream(Encoding.UTF8.GetBytes(output.Result)))
|
|
35
|
+
return (LaunchPlan)new DataContractJsonSerializer(typeof(LaunchPlan)).ReadObject(stream);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
public static int Main(string[] args) {
|
|
39
|
+
LaunchPlan plan=null;
|
|
40
|
+
try {
|
|
41
|
+
Console.OutputEncoding=new UTF8Encoding(false);
|
|
42
|
+
plan=Prepare(args);
|
|
43
|
+
using(var lifetime=new AitermNativeProcess.Lifetime())
|
|
44
|
+
using(var server=AitermNativeProcess.Start(AitermNativeProcess.ParentId(),${literal(binary)},plan.arguments,plan.directory!=null,lifetime)) {
|
|
45
|
+
if(plan.directory==null) return server.Wait();
|
|
46
|
+
// 中継は公式CLIのjobを継承する。launcherは終了監視だけを担い、JSON-RPCを通さない。
|
|
47
|
+
using(var bridge=AitermNativeProcess.Start(server.Pid,${literal(node)},new string[] {
|
|
48
|
+
${literal(relay)},"--serve",${literal(binary)},plan.directory,plan.endpoint,server.Pid.ToString()
|
|
49
|
+
},false,null)) {
|
|
50
|
+
int code=bridge.Wait();
|
|
51
|
+
lifetime.Dispose();
|
|
52
|
+
server.Wait();
|
|
53
|
+
return code;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch(Exception error) { Console.Error.WriteLine("aiterm-mcp: Windows起動失敗: "+error.Message); return 1; }
|
|
57
|
+
finally { if(plan!=null && plan.directory!=null && Directory.Exists(plan.directory)) Directory.Delete(plan.directory,true); }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Windowsの標準process作成APIで、公式CLIの直接の親と必要なstdioだけを引き継ぐ。
|
|
2
|
+
// launcherと公式CLIのPIDは異なる。Macのexecと同じPIDだとは扱わない。
|
|
3
|
+
export const windowsNativeProcessSource = String.raw `
|
|
4
|
+
public static class AitermNativeProcess {
|
|
5
|
+
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct StartupInfo {
|
|
6
|
+
public int cb; public string reserved,desktop,title;
|
|
7
|
+
public int x,y,xSize,ySize,xChars,yChars,fill,flags; public short show,reservedSize;
|
|
8
|
+
public IntPtr reservedBytes,input,output,error;
|
|
9
|
+
}
|
|
10
|
+
[StructLayout(LayoutKind.Sequential)] struct StartupInfoEx { public StartupInfo startup; public IntPtr attributes; }
|
|
11
|
+
[StructLayout(LayoutKind.Sequential)] struct ProcessInfo { public IntPtr process,thread; public int pid,tid; }
|
|
12
|
+
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct ProcessEntry {
|
|
13
|
+
public uint size,usage,pid; public UIntPtr heap; public uint module,threads,parent; public int priority; public uint flags;
|
|
14
|
+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=260)] public string executable;
|
|
15
|
+
}
|
|
16
|
+
[StructLayout(LayoutKind.Sequential)] struct JobLimits {
|
|
17
|
+
public long processTime,jobTime; public uint flags; public UIntPtr minWorking,maxWorking;
|
|
18
|
+
public uint activeProcesses; public UIntPtr affinity; public uint priority,scheduling;
|
|
19
|
+
}
|
|
20
|
+
[StructLayout(LayoutKind.Sequential)] struct JobInfo {
|
|
21
|
+
public JobLimits limits; public ulong reads,writes,others,readBytes,writeBytes,otherBytes;
|
|
22
|
+
public UIntPtr processMemory,jobMemory,peakProcessMemory,peakJobMemory;
|
|
23
|
+
}
|
|
24
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr CreateToolhelp32Snapshot(uint flags,uint pid);
|
|
25
|
+
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool Process32FirstW(IntPtr snapshot,ref ProcessEntry entry);
|
|
26
|
+
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool Process32NextW(IntPtr snapshot,ref ProcessEntry entry);
|
|
27
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr OpenProcess(uint access,bool inherit,int pid);
|
|
28
|
+
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
|
|
29
|
+
[DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind);
|
|
30
|
+
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr CreateFileW(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template);
|
|
31
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern bool DuplicateHandle(IntPtr source,IntPtr handle,IntPtr target,out IntPtr duplicate,uint access,bool inherit,uint options);
|
|
32
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern bool InitializeProcThreadAttributeList(IntPtr list,int count,int flags,ref IntPtr size);
|
|
33
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern bool UpdateProcThreadAttribute(IntPtr list,uint flags,IntPtr attribute,IntPtr value,IntPtr size,IntPtr previous,IntPtr returned);
|
|
34
|
+
[DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list);
|
|
35
|
+
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool CreateProcessW(string app,StringBuilder command,IntPtr processSecurity,IntPtr threadSecurity,bool inherit,uint flags,IntPtr environment,string cwd,ref StartupInfoEx startup,out ProcessInfo info);
|
|
36
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr CreateJobObjectW(IntPtr security,IntPtr name);
|
|
37
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job,int kind,ref JobInfo info,uint size);
|
|
38
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job,IntPtr process);
|
|
39
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern uint ResumeThread(IntPtr thread);
|
|
40
|
+
[DllImport("kernel32.dll")] static extern bool TerminateProcess(IntPtr process,uint code);
|
|
41
|
+
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
|
|
42
|
+
[DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle,uint time);
|
|
43
|
+
[DllImport("kernel32.dll",SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process,out uint exitCode);
|
|
44
|
+
static void Check(bool ok) { if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error()); }
|
|
45
|
+
public static int ParentId() {
|
|
46
|
+
IntPtr snapshot=CreateToolhelp32Snapshot(2,0); Check(snapshot!=new IntPtr(-1));
|
|
47
|
+
try {
|
|
48
|
+
var entry=new ProcessEntry(); entry.size=(uint)Marshal.SizeOf(entry);
|
|
49
|
+
Check(Process32FirstW(snapshot,ref entry)); int current=Process.GetCurrentProcess().Id;
|
|
50
|
+
do { if(entry.pid==current) return (int)entry.parent; } while(Process32NextW(snapshot,ref entry));
|
|
51
|
+
throw new InvalidOperationException("launcherの親processが見つかりません");
|
|
52
|
+
} finally { CloseHandle(snapshot); }
|
|
53
|
+
}
|
|
54
|
+
public static string Quote(string text) {
|
|
55
|
+
if(text.Length>0 && text.IndexOfAny(new char[] {' ','\t','\n','\v','"'})<0) return text;
|
|
56
|
+
var result=new StringBuilder().Append('"'); int slashes=0;
|
|
57
|
+
foreach(char c in text) {
|
|
58
|
+
if(c=='\\') { slashes++; continue; }
|
|
59
|
+
if(c=='"') { result.Append('\\',slashes*2+1); result.Append(c); }
|
|
60
|
+
else { result.Append('\\',slashes); result.Append(c); }
|
|
61
|
+
slashes=0;
|
|
62
|
+
}
|
|
63
|
+
result.Append('\\',slashes*2); return result.Append('"').ToString();
|
|
64
|
+
}
|
|
65
|
+
public sealed class Child : IDisposable {
|
|
66
|
+
internal IntPtr handle; public readonly int Pid;
|
|
67
|
+
internal Child(IntPtr process,int pid) { handle=process; Pid=pid; }
|
|
68
|
+
public int Wait() { WaitForSingleObject(handle,0xffffffff); uint code; Check(GetExitCodeProcess(handle,out code)); return (int)code; }
|
|
69
|
+
public void Dispose() { if(handle!=IntPtr.Zero) { CloseHandle(handle); handle=IntPtr.Zero; } }
|
|
70
|
+
}
|
|
71
|
+
public sealed class Lifetime : IDisposable {
|
|
72
|
+
internal IntPtr handle;
|
|
73
|
+
public Lifetime() {
|
|
74
|
+
handle=CreateJobObjectW(IntPtr.Zero,IntPtr.Zero); Check(handle!=IntPtr.Zero);
|
|
75
|
+
var info=new JobInfo(); info.limits.flags=0x2000;
|
|
76
|
+
try { Check(SetInformationJobObject(handle,9,ref info,(uint)Marshal.SizeOf(info))); }
|
|
77
|
+
catch { Dispose(); throw; }
|
|
78
|
+
}
|
|
79
|
+
public void Dispose() { if(handle!=IntPtr.Zero) { CloseHandle(handle); handle=IntPtr.Zero; } }
|
|
80
|
+
}
|
|
81
|
+
public static Child Start(int parentId,string binary,string[] args,bool quiet,Lifetime lifetime) {
|
|
82
|
+
IntPtr parent=IntPtr.Zero,list=IntPtr.Zero,parentValue=IntPtr.Zero,handleList=IntPtr.Zero,nul=IntPtr.Zero;
|
|
83
|
+
var remote=new IntPtr[3]; var info=new ProcessInfo(); bool initialized=false;
|
|
84
|
+
try {
|
|
85
|
+
parent=OpenProcess(0x80|0x40,false,parentId); Check(parent!=IntPtr.Zero);
|
|
86
|
+
var current=GetCurrentProcess();
|
|
87
|
+
if(quiet) { nul=CreateFileW("NUL",0xc0000000,3,IntPtr.Zero,3,0,IntPtr.Zero); Check(nul!=new IntPtr(-1)); }
|
|
88
|
+
// 指定した親へ必要な3 handleだけを一時複製し、child作成直後に親から閉じる。
|
|
89
|
+
for(int i=0;i<3;i++) Check(DuplicateHandle(current,quiet && i<2 ? nul : GetStdHandle(-10-i),parent,out remote[i],0,true,2));
|
|
90
|
+
IntPtr size=IntPtr.Zero; InitializeProcThreadAttributeList(IntPtr.Zero,2,0,ref size);
|
|
91
|
+
list=Marshal.AllocHGlobal(size); Check(InitializeProcThreadAttributeList(list,2,0,ref size)); initialized=true;
|
|
92
|
+
parentValue=Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(parentValue,parent);
|
|
93
|
+
Check(UpdateProcThreadAttribute(list,0,(IntPtr)0x20000,parentValue,(IntPtr)IntPtr.Size,IntPtr.Zero,IntPtr.Zero));
|
|
94
|
+
handleList=Marshal.AllocHGlobal(3*IntPtr.Size);
|
|
95
|
+
for(int i=0;i<3;i++) Marshal.WriteIntPtr(handleList,i*IntPtr.Size,remote[i]);
|
|
96
|
+
Check(UpdateProcThreadAttribute(list,0,(IntPtr)0x20002,handleList,(IntPtr)(3*IntPtr.Size),IntPtr.Zero,IntPtr.Zero));
|
|
97
|
+
var startup=new StartupInfoEx(); startup.startup.cb=Marshal.SizeOf(startup); startup.attributes=list;
|
|
98
|
+
startup.startup.flags=0x100; startup.startup.input=remote[0]; startup.startup.output=remote[1]; startup.startup.error=remote[2];
|
|
99
|
+
var command=new StringBuilder(Quote(binary)); foreach(var arg in args) command.Append(' ').Append(Quote(arg));
|
|
100
|
+
Check(CreateProcessW(binary,command,IntPtr.Zero,IntPtr.Zero,true,0x80000|0x8000000|4,IntPtr.Zero,Environment.CurrentDirectory,ref startup,out info));
|
|
101
|
+
if(lifetime!=null) Check(AssignProcessToJobObject(lifetime.handle,info.process));
|
|
102
|
+
for(int i=0;i<3;i++) { IntPtr local; Check(DuplicateHandle(parent,remote[i],current,out local,0,false,3)); CloseHandle(local); remote[i]=IntPtr.Zero; }
|
|
103
|
+
Check(ResumeThread(info.thread)!=0xffffffff);
|
|
104
|
+
var child=new Child(info.process,info.pid); info.process=IntPtr.Zero; return child;
|
|
105
|
+
} catch {
|
|
106
|
+
if(info.process!=IntPtr.Zero) TerminateProcess(info.process,1);
|
|
107
|
+
throw;
|
|
108
|
+
} finally {
|
|
109
|
+
if(nul!=IntPtr.Zero && nul!=new IntPtr(-1)) CloseHandle(nul);
|
|
110
|
+
if(info.thread!=IntPtr.Zero) CloseHandle(info.thread); if(info.process!=IntPtr.Zero) CloseHandle(info.process);
|
|
111
|
+
foreach(var handle in remote) if(handle!=IntPtr.Zero) { IntPtr local; if(DuplicateHandle(parent,handle,GetCurrentProcess(),out local,0,false,3)) CloseHandle(local); }
|
|
112
|
+
if(initialized) DeleteProcThreadAttributeList(list);
|
|
113
|
+
if(list!=IntPtr.Zero) Marshal.FreeHGlobal(list); if(parentValue!=IntPtr.Zero) Marshal.FreeHGlobal(parentValue); if(handleList!=IntPtr.Zero) Marshal.FreeHGlobal(handleList);
|
|
114
|
+
if(parent!=IntPtr.Zero) CloseHandle(parent);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
`;
|
|
@@ -1,154 +1,94 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Windowsの起動processと認証付きloopback接続を所有する。JSON-RPC本文は変更しない。
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
// Windowsの認証付きloopback接続と接続情報だけを所有する。起動・stdio中継の契約は共通。
|
|
4
2
|
import { createServer } from "node:net";
|
|
5
|
-
import { createInterface } from "node:readline";
|
|
6
3
|
import { once } from "node:events";
|
|
7
4
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
8
5
|
import { join } from "node:path";
|
|
9
6
|
import { fileURLToPath } from "node:url";
|
|
10
7
|
import { writeFileSync, rmSync } from "node:fs";
|
|
11
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
12
8
|
import WebSocket from "ws";
|
|
13
9
|
import { ensurePrivateDirectory, makeFilePrivate } from "./windows-codex-state.js";
|
|
14
10
|
import { readWindowsProcesses } from "./windows-codex-connection.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
if (/^(?:--config=|--enable=|--disable=|-c.)/.test(arg)) {
|
|
25
|
-
result.push(arg);
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
if (!server) {
|
|
29
|
-
if (arg !== "app-server")
|
|
30
|
-
return null;
|
|
31
|
-
server = true;
|
|
32
|
-
result.push(arg);
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
if (["proxy", "daemon", "start", "stop", "status", "generate-ts", "generate-json-schema", "--help", "-h", "--version", "-V"].includes(arg))
|
|
36
|
-
return null;
|
|
37
|
-
if (arg === "--stdio" || arg === "--listen=stdio://")
|
|
38
|
-
continue;
|
|
39
|
-
if (arg === "--listen") {
|
|
40
|
-
if (args[++i] !== "stdio://")
|
|
41
|
-
throw new Error("stdio以外の接続指定は変更できません");
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
if (arg.startsWith("--listen=") || arg.startsWith("--ws-"))
|
|
45
|
-
throw new Error("既存の接続・認証指定は変更できません");
|
|
46
|
-
result.push(arg);
|
|
47
|
-
}
|
|
48
|
-
return server ? result : null;
|
|
49
|
-
}
|
|
50
|
-
export async function runWindowsCodexRelay(binary, root, args) {
|
|
51
|
-
const serverArgs = windowsServerArguments(args);
|
|
52
|
-
if (!serverArgs) {
|
|
53
|
-
const child = spawn(binary, args, { stdio: "inherit", windowsHide: true });
|
|
54
|
-
return (await once(child, "close"))[0] ?? 1;
|
|
55
|
-
}
|
|
11
|
+
import { codexServerArguments } from "./codex-relay-arguments.js";
|
|
12
|
+
import { relayCodexStdio } from "./codex-relay-stdio.js";
|
|
13
|
+
export { codexServerArguments as windowsServerArguments } from "./codex-relay-arguments.js";
|
|
14
|
+
export async function prepareWindowsRelay(root, args) {
|
|
15
|
+
const serverArgs = codexServerArguments(args);
|
|
16
|
+
if (!serverArgs)
|
|
17
|
+
return { directory: null, endpoint: null, arguments: args };
|
|
56
18
|
const directory = join(root, randomUUID());
|
|
57
19
|
ensurePrivateDirectory(directory);
|
|
58
|
-
const file = join(directory, "connection.json");
|
|
59
|
-
const tokenFile = join(directory, "token");
|
|
60
|
-
const token = randomBytes(32).toString("hex");
|
|
61
|
-
writeFileSync(tokenFile, token, { flag: "wx" });
|
|
62
|
-
makeFilePrivate(tokenFile);
|
|
63
|
-
// OSが割り当てた空きportを使い、取得競合は公式serverの起動失敗として明示する。
|
|
64
|
-
const reservation = createServer();
|
|
65
|
-
reservation.listen(0, "127.0.0.1");
|
|
66
|
-
await once(reservation, "listening");
|
|
67
|
-
const port = reservation.address().port;
|
|
68
|
-
await new Promise((resolve, reject) => reservation.close(error => error ? reject(error) : resolve()));
|
|
69
|
-
const endpoint = `ws://127.0.0.1:${port}`;
|
|
70
|
-
const child = spawn(binary, [...serverArgs, "--listen", endpoint, "--ws-auth", "capability-token", "--ws-token-file", tokenFile], {
|
|
71
|
-
stdio: ["ignore", "ignore", "pipe"], windowsHide: true,
|
|
72
|
-
});
|
|
73
|
-
const exited = new Promise(resolve => child.once("close", () => resolve()));
|
|
74
|
-
let failed;
|
|
75
|
-
child.on("error", error => { failed = error; });
|
|
76
|
-
// WebSocketの定型起動案内だけを除き、公式CLIの診断はDesktopへそのまま返す。
|
|
77
|
-
const diagnostics = createInterface({ input: child.stderr });
|
|
78
|
-
diagnostics.on("line", line => {
|
|
79
|
-
if (!/^(?:\s*|codex app-server \(WebSockets\)| {2}(?:listening on:|readyz:|healthz:|note:).*)$/.test(line))
|
|
80
|
-
process.stderr.write(line + "\n");
|
|
81
|
-
});
|
|
82
|
-
let socket;
|
|
83
|
-
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
84
|
-
const lines = input[Symbol.asyncIterator]();
|
|
85
|
-
let ending = false;
|
|
86
|
-
const stop = () => { ending = true; child.kill(); socket?.terminate(); input.close(); process.stdin.destroy(); };
|
|
87
|
-
input.once("close", stop);
|
|
88
|
-
process.stdout.once("error", stop);
|
|
89
20
|
try {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
21
|
+
const tokenFile = join(directory, "token");
|
|
22
|
+
writeFileSync(tokenFile, randomBytes(32).toString("hex"), { flag: "wx" });
|
|
23
|
+
makeFilePrivate(tokenFile);
|
|
24
|
+
// port取得後の競合は公式serverの起動失敗として明示する。
|
|
25
|
+
const reservation = createServer();
|
|
26
|
+
reservation.listen(0, "127.0.0.1");
|
|
27
|
+
await once(reservation, "listening");
|
|
28
|
+
const port = reservation.address().port;
|
|
29
|
+
await new Promise((resolve, reject) => reservation.close(error => error ? reject(error) : resolve()));
|
|
30
|
+
const endpoint = "ws://127.0.0.1:" + port;
|
|
31
|
+
return { directory, endpoint, arguments: [...serverArgs, "--listen", endpoint, "--ws-auth", "capability-token", "--ws-token-file", tokenFile] };
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
rmSync(directory, { recursive: true, force: true });
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function serveWindowsRelay(binary, directory, endpoint, serverPid) {
|
|
39
|
+
if (process.ppid !== serverPid)
|
|
40
|
+
throw new Error("公式Codexの子として中継を起動してください");
|
|
41
|
+
const { readFileSync } = await import("node:fs");
|
|
42
|
+
const token = readFileSync(join(directory, "token"), "utf8");
|
|
43
|
+
try {
|
|
44
|
+
await relayCodexStdio({
|
|
45
|
+
alive: () => { try {
|
|
46
|
+
process.kill(serverPid, 0);
|
|
47
|
+
return true;
|
|
98
48
|
}
|
|
99
49
|
catch (error) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
50
|
+
if (error.code === "ESRCH")
|
|
51
|
+
return false;
|
|
52
|
+
throw error;
|
|
53
|
+
} },
|
|
54
|
+
socket: () => new WebSocket(endpoint, { headers: { Authorization: "Bearer " + token }, handshakeTimeout: 1_000, perMessageDeflate: false }),
|
|
55
|
+
stop: async () => {
|
|
56
|
+
// launcherが保持するprocess handleにより、このPIDは終了確認まで再利用されない。
|
|
57
|
+
try {
|
|
58
|
+
process.kill(serverPid);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error.code !== "ESRCH")
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
connected: () => {
|
|
66
|
+
const server = readWindowsProcesses().find(row => row.pid === serverPid);
|
|
67
|
+
if (!server || server.executable.toLowerCase() !== binary.toLowerCase())
|
|
68
|
+
throw new Error("公式Codexの起動を確認できません");
|
|
69
|
+
const record = { schema: "aiterm.windows-relay.v1", serverPid, serverStarted: server.started, binary, endpoint };
|
|
70
|
+
const file = join(directory, "connection.json");
|
|
71
|
+
writeFileSync(file, JSON.stringify(record), { flag: "wx" });
|
|
72
|
+
makeFilePrivate(file);
|
|
73
|
+
},
|
|
122
74
|
});
|
|
123
|
-
process.stdout.on("drain", () => connected.resume());
|
|
124
|
-
connected.on("error", () => { failed = new Error("公式Codexとの接続に失敗しました"); stop(); });
|
|
125
|
-
connected.on("close", () => { if (!ending) {
|
|
126
|
-
failed = new Error("公式Codexとの接続が終了しました");
|
|
127
|
-
stop();
|
|
128
|
-
} });
|
|
129
|
-
// initializeが接続準備前に届いても失わないよう、iteratorを接続前から保持する。
|
|
130
|
-
for await (const line of lines)
|
|
131
|
-
await new Promise((resolve, reject) => connected.send(line, error => error ? reject(error) : resolve()));
|
|
132
|
-
if (failed)
|
|
133
|
-
throw failed;
|
|
134
|
-
return 0;
|
|
135
75
|
}
|
|
136
76
|
finally {
|
|
137
|
-
stop();
|
|
138
|
-
await exited;
|
|
139
|
-
diagnostics.close();
|
|
140
77
|
rmSync(directory, { recursive: true, force: true });
|
|
141
78
|
}
|
|
142
79
|
}
|
|
143
80
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
144
|
-
const [
|
|
81
|
+
const [mode, ...args] = process.argv.slice(2);
|
|
145
82
|
try {
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
83
|
+
if (mode === "--prepare" && args[0])
|
|
84
|
+
process.stdout.write(JSON.stringify(await prepareWindowsRelay(args[0], args.slice(1))));
|
|
85
|
+
else if (mode === "--serve" && args.length === 4 && /^[1-9][0-9]*$/.test(args[3]))
|
|
86
|
+
await serveWindowsRelay(args[0], args[1], args[2], Number(args[3]));
|
|
87
|
+
else
|
|
88
|
+
throw new Error("Windows中継の起動指定が不正です");
|
|
149
89
|
}
|
|
150
90
|
catch (error) {
|
|
151
|
-
process.stderr.write(
|
|
91
|
+
process.stderr.write("aiterm-mcp: " + (error instanceof Error ? error.message : "Windows中継が失敗しました") + "\n");
|
|
152
92
|
process.exitCode = 1;
|
|
153
93
|
}
|
|
154
94
|
}
|