aiterm-mcp 0.35.1 → 0.37.0

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.
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // DesktopのJSONLと公式App ServerのUnix WebSocketの中継。RPCの内容は変更しない。
3
+ import { createConnection } from "node:net";
4
+ import { createInterface } from "node:readline";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { once } from "node:events";
7
+ import { fileURLToPath } from "node:url";
8
+ import WebSocket from "ws";
9
+ import { prepareRelayDirectory } from "./codex-relay-config.js";
10
+ export async function runCodexStdioRelay(socketPath, serverPid) {
11
+ let socket;
12
+ let ending = false;
13
+ let stopping;
14
+ function stopServer() {
15
+ return stopping ??= (async () => {
16
+ // 公式Unix受付は1回目でturn完了待ち、2回目で終了する。
17
+ for (let i = 0; i < 2; i++) {
18
+ if (process.ppid !== serverPid)
19
+ return;
20
+ try {
21
+ process.kill(serverPid, "SIGTERM");
22
+ }
23
+ catch (error) {
24
+ if (error.code === "ESRCH")
25
+ return;
26
+ throw error;
27
+ }
28
+ if (i === 0)
29
+ await delay(100);
30
+ }
31
+ })();
32
+ }
33
+ async function connectDuringStartup() {
34
+ const deadline = Date.now() + 15_000;
35
+ while (true) {
36
+ if (process.ppid !== serverPid)
37
+ throw new Error("公式App Serverが起動中に終了しました");
38
+ const candidate = new WebSocket("ws://localhost/rpc", {
39
+ createConnection: () => createConnection(socketPath), handshakeTimeout: 10_000, perMessageDeflate: false,
40
+ });
41
+ try {
42
+ await once(candidate, "open");
43
+ return candidate;
44
+ }
45
+ catch (error) {
46
+ candidate.on("error", () => { });
47
+ candidate.terminate();
48
+ // exec直後のsocket作成だけを待つ。送信済みRPCは再送しない。
49
+ if (!["ENOENT", "ECONNREFUSED"].includes(error.code ?? "") || Date.now() >= deadline)
50
+ throw error;
51
+ await delay(25);
52
+ }
53
+ }
54
+ }
55
+ try {
56
+ socket = await connectDuringStartup();
57
+ const connected = socket;
58
+ connected.on("message", (data, binary) => {
59
+ if (binary) {
60
+ process.stderr.write("aiterm-relay: 予期しないバイナリ応答\n");
61
+ process.exitCode = 1;
62
+ void stopServer();
63
+ connected.terminate();
64
+ return;
65
+ }
66
+ if (!process.stdout.write(`${data.toString()}\n`))
67
+ connected.pause();
68
+ });
69
+ process.stdout.on("drain", () => connected.resume());
70
+ connected.on("error", () => {
71
+ process.stderr.write("aiterm-relay: 通信に失敗しました\n");
72
+ process.exitCode = 1;
73
+ void stopServer();
74
+ });
75
+ connected.on("close", () => {
76
+ if (!ending) {
77
+ process.stderr.write("aiterm-relay: 公式App Serverとの接続が終了しました\n");
78
+ process.exitCode = 1;
79
+ }
80
+ process.stdin.destroy();
81
+ });
82
+ process.stdout.on("error", () => { ending = true; void stopServer(); connected.terminate(); process.stdin.destroy(); });
83
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
84
+ for await (const line of lines) {
85
+ await new Promise((resolve, reject) => connected.send(line, error => error ? reject(error) : resolve()));
86
+ }
87
+ ending = true;
88
+ await stopServer();
89
+ connected.terminate();
90
+ }
91
+ catch (error) {
92
+ process.stderr.write(`aiterm-relay: 中継失敗: ${error instanceof Error ? error.message : String(error)}\n`);
93
+ process.exitCode = 1;
94
+ await stopServer();
95
+ socket?.terminate();
96
+ process.stdin.destroy();
97
+ }
98
+ }
99
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
100
+ const [socket, pid] = process.argv.slice(2);
101
+ if (socket === "--prepare") {
102
+ try {
103
+ if (process.platform === "win32")
104
+ throw new Error("WindowsのAF_UNIX中継は未対応です");
105
+ if (!pid || Buffer.byteLength(`${pid}/9999999999.sock`) >= 104)
106
+ throw new Error("socketのpathが長すぎます");
107
+ prepareRelayDirectory(pid);
108
+ }
109
+ catch (error) {
110
+ process.stderr.write(`aiterm-relay: ${error instanceof Error ? error.message : String(error)}\n`);
111
+ process.exitCode = 2;
112
+ }
113
+ }
114
+ else if (!socket || !/^\d+$/.test(pid ?? "") || Number(pid) <= 1 || Number(pid) !== process.ppid) {
115
+ process.stderr.write("aiterm-relay: 親processの指定が不正です\n");
116
+ process.exitCode = 2;
117
+ }
118
+ else
119
+ await runCodexStdioRelay(socket, Number(pid));
120
+ }
@@ -235,7 +235,7 @@ export class ParentDeliveryManager {
235
235
  }
236
236
  record.state = "ready";
237
237
  this.save(job);
238
- // 次の依頼へ進める時点は本文保存まで。親のキュー受付を待たせない。
238
+ // 次の依頼へ進める時点は本文保存まで。親の受付を待たせない。
239
239
  job.delivery = this.deliver(job).catch((error) => this.reportServiceError(error));
240
240
  })();
241
241
  return job.capture;
@@ -368,7 +368,7 @@ export class ParentDeliveryManager {
368
368
  this.jobs.set(record.delivery_id, job);
369
369
  if (record.state === "sending") {
370
370
  record.state = "unknown";
371
- record.error = "PARENT_DELIVERY_INTERRUPTED: キュー送信中にMCP processが終了しました。自動再送はしていません";
371
+ record.error = "PARENT_DELIVERY_INTERRUPTED: 回答送信中にMCP processが終了しました。自動再送はしていません";
372
372
  this.finish(job);
373
373
  }
374
374
  else if (record.state === "waiting" || record.state === "ready") {
@@ -44,7 +44,7 @@ export function readRuntimeProcesses() {
44
44
  "$ErrorActionPreference='Stop'",
45
45
  "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)",
46
46
  "@(Get-CimInstance Win32_Process | Where-Object { $null -ne $_.CreationDate -and $null -ne $_.CommandLine } | ForEach-Object {",
47
- "[ordered]@{ pid=[int]$_.ProcessId; parent_pid=[int]$_.ParentProcessId; started_identity=$_.CreationDate.ToUniversalTime().ToString('o'); command=$_.CommandLine; cpu_seconds=([double]$_.KernelModeTime+[double]$_.UserModeTime)/10000000 }",
47
+ "[ordered]@{ pid=[int]$_.ProcessId; parent_pid=[int]$_.ParentProcessId; executable=[string]$_.ExecutablePath; started_identity=$_.CreationDate.ToUniversalTime().ToString('o'); command=$_.CommandLine; cpu_seconds=([double]$_.KernelModeTime+[double]$_.UserModeTime)/10000000 }",
48
48
  "}) | ConvertTo-Json -Compress",
49
49
  ].join("\n");
50
50
  const result = spawnSync(resolveWindowsPowerShell7(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
@@ -69,6 +69,7 @@ export function readRuntimeProcesses() {
69
69
  const command = row.command.trim();
70
70
  return {
71
71
  pid: row.pid, parent_pid: row.parent_pid, process_group_id: null, stopped: null,
72
+ executable: row.executable,
72
73
  started_identity: new Date(row.started_identity).toISOString(),
73
74
  command, cpu_seconds: row.cpu_seconds, argv_digest: createHash("sha256").update(command).digest("hex"),
74
75
  };
@@ -7,7 +7,7 @@ import { defaultRuntimeErrorPaths, windowsPrivateDaclCommand, expectedHostProfil
7
7
  export { defaultRuntimeErrorPaths, windowsPrivateDaclCommand, windowsPrivateDaclVerifyCommand } from "./runtime-error-os.js";
8
8
  import { fileURLToPath } from "node:url";
9
9
  const pkg = createRequire(import.meta.url)("../package.json");
10
- const STORE_SCHEMA = "aiterm-mcp.runtime-errors.v1";
10
+ const STORE_SCHEMA = "aiterm-mcp.runtime-errors.v2";
11
11
  const STATE_SCHEMA = "1.0";
12
12
  const MAX_CONFIG_BYTES = 16 * 1024;
13
13
  const MAX_STORE_BYTES = 1024 * 1024;
@@ -241,9 +241,17 @@ export class RuntimeErrorStore {
241
241
  return EMPTY_STATE();
242
242
  throw error;
243
243
  }
244
- const state = validateState(JSON.parse(text), this.maxRecords);
244
+ const value = JSON.parse(text);
245
+ const legacy = isObject(value) && value.schema_version === "aiterm-mcp.runtime-errors.v1";
246
+ const state = validateState(legacy ? { ...value, schema_version: STORE_SCHEMA } : value, this.maxRecords);
245
247
  if (!state)
246
248
  throw new Error("runtime error store schema が不正です");
249
+ // 旧集約は初回版だけを保持した。複数回発生した記録の最終発生版は復元できない。
250
+ if (legacy)
251
+ for (const record of state.records) {
252
+ if (record.occurrence_count > 1)
253
+ record.product_version = "unknown";
254
+ }
247
255
  return state;
248
256
  }
249
257
  applyWindowsDacl(target, kind) {
@@ -542,6 +550,7 @@ export class RuntimeErrorStore {
542
550
  const seen = this.now().toISOString();
543
551
  const sequence = state.cursor + 1;
544
552
  if (record) {
553
+ record.product_version = this.productVersion;
545
554
  record.occurrence_count += 1;
546
555
  record.last_seen = seen;
547
556
  record.status = "open";
package/dist/setup-cli.js CHANGED
@@ -3,6 +3,9 @@ import { runSetup } from "./setup.js";
3
3
  import { removeClaudeParentHooks } from "./setup-integrations.js";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { configureCodexSteer } from "./setup-codex-relay.js";
8
+ import { readRelayConfig } from "./codex-relay-config.js";
6
9
  const args = process.argv.slice(2);
7
10
  if (args.length === 1 && args[0] === "--remove-claude-parent-hooks") {
8
11
  try {
@@ -15,14 +18,42 @@ if (args.length === 1 && args[0] === "--remove-claude-parent-hooks") {
15
18
  }
16
19
  }
17
20
  else if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
18
- process.stdout.write("使い方: aiterm-setup [--json | --remove-claude-parent-hooks]\n製品の依存準備、検出したAIへの登録、MCPと端末の実動作確認を行います。結果はJSONで返します。旧版へ戻す前の専用hook解除も、この入口で行います。\n");
19
- }
20
- else if (args.length > 1 || (args.length === 1 && args[0] !== "--json")) {
21
- process.stderr.write("使い方: aiterm-setup [--json]\n");
22
- process.exitCode = 2;
21
+ process.stdout.write("使い方: aiterm-setup [--json] [--codex-steer enable|disable|status]\n依存準備、AIへの登録、MCPと端末の実動作確認を行います。対話実行ではAiterm単品かCodex Desktop Steer付きかを選べます。SteerはmacOS・Windows対応で、初回はCodexの再起動が必要です。disableは元の起動設定へ戻し、statusは実際の接続を確認します。--jsonは対話せず、Steerの選択を維持します。\n旧版へ戻す前のClaude専用hook解除: --remove-claude-parent-hooks\n");
23
22
  }
24
23
  else {
25
- const result = await runSetup();
26
- process.stdout.write(`${JSON.stringify(result)}\n`);
27
- process.exitCode = result.status === "ready" ? 0 : 2;
24
+ try {
25
+ let action;
26
+ let json = false;
27
+ for (let i = 0; i < args.length; i++) {
28
+ if (args[i] === "--json" && !json)
29
+ json = true;
30
+ else if (args[i] === "--codex-steer" && !action && ["enable", "disable", "status"].includes(args[i + 1]))
31
+ action = args[++i];
32
+ else
33
+ throw new Error("使い方: aiterm-setup [--json] [--codex-steer enable|disable|status]");
34
+ }
35
+ const onlySteer = action === "disable" || action === "status";
36
+ if (!action && !json && process.stdin.isTTY && process.stderr.isTTY && ["darwin", "win32"].includes(process.platform)) {
37
+ const enabled = readRelayConfig()?.enabled ?? false;
38
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
39
+ try {
40
+ const answer = (await prompt.question(`導入方法: 1=Aiterm単品(公式キュー配送)、2=Codex DesktopへSteerと終了後再開を有効化 [${enabled ? "2" : "1"}]: `)).trim();
41
+ if (answer && !["1", "2"].includes(answer))
42
+ throw new Error("導入方法は1または2で指定してください");
43
+ action = (answer || (enabled ? "2" : "1")) === "2" ? "enable" : "disable";
44
+ }
45
+ finally {
46
+ prompt.close();
47
+ }
48
+ }
49
+ const result = onlySteer
50
+ ? { schema: "aiterm.codex-steer-result.v1", ...await configureCodexSteer(action) }
51
+ : await runSetup({ codex_steer: action });
52
+ process.stdout.write(`${JSON.stringify(result)}\n`);
53
+ process.exitCode = ["ready", "disabled"].includes(result.status) ? 0 : result.status === "restart_required" ? 3 : 2;
54
+ }
55
+ catch (error) {
56
+ process.stderr.write(`aiterm-setup: ${error instanceof Error ? error.message : String(error)}\n`);
57
+ process.exitCode = 2;
58
+ }
28
59
  }
@@ -0,0 +1,187 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { createInterface } from "node:readline";
6
+ import { fileURLToPath } from "node:url";
7
+ import { randomUUID } from "node:crypto";
8
+ import { SetupError } from "./setup-platform.js";
9
+ import { CodexDeliveryError } from "./codex-delivery-error.js";
10
+ import { readRelayConfig, relayConfigDirectory, prepareRelayDirectory } from "./codex-relay-config.js";
11
+ import { codexRelayLauncher } from "./codex-relay-launcher.js";
12
+ import { withCodexRelay } from "./codex-relay-client.js";
13
+ import { readRuntimeProcesses } from "./process-runtime.js";
14
+ import { installRelayLogin, removeRelayLogin } from "./codex-relay-login.js";
15
+ import { configureWindowsCodexSteer } from "./windows-codex-setup.js";
16
+ function command(executable, args) {
17
+ const result = spawnSync(executable, args, { encoding: "utf8", timeout: 15_000 });
18
+ if (result.error || result.status !== 0)
19
+ throw new SetupError("codex_steer_setup_failed", `${path.basename(executable)}を実行できません`);
20
+ return result.stdout.trim();
21
+ }
22
+ function getGui(key) {
23
+ const result = spawnSync("/bin/launchctl", ["getenv", key], { encoding: "utf8", timeout: 5_000 });
24
+ if (result.status === 1 && !result.stderr.trim())
25
+ return null;
26
+ if (result.error || result.status !== 0)
27
+ throw new SetupError("codex_steer_environment_unavailable", "GUIの起動設定を確認できません");
28
+ return result.stdout.trim() || null;
29
+ }
30
+ function findDesktopBinary() {
31
+ const search = spawnSync("/usr/bin/mdfind", ["kMDItemCFBundleIdentifier == 'com.openai.codex'"], { encoding: "utf8", timeout: 10_000 });
32
+ const candidates = [...new Set(["/Applications/Codex.app", "/Applications/ChatGPT.app", ...(search.status === 0 ? search.stdout.trim().split("\n") : [])])];
33
+ const found = candidates.filter(app => {
34
+ if (!app || !fs.existsSync(path.join(app, "Contents", "Resources", "codex")))
35
+ return false;
36
+ const result = spawnSync("/usr/libexec/PlistBuddy", ["-c", "Print:CFBundleIdentifier", path.join(app, "Contents", "Info.plist")], { encoding: "utf8", timeout: 5_000 });
37
+ return result.status === 0 && result.stdout.trim() === "com.openai.codex";
38
+ });
39
+ if (found.length !== 1)
40
+ throw new SetupError("codex_desktop_not_identified", "Codex Desktopのインストール先を一つに特定できません");
41
+ const binary = path.join(found[0], "Contents", "Resources", "codex");
42
+ command("/usr/bin/codesign", ["--verify", "--strict", binary]);
43
+ const version = command(binary, ["--version"]);
44
+ const match = /codex-cli (\d+)\.(\d+)\.(\d+)/.exec(version);
45
+ if (!match || (Number(match[1]) === 0 && Number(match[2]) < 154)) {
46
+ throw new SetupError("codex_version_unsupported", "SteerにはCodex CLI 0.154以上を同梱したCodex Desktopが必要です");
47
+ }
48
+ return binary;
49
+ }
50
+ /** 模擬HOMEで起動・initialize・終了を確認する。利用者の認証やtaskは使わない。 */
51
+ export async function verifyRelayLauncher(launcher) {
52
+ const temporary = fs.mkdtempSync(path.join(tmpdir(), "aiterm-relay-setup-"));
53
+ fs.mkdirSync(path.join(temporary, ".codex"));
54
+ const child = spawn(launcher, ["-c", 'cli_auth_credentials_store="file"', "-c", 'mcp_oauth_credentials_store="file"', "app-server"], {
55
+ env: { ...process.env, HOME: temporary, CODEX_HOME: path.join(temporary, ".codex") }, stdio: ["pipe", "pipe", "ignore"], windowsHide: true,
56
+ });
57
+ const reader = createInterface({ input: child.stdout });
58
+ const exited = new Promise(resolve => child.once("close", resolve));
59
+ try {
60
+ await new Promise((resolve, reject) => {
61
+ const timer = setTimeout(() => reject(new SetupError("codex_relay_probe_failed", "中継経由の公式受付を確認できません")), 20_000);
62
+ const fail = () => { clearTimeout(timer); reject(new SetupError("codex_relay_probe_failed", "中継経由の公式起動に失敗しました")); };
63
+ child.once("error", fail);
64
+ child.once("close", fail);
65
+ child.stdin.once("error", fail);
66
+ reader.on("line", line => {
67
+ try {
68
+ const value = JSON.parse(line);
69
+ if (value.id !== 1 || value.method)
70
+ return;
71
+ clearTimeout(timer);
72
+ if (!value.result || value.error)
73
+ fail();
74
+ else
75
+ resolve();
76
+ }
77
+ catch {
78
+ fail();
79
+ }
80
+ });
81
+ child.stdin.write(JSON.stringify({ id: 1, method: "initialize", params: { clientInfo: { name: "aiterm_setup", version: "1" } } }) + "\n");
82
+ });
83
+ }
84
+ finally {
85
+ child.stdin.end();
86
+ const timer = setTimeout(() => child.kill("SIGKILL"), 2_000);
87
+ const code = await exited;
88
+ clearTimeout(timer);
89
+ reader.close();
90
+ fs.rmSync(temporary, { recursive: true, force: true });
91
+ if (code !== 0)
92
+ throw new SetupError("codex_relay_probe_failed", "中継の終了を確認できません");
93
+ }
94
+ }
95
+ export async function liveRelay(config) {
96
+ if (!fs.existsSync(config.socket_root))
97
+ return false;
98
+ const processes = readRuntimeProcesses();
99
+ const rows = new Map(processes.map(row => [row.pid, row]));
100
+ const desktopPrefix = path.join(path.dirname(path.dirname(config.binary)), "MacOS") + path.sep;
101
+ for (const name of fs.readdirSync(config.socket_root).filter(name => /^\d+\.sock$/.test(name))) {
102
+ const server = rows.get(Number(name.slice(0, -5)));
103
+ const desktop = server && rows.get(server.parent_pid);
104
+ if (!server || !(server.command === config.binary || server.command.startsWith(config.binary + " "))
105
+ || !desktop?.command.startsWith(desktopPrefix))
106
+ continue;
107
+ try {
108
+ await withCodexRelay(path.join(config.socket_root, name), async (request) => { await request("thread/loaded/list", { limit: 1 }); }, 1_000);
109
+ return true;
110
+ }
111
+ catch (error) {
112
+ // 終了済みsocketだけをreadyの証拠から外す。権限・protocolエラーはそのまま返す。
113
+ if (!(error instanceof CodexDeliveryError) || error.delivery_code !== "CODEX_RELAY_UNAVAILABLE")
114
+ throw error;
115
+ }
116
+ }
117
+ return false;
118
+ }
119
+ function writeConfig(directory, config) {
120
+ const temporary = path.join(directory, `${randomUUID()}.tmp`);
121
+ fs.writeFileSync(temporary, JSON.stringify(config, null, 2) + "\n", { mode: 0o600, flag: "wx" });
122
+ fs.renameSync(temporary, path.join(directory, "config.json"));
123
+ }
124
+ export async function configureCodexSteer(action = "status", overrides = {}) {
125
+ if ((overrides.platform ?? process.platform) === "win32")
126
+ return configureWindowsCodexSteer(action, overrides);
127
+ const runtime = {
128
+ platform: process.platform, directory: relayConfigDirectory(), socket_root: `/tmp/aiterm-codex-${process.getuid?.() ?? 0}`,
129
+ node: process.execPath, relay: fileURLToPath(new URL("./codex-stdio-relay.js", import.meta.url)),
130
+ findBinary: findDesktopBinary, getGui,
131
+ setGui: (key, value) => { command("/bin/launchctl", value === null ? ["unsetenv", key] : ["setenv", key, value]); },
132
+ persist: installRelayLogin, unpersist: removeRelayLogin,
133
+ verify: verifyRelayLauncher, live: liveRelay, ...overrides,
134
+ };
135
+ if (runtime.platform !== "darwin") {
136
+ return action === "enable" ? { status: "unsupported", reason_code: "codex_steer_platform_unsupported" } : { status: "disabled" };
137
+ }
138
+ const previous = readRelayConfig(runtime.directory);
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" };
187
+ }
package/dist/setup.js CHANGED
@@ -8,6 +8,8 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
8
8
  import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
9
9
  import { prepareBackend, runSetupCommand, SetupError } from "./setup-platform.js";
10
10
  import { configureIntegrations } from "./setup-integrations.js";
11
+ import { configureCodexSteer } from "./setup-codex-relay.js";
12
+ import { readRelayConfig } from "./codex-relay-config.js";
11
13
  export function globalRegistration(run = runSetupCommand) {
12
14
  const root = run(process.platform === "win32" ? "npm.cmd" : "npm", ["root", "-g"]).trim();
13
15
  if (!isAbsolute(root) || /[\r\n]/u.test(root))
@@ -75,6 +77,18 @@ export async function runSetup(options = {}) {
75
77
  }
76
78
  else
77
79
  result.status = "ready";
80
+ if (result.status === "ready") {
81
+ stage = "codex_steer";
82
+ result.codex_steer = await (options.steer ?? configureCodexSteer)(options.codex_steer ?? (readRelayConfig()?.enabled ? "enable" : "status"));
83
+ if (["failed", "unsupported", "restart_required"].includes(result.codex_steer.status)) {
84
+ result.status = result.codex_steer.status;
85
+ result.reason_code = result.codex_steer.reason_code;
86
+ }
87
+ if (result.codex_steer.status === "restart_required")
88
+ progress("起動設定を保存しました。Codexを完全終了して再起動し、aiterm-setup --codex-steer statusで確認してください");
89
+ if (result.codex_steer.status === "unsupported")
90
+ progress("このOSのCodex Desktop Steerは未対応です。Aiterm単品の導入・利用は可能です");
91
+ }
78
92
  }
79
93
  catch (error) {
80
94
  const code = error instanceof SetupError ? error.code : `${stage}_failed`;
@@ -0,0 +1,111 @@
1
+ // gpt-connectorのWindows実装をMIT条件で移植。実行時の外部製品依存は持たない。
2
+ import { lstatSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative } from "node:path";
4
+ import { z } from "zod";
5
+ import { windowsPowerShellSync, quotePowerShell } from "./windows-codex-state.js";
6
+ import { CodexDeliveryError } from "./codex-delivery-error.js";
7
+ import { readRuntimeProcesses } from "./process-runtime.js";
8
+ export const windowsRelaySchema = z.object({
9
+ schema: z.enum(["aiterm.windows-relay.v1", "gpt-connector.windows-relay.v1"]),
10
+ serverPid: z.number().int().positive(), serverStarted: z.string(), binary: z.string(),
11
+ endpoint: z.string().regex(/^ws:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/),
12
+ }).strict();
13
+ const processSchema = z.object({ pid: z.number(), parent_pid: z.number(), command: z.string(), executable: z.string(), started: z.string() });
14
+ export function readWindowsProcesses() {
15
+ return readRuntimeProcesses().map(row => ({ pid: row.pid, parent_pid: row.parent_pid, command: row.command, executable: row.executable ?? "", started: row.started_identity }));
16
+ }
17
+ function invalid(message) { throw new CodexDeliveryError("CODEX_RELAY_CONNECTION_INVALID", message); }
18
+ export function readWindowsRelay(file, processes = readWindowsProcesses()) {
19
+ if (!isAbsolute(file) || lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile())
20
+ invalid("WindowsのSteer接続記録が不正です。");
21
+ const root = dirname(file);
22
+ if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory())
23
+ invalid("WindowsのSteer保存先が不正です。");
24
+ // 接続情報は外部入力。読む前に所有者とACLを検証し、権限を修復して成功扱いしない。
25
+ windowsPowerShellSync(`
26
+ $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
27
+ foreach ($entry in @(${quotePowerShell(root)}, ${quotePowerShell(file)}, ${quotePowerShell(join(root, "token"))})) {
28
+ $acl = Get-Acl -LiteralPath $entry
29
+ if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -notin @($sid, 'S-1-5-32-544')) { throw 'Steer保存先の所有者が一致しません' }
30
+ foreach ($rule in $acl.Access) {
31
+ $identity = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
32
+ if ($rule.AccessControlType -eq 'Allow' -and $identity -notin @($sid, 'S-1-5-18', 'S-1-5-32-544')) { throw 'Steer保存先が他userへ公開されています' }
33
+ }
34
+ }`);
35
+ const record = windowsRelaySchema.parse(JSON.parse(readFileSync(file, "utf8")));
36
+ const server = processes.find(row => row.pid === record.serverPid);
37
+ if (!server || Date.parse(server.started) !== Date.parse(record.serverStarted) || server.executable.toLowerCase() !== record.binary.toLowerCase()
38
+ || !server.command.includes(record.endpoint) || !server.command.includes(join(root, "token")))
39
+ invalid("Windowsの親Codex processを照合できません。");
40
+ return record;
41
+ }
42
+ export function findWindowsParentSocket(rows = readWindowsProcesses(), callerPid = process.pid, root) {
43
+ const parents = new Map(rows.map(row => [row.pid, row]));
44
+ const seen = new Set();
45
+ let pid = parents.get(callerPid)?.parent_pid;
46
+ while (pid && !seen.has(pid)) {
47
+ const row = parents.get(pid);
48
+ // 自分が起動した公式CLIの引数から接続先を取得する。MCPへの環境変数継承を前提にしない。
49
+ const token = row && basename(row.executable).toLowerCase() === "codex.exe"
50
+ ? /(?:^|\s)--ws-token-file\s+(?:"([^"]+)"|(\S+))/.exec(row.command) : null;
51
+ const tokenFile = token?.[1] ?? token?.[2];
52
+ if (tokenFile && isAbsolute(tokenFile) && basename(tokenFile) === "token") {
53
+ const file = join(dirname(tokenFile), "connection.json");
54
+ if (root) {
55
+ const location = relative(root, file);
56
+ if (isAbsolute(location) || location === ".." || location.startsWith("..\\"))
57
+ invalid("親CodexはAitermのSteer起動設定を使っていません。Codexを完全再起動してください。");
58
+ }
59
+ const record = readWindowsRelay(file, rows);
60
+ if (record.serverPid !== pid)
61
+ invalid("Steer接続記録と親Codexが一致しません。");
62
+ return file;
63
+ }
64
+ seen.add(pid);
65
+ pid = row?.parent_pid;
66
+ }
67
+ invalid("親CodexのSteer接続がありません。aiterm-setup --codex-steer enableを実行し、Codexを再起動してください。");
68
+ }
69
+ export function windowsSocketConnection(file) {
70
+ const record = readWindowsRelay(file);
71
+ const tokenFile = join(dirname(file), "token");
72
+ if (lstatSync(tokenFile).isSymbolicLink() || !lstatSync(tokenFile).isFile())
73
+ invalid("Steer認証fileが不正です。");
74
+ const token = readFileSync(tokenFile, "utf8");
75
+ if (!/^[a-f0-9]{64}$/.test(token))
76
+ invalid("Steer認証情報が不正です。");
77
+ return { url: record.endpoint, options: { headers: { Authorization: `Bearer ${token}` } } };
78
+ }
79
+ /** 登録済みlauncherとDesktopの子孫にある、対応する公式接続だけを共有できる。 */
80
+ export function windowsDesktopRelay(launcher, rows = readWindowsProcesses()) {
81
+ const parents = new Map(rows.map(row => [row.pid, row]));
82
+ for (const server of rows) {
83
+ if (basename(server.executable).toLowerCase() !== "codex.exe" || !server.command.includes("--ws-token-file"))
84
+ continue;
85
+ let row = parents.get(server.parent_pid);
86
+ let matchedLauncher = false;
87
+ let matchedDesktop = false;
88
+ const seen = new Set();
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)
100
+ continue;
101
+ const token = /(?:^|\s)--ws-token-file\s+(?:"([^"]+)"|(\S+))/.exec(server.command);
102
+ const tokenFile = token?.[1] ?? token?.[2];
103
+ if (!tokenFile || !isAbsolute(tokenFile) || basename(tokenFile) !== "token")
104
+ continue;
105
+ const file = join(dirname(tokenFile), "connection.json");
106
+ if (readWindowsRelay(file, rows).serverPid !== server.pid)
107
+ invalid("Steer接続記録と親Codexが一致しません。");
108
+ return file;
109
+ }
110
+ return null;
111
+ }