aiterm-mcp 0.27.0 → 0.27.3
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/dist/agent-resolver.js +178 -0
- package/dist/core.js +29 -340
- package/dist/errors.js +36 -0
- package/dist/runtime-error-os.js +168 -0
- package/dist/runtime-error-store.js +2 -158
- package/dist/tmux-runtime.js +131 -0
- package/package.json +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// エージェント CLI(claude / codex / grok)・Throughline・pane shell(Windows は Git Bash)の
|
|
2
|
+
// 実行ファイルをどう見つけ、どう起動するかの所有者(OS 分岐の所有者。tmux とは独立)。
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import { AitermError } from "./errors.js";
|
|
8
|
+
import { isWin } from "./tmux-runtime.js";
|
|
9
|
+
export function isUsableExecutableFile(candidate) {
|
|
10
|
+
try {
|
|
11
|
+
if (!fs.statSync(candidate).isFile())
|
|
12
|
+
return false;
|
|
13
|
+
if (isWin)
|
|
14
|
+
return /\.(?:exe|cmd|bat|com)$/i.test(candidate);
|
|
15
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function isWindowsDrivePath(candidate) {
|
|
23
|
+
return /^[A-Za-z]:[\\/]/.test(candidate);
|
|
24
|
+
}
|
|
25
|
+
export function isWindowsNativeExecutable(candidate) {
|
|
26
|
+
return isWindowsDrivePath(candidate) && /\.(?:exe|com|cmd|bat)$/i.test(candidate);
|
|
27
|
+
}
|
|
28
|
+
// Windows の bin 受入: native 実行ファイル(.exe/.cmd/.bat)に加え、pane shell
|
|
29
|
+
// (Git Bash)が shebang で実行できる script も実在すれば受け入れる。旧 WSL 側
|
|
30
|
+
// バイナリ検査への黙ったフォールバックは廃止(別 HOME・別 auth の subagent を
|
|
31
|
+
// 作るため)。native 実行ファイルの強制が要る vendor(grok/composer の実効
|
|
32
|
+
// sandbox 等)は openAgent 側の専用ゲートが明示エラーで担う。
|
|
33
|
+
export function isUsableAgentExecutableFile(candidate) {
|
|
34
|
+
if (!isWin)
|
|
35
|
+
return isUsableExecutableFile(candidate);
|
|
36
|
+
if (isWindowsNativeExecutable(candidate))
|
|
37
|
+
return isUsableExecutableFile(candidate);
|
|
38
|
+
try {
|
|
39
|
+
return fs.statSync(candidate).isFile();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function resolveWindowsCodexShim(kind, candidate) {
|
|
46
|
+
if (!isWin || kind !== "codex" || !/\.(?:cmd|bat)$/i.test(candidate))
|
|
47
|
+
return candidate;
|
|
48
|
+
const packageRoot = path.join(path.dirname(candidate), "node_modules", "@openai", "codex", "node_modules", "@openai");
|
|
49
|
+
try {
|
|
50
|
+
for (const platformPackage of fs.readdirSync(packageRoot).filter((name) => name.startsWith("codex-win32-"))) {
|
|
51
|
+
const vendorRoot = path.join(packageRoot, platformPackage, "vendor");
|
|
52
|
+
for (const target of fs.readdirSync(vendorRoot)) {
|
|
53
|
+
const executable = path.join(vendorRoot, target, "bin", "codex.exe");
|
|
54
|
+
if (isUsableExecutableFile(executable))
|
|
55
|
+
return executable;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* 下の明示エラーへ */
|
|
61
|
+
}
|
|
62
|
+
throw new AitermError(`CODEX_BIN のnpm shimからWindows native codex.exeを解決できません: ${candidate}。` +
|
|
63
|
+
"@openai/codexを再インストールするか、CODEX_BINへcodex.exeを指定してください", 2);
|
|
64
|
+
}
|
|
65
|
+
// pane 内の POSIX shell(Windows は Git Bash 等)へ渡す bin パス。Git Bash は
|
|
66
|
+
// バックスラッシュをエスケープとして解釈しうるため、ドライブパスは forward slash 形へ。
|
|
67
|
+
export function agentBinForPaneShell(bin) {
|
|
68
|
+
return isWin && isWindowsDrivePath(bin) ? bin.replace(/\\/g, "/") : bin;
|
|
69
|
+
}
|
|
70
|
+
export function spawnAgentControlCommand(bin, args, _cwd, options) {
|
|
71
|
+
// 受入(isUsableAgentExecutableFile)が「使える」と判定した bin は、control command
|
|
72
|
+
//(`claude auth status --json`/`grok models`)でも同じく実行できなければならない。
|
|
73
|
+
if (isWin && !/\.(?:exe|com)$/i.test(bin)) {
|
|
74
|
+
if (/\.(?:cmd|bat)$/i.test(bin)) {
|
|
75
|
+
// Node は CVE-2024-27980 対処以降、.cmd/.bat の直接 spawn を EINVAL で拒否する。
|
|
76
|
+
// 受入が .cmd/.bat を許す以上、control 経路は shell 経由で実行する(args は固定語彙)。
|
|
77
|
+
return spawnSync(bin, args, { ...options, shell: true });
|
|
78
|
+
}
|
|
79
|
+
// 受入は pane shell(Git Bash)が shebang で実行できる script も許す。Windows の
|
|
80
|
+
// CreateProcess は shebang を解さないため、control command も同じ Git Bash で実行する。
|
|
81
|
+
// これを直接 spawn すると常に失敗し、「受入が通した bin で起動が必ず失敗する」矛盾になる。
|
|
82
|
+
return spawnSync(resolveWinPaneShell("bash"), [bin, ...args], options);
|
|
83
|
+
}
|
|
84
|
+
return spawnSync(bin, args, options);
|
|
85
|
+
}
|
|
86
|
+
export function resolveWinPaneShell(shell) {
|
|
87
|
+
if (!isWin || shell !== "bash")
|
|
88
|
+
return shell;
|
|
89
|
+
const fromEnv = process.env.AITERM_BASH;
|
|
90
|
+
if (fromEnv) {
|
|
91
|
+
if (isUsableExecutableFile(fromEnv))
|
|
92
|
+
return fromEnv;
|
|
93
|
+
throw new AitermError(`AITERM_BASH に指定された bash が存在しません: ${fromEnv}`, 2);
|
|
94
|
+
}
|
|
95
|
+
const candidates = [
|
|
96
|
+
path.join(process.env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe"),
|
|
97
|
+
path.join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
|
|
98
|
+
];
|
|
99
|
+
const gitPath = spawnSync("where.exe", ["git.exe"], { encoding: "utf8", timeout: 5000 })
|
|
100
|
+
.stdout?.split(/\r?\n/)
|
|
101
|
+
.find(Boolean);
|
|
102
|
+
if (gitPath)
|
|
103
|
+
candidates.push(path.join(path.dirname(path.dirname(gitPath)), "bin", "bash.exe"));
|
|
104
|
+
for (const cand of candidates) {
|
|
105
|
+
if (isUsableExecutableFile(cand))
|
|
106
|
+
return cand;
|
|
107
|
+
}
|
|
108
|
+
throw new AitermError("Git Bash が見つかりません。Windows ネイティブの pane shell には Git for Windows の bash.exe が必要です" +
|
|
109
|
+
"(System32 の bash.exe は WSL launcher のため使いません)。Git for Windows を導入するか、" +
|
|
110
|
+
"AITERM_BASH に bash.exe のパスを指定してください。", 2);
|
|
111
|
+
}
|
|
112
|
+
export function resolveAgentBin(kind) {
|
|
113
|
+
const home = process.env.HOME ?? os.homedir();
|
|
114
|
+
const [envVar, rel, name] = kind === "claude"
|
|
115
|
+
? ["CLAUDE_BIN", [".local", "bin", "claude"], "claude"]
|
|
116
|
+
: kind === "codex"
|
|
117
|
+
? ["CODEX_BIN", [".local", "bin", "codex"], "codex"]
|
|
118
|
+
: ["GROK_BIN", [".grok", "bin", isWin ? "grok.exe" : "grok"], "grok"];
|
|
119
|
+
const fromEnv = process.env[envVar];
|
|
120
|
+
if (fromEnv) {
|
|
121
|
+
// 明示指定 env は実在を検証する。存在しないパスを黙って返すと、session を作って
|
|
122
|
+
// `'/typo' ...` を送信し bash が command not found を出すだけで openAgent は「起動した」と
|
|
123
|
+
// 偽成功を返す(既定パス/PATH 経路は検証するのに env だけ無検証だった非対称の解消・A3)。
|
|
124
|
+
if (isUsableAgentExecutableFile(fromEnv))
|
|
125
|
+
return resolveWindowsCodexShim(kind, fromEnv);
|
|
126
|
+
throw new AitermError(`${envVar} に指定された ${name} が存在しません: ${fromEnv}`, 2);
|
|
127
|
+
}
|
|
128
|
+
const cand = path.join(home, ...rel);
|
|
129
|
+
if (isUsableAgentExecutableFile(cand))
|
|
130
|
+
return resolveWindowsCodexShim(kind, cand);
|
|
131
|
+
const w = spawnSync(isWin ? "where" : "which", [name], {
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
timeout: 5000,
|
|
134
|
+
});
|
|
135
|
+
if (w.status === 0 && (w.stdout ?? "").trim()) {
|
|
136
|
+
const found = w.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
137
|
+
const ordered = isWin
|
|
138
|
+
? [...found.filter((p) => /\.(?:exe|com|cmd|bat)$/i.test(p)), ...found.filter((p) => !/\.(?:exe|com|cmd|bat)$/i.test(p))]
|
|
139
|
+
: found;
|
|
140
|
+
for (const resolved of ordered) {
|
|
141
|
+
if (isUsableAgentExecutableFile(resolved))
|
|
142
|
+
return resolveWindowsCodexShim(kind, resolved);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
export function resolveThroughlineBin() {
|
|
148
|
+
const fromEnv = process.env.THROUGHLINE_BIN;
|
|
149
|
+
if (fromEnv)
|
|
150
|
+
return isUsableExecutableFile(fromEnv) ? fromEnv : null;
|
|
151
|
+
const resolved = spawnSync(isWin ? "where" : "which", ["throughline"], {
|
|
152
|
+
encoding: "utf8",
|
|
153
|
+
}).stdout?.split(/\r?\n/).find(Boolean) ?? null;
|
|
154
|
+
return resolved && isUsableExecutableFile(resolved) ? resolved : null;
|
|
155
|
+
}
|
|
156
|
+
export function runThroughlineHandoffContext(bin, sessionId) {
|
|
157
|
+
if (isWin && /\.(?:cmd|bat)$/i.test(bin)) {
|
|
158
|
+
const ps1 = path.join(path.dirname(bin), `${path.basename(bin, path.extname(bin))}.ps1`);
|
|
159
|
+
if (fs.existsSync(ps1)) {
|
|
160
|
+
return spawnSync("powershell.exe", [
|
|
161
|
+
"-NoLogo",
|
|
162
|
+
"-NoProfile",
|
|
163
|
+
"-NonInteractive",
|
|
164
|
+
"-ExecutionPolicy",
|
|
165
|
+
"Bypass",
|
|
166
|
+
"-File",
|
|
167
|
+
ps1,
|
|
168
|
+
"handoff-context",
|
|
169
|
+
"--session",
|
|
170
|
+
sessionId,
|
|
171
|
+
"--json",
|
|
172
|
+
], { encoding: "utf8" });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return spawnSync(bin, ["handoff-context", "--session", sessionId, "--json"], {
|
|
176
|
+
encoding: "utf8",
|
|
177
|
+
});
|
|
178
|
+
}
|
package/dist/core.js
CHANGED
|
@@ -14,19 +14,11 @@ import * as os from "node:os";
|
|
|
14
14
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import * as rtk from "./rtk.js";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
// POSIX は従来どおり。Windows は TMPDIR→TEMP→os.tmpdir() の順で Windows 側の一時領域に置く。
|
|
23
|
-
const SOCKDIR = path.join(process.env.TMPDIR ?? (isWin ? process.env.TEMP ?? os.tmpdir() : "/tmp"), "claude-tmux-sockets");
|
|
24
|
-
// tmux -S に渡すソケットパス(POSIX はログと同じツリーに置く)。
|
|
25
|
-
// Windows は native psmux を使い、-S でなく -L server namespace で隔離する
|
|
26
|
-
// (psmux の -S は黙って既定 namespace へ落ちるため使わない・実測 2026-08-16)。
|
|
27
|
-
// SOCKDIR から短い安定名を導出し、TMPDIR ごとの隔離(テスト)と再起動跨ぎ再接続を両立する。
|
|
28
|
-
const SOCK = path.join(SOCKDIR, "claude.sock");
|
|
29
|
-
const WIN_NS = `aiterm-${createHash("sha1").update(SOCKDIR).digest("hex").slice(0, 12)}`;
|
|
17
|
+
import { AitermError, telemetryOwnedFailure, ownTelemetryFailure } from "./errors.js";
|
|
18
|
+
import { isWin, SOCKDIR, SOCK, WIN_NS, tmuxCommand, tmuxCommandWithInput } from "./tmux-runtime.js";
|
|
19
|
+
import { resolveAgentBin, spawnAgentControlCommand, resolveThroughlineBin, runThroughlineHandoffContext, isWindowsNativeExecutable, agentBinForPaneShell, resolveWinPaneShell } from "./agent-resolver.js";
|
|
20
|
+
export { AitermError } from "./errors.js";
|
|
21
|
+
export { tmuxSpawnEnv } from "./tmux-runtime.js";
|
|
30
22
|
// 完了検出
|
|
31
23
|
export const DEFAULT_TIMEOUT = 10.0;
|
|
32
24
|
const POLL = 0.25;
|
|
@@ -121,150 +113,6 @@ const KEYMAP = {
|
|
|
121
113
|
up: "Up", down: "Down", left: "Left", right: "Right",
|
|
122
114
|
space: "Space", bspace: "BSpace", backspace: "BSpace",
|
|
123
115
|
};
|
|
124
|
-
export class AitermError extends Error {
|
|
125
|
-
code;
|
|
126
|
-
constructor(message, code = 1) {
|
|
127
|
-
super(message);
|
|
128
|
-
this.code = code;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
class TelemetryOwnedError extends AitermError {
|
|
132
|
-
telemetryCode;
|
|
133
|
-
constructor(message, code, telemetryCode, cause) {
|
|
134
|
-
super(message, code);
|
|
135
|
-
this.name = "TelemetryOwnedError";
|
|
136
|
-
this.telemetryCode = telemetryCode;
|
|
137
|
-
if (cause !== undefined)
|
|
138
|
-
this.cause = cause;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
function telemetryOwnedFailure(telemetryCode, error, fallbackCode = 1) {
|
|
142
|
-
if (error instanceof TelemetryOwnedError)
|
|
143
|
-
return error;
|
|
144
|
-
recordRuntimeError(telemetryCode);
|
|
145
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
146
|
-
const code = error instanceof AitermError ? error.code : fallbackCode;
|
|
147
|
-
return new TelemetryOwnedError(message, code, telemetryCode, error);
|
|
148
|
-
}
|
|
149
|
-
function ownTelemetryFailure(telemetryCode, error, fallbackCode = 1) {
|
|
150
|
-
throw telemetryOwnedFailure(telemetryCode, error, fallbackCode);
|
|
151
|
-
}
|
|
152
|
-
function ptyDependencyError(message, observe = true) {
|
|
153
|
-
const error = new AitermError(message, 2);
|
|
154
|
-
if (observe)
|
|
155
|
-
ownTelemetryFailure("AITERM.PTY_DEPENDENCY_UNAVAILABLE", error, 2);
|
|
156
|
-
throw error;
|
|
157
|
-
}
|
|
158
|
-
// Windows で最初の呼び出し前に一度だけ native psmux の可用性を確かめ、失敗は原因別に投げる。
|
|
159
|
-
// psmux は tmux CLI 互換の Windows ネイティブ実装(ConPTY・WSL 不要)。AITERM_PSMUX で
|
|
160
|
-
// バイナリを明示上書きできる(POSIX の AITERM_TMUX に対応)。
|
|
161
|
-
function psmuxBin() {
|
|
162
|
-
return process.env.AITERM_PSMUX || "psmux";
|
|
163
|
-
}
|
|
164
|
-
let winPsmuxOk = false;
|
|
165
|
-
function ensureWinPsmux(observe = true) {
|
|
166
|
-
if (winPsmuxOk)
|
|
167
|
-
return;
|
|
168
|
-
const r = spawnSync(psmuxBin(), ["-V"], { encoding: "utf8", timeout: 10000 });
|
|
169
|
-
if (r.error) {
|
|
170
|
-
const code = r.error.code;
|
|
171
|
-
if (code === "ENOENT")
|
|
172
|
-
ptyDependencyError("psmux が見つかりません。Windows ネイティブでは psmux が必要です(導入例: winget install marlocarlo.psmux)。既存の psmux を使う場合は AITERM_PSMUX でパスを指定してください。", observe);
|
|
173
|
-
ptyDependencyError(`psmux を起動できませんでした(${code ?? "unknown"})。`, observe);
|
|
174
|
-
}
|
|
175
|
-
if (r.status !== 0)
|
|
176
|
-
ptyDependencyError("psmux -V が失敗しました。`psmux -V` が通るか確認してください。", observe);
|
|
177
|
-
winPsmuxOk = true;
|
|
178
|
-
}
|
|
179
|
-
// tmux が見つからないときの説明。macOS は tmux を同梱せず、Homebrew の bin は GUI 起動時の PATH に
|
|
180
|
-
// 入らないため、原因と対処(導入・自動探索・AITERM_TMUX 上書き)を正直に提示する。
|
|
181
|
-
function tmuxMissingMessage() {
|
|
182
|
-
const head = "tmux が見つかりません(PATH 上に存在しません)。aiterm は実行時に tmux を必要とします。";
|
|
183
|
-
const hint = process.platform === "darwin"
|
|
184
|
-
? "macOS では `brew install tmux` で導入してください。GUI から起動された場合、Homebrew の bin " +
|
|
185
|
-
"(Apple Silicon: /opt/homebrew/bin、Intel: /usr/local/bin)が PATH に含まれないことがあります" +
|
|
186
|
-
"(その場合 aiterm が自動で探索します)。"
|
|
187
|
-
: "(例: Debian/Ubuntu は `sudo apt install tmux`)。";
|
|
188
|
-
const override = "別の場所にある tmux を使うには AITERM_TMUX=/path/to/tmux を指定してください。";
|
|
189
|
-
return `${head}${hint}${override}`;
|
|
190
|
-
}
|
|
191
|
-
// POSIX(Linux/WSL2/macOS) 用の tmux バイナリ解決。Windows の ensureWinBridge() に対応する事前確認。
|
|
192
|
-
// 解決順: AITERM_TMUX(明示指定)→ PATH 上の tmux → Homebrew 既定パス。一度だけ実行しキャッシュする。
|
|
193
|
-
// 見つからなければ tmuxMissingMessage で投げる(黙ってフォールバックせず、原因が見えるようにする)。
|
|
194
|
-
let tmuxBin = null;
|
|
195
|
-
function resolveTmux(observe = true) {
|
|
196
|
-
if (tmuxBin)
|
|
197
|
-
return tmuxBin;
|
|
198
|
-
const override = process.env.AITERM_TMUX;
|
|
199
|
-
if (override) {
|
|
200
|
-
const r = spawnSync(override, ["-V"], { encoding: "utf8", timeout: 5000 });
|
|
201
|
-
if (!r.error && r.status === 0)
|
|
202
|
-
return (tmuxBin = override);
|
|
203
|
-
ptyDependencyError(`AITERM_TMUX に指定された tmux を起動できません: ${override}(\`${override} -V\` が通りません)`, observe);
|
|
204
|
-
}
|
|
205
|
-
// CLI/開発時は PATH 上の tmux をそのまま使う(最優先)。
|
|
206
|
-
const onPath = spawnSync("tmux", ["-V"], { encoding: "utf8", timeout: 5000 });
|
|
207
|
-
if (!onPath.error && onPath.status === 0)
|
|
208
|
-
return (tmuxBin = "tmux");
|
|
209
|
-
// GUI 起動などで PATH に Homebrew の bin が無い場合、既定の場所を探す。
|
|
210
|
-
// 使う場合は黙らず stderr へ告知する(stdout は JSON-RPC 専用=index.ts の制約)。
|
|
211
|
-
for (const cand of ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux"]) {
|
|
212
|
-
try {
|
|
213
|
-
fs.accessSync(cand, fs.constants.X_OK);
|
|
214
|
-
console.error(`aiterm: tmux が PATH 上に無いため ${cand} を使用します`);
|
|
215
|
-
return (tmuxBin = cand);
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
218
|
-
/* 次の候補へ */
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
ptyDependencyError(tmuxMissingMessage(), observe);
|
|
222
|
-
}
|
|
223
|
-
// tmux は locale が C/POSIX/未設定だと UTF-8 を扱えない: server は send-keys/paste のマルチバイト
|
|
224
|
-
// 入力を破壊し(文字消失・周辺バイトの並べ替えを実測)、client は format 出力のタブ等を "_" へ
|
|
225
|
-
// サニタイズする。GUI 起動の MCP client は LANG を持たないことが多く、その環境で立った tmux server は
|
|
226
|
-
// 以後すべての入力を壊すため、UTF-8 locale が確定しない場合だけ LC_CTYPE を明示注入する。
|
|
227
|
-
// 利用者が C/POSIX 以外を明示設定している場合はその選択を尊重して触らない。
|
|
228
|
-
export function tmuxSpawnEnv() {
|
|
229
|
-
const effective = process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "";
|
|
230
|
-
// 素の "C"/"POSIX" だけを壊れた既定とみなす。"C.UTF-8" を含む charset 付きの明示設定は尊重する。
|
|
231
|
-
if (effective && !/^(C|POSIX)$/i.test(effective))
|
|
232
|
-
return undefined;
|
|
233
|
-
const env = { ...process.env, LC_CTYPE: process.platform === "darwin" ? "UTF-8" : "C.UTF-8" };
|
|
234
|
-
// LC_ALL は LC_CTYPE より優先されるため、C/POSIX の LC_ALL が残ると注入が無効になる
|
|
235
|
-
delete env.LC_ALL;
|
|
236
|
-
return env;
|
|
237
|
-
}
|
|
238
|
-
function tmuxCommandWithInput(observe, input, ...args) {
|
|
239
|
-
// maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)や多セッションの list-sessions で
|
|
240
|
-
// 頭打ちになり stdout が切れる/空になる。Python の subprocess.run は無制限だったので 64MiB へ広げる。
|
|
241
|
-
// Windows は tmux CLI 互換の native psmux を -L namespace 隔離で叩く(WSL 非依存)。
|
|
242
|
-
let r;
|
|
243
|
-
const spawnOpts = { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, input, env: tmuxSpawnEnv() };
|
|
244
|
-
if (isWin) {
|
|
245
|
-
ensureWinPsmux(observe);
|
|
246
|
-
r = spawnSync(psmuxBin(), ["-L", WIN_NS, ...args], spawnOpts);
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
// resolveTmux() は tmux を解決できなければ明確な AitermError を投げる(POSIX 版の事前確認)。
|
|
250
|
-
r = spawnSync(resolveTmux(observe), ["-S", SOCK, ...args], spawnOpts);
|
|
251
|
-
}
|
|
252
|
-
// ENOBUFS(出力が 64MiB 超)を「code=1 の失敗」へ握り潰すと部分/空 stdout を正常扱いしてしまう。区別して投げる。
|
|
253
|
-
// EXPECTED-FAILURE: 外部システム境界(tmux 出力過大)
|
|
254
|
-
if (r.error && r.error.code === "ENOBUFS") {
|
|
255
|
-
throw new AitermError(`tmux 出力が 64MiB を超えました(${args[0]})。範囲を絞って読んでください。`, 2);
|
|
256
|
-
}
|
|
257
|
-
// 解決済み tmux が実行時に消えた(アンインストール等)場合の ENOENT を、空 stderr の code=1 へ握り潰さない。
|
|
258
|
-
// 通常は resolveTmux() が事前に弾くため発火しないが、mid-run 消滅に対する正直な防御。
|
|
259
|
-
if (!isWin && r.error && r.error.code === "ENOENT") {
|
|
260
|
-
tmuxBin = null; // 次回 resolveTmux で再解決を許す
|
|
261
|
-
ptyDependencyError(tmuxMissingMessage(), observe);
|
|
262
|
-
}
|
|
263
|
-
return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
264
|
-
}
|
|
265
|
-
function tmuxCommand(observe, ...args) {
|
|
266
|
-
return tmuxCommandWithInput(observe, undefined, ...args);
|
|
267
|
-
}
|
|
268
116
|
function tmux(...args) {
|
|
269
117
|
return tmuxCommand(true, ...args);
|
|
270
118
|
}
|
|
@@ -831,32 +679,6 @@ function assertNotDestructive(text, code, context = "") {
|
|
|
831
679
|
// Windows native pane の既定 shell 解決。裸の "bash" は PATH 上で System32 の
|
|
832
680
|
// WSL launcher (bash.exe) に解決されてしまうため、Git for Windows の bash.exe を
|
|
833
681
|
// 明示解決する(WSL 非依存の裁定に従う)。AITERM_BASH で上書き可。
|
|
834
|
-
function resolveWinPaneShell(shell) {
|
|
835
|
-
if (!isWin || shell !== "bash")
|
|
836
|
-
return shell;
|
|
837
|
-
const fromEnv = process.env.AITERM_BASH;
|
|
838
|
-
if (fromEnv) {
|
|
839
|
-
if (isUsableExecutableFile(fromEnv))
|
|
840
|
-
return fromEnv;
|
|
841
|
-
throw new AitermError(`AITERM_BASH に指定された bash が存在しません: ${fromEnv}`, 2);
|
|
842
|
-
}
|
|
843
|
-
const candidates = [
|
|
844
|
-
path.join(process.env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe"),
|
|
845
|
-
path.join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
|
|
846
|
-
];
|
|
847
|
-
const gitPath = spawnSync("where.exe", ["git.exe"], { encoding: "utf8", timeout: 5000 })
|
|
848
|
-
.stdout?.split(/\r?\n/)
|
|
849
|
-
.find(Boolean);
|
|
850
|
-
if (gitPath)
|
|
851
|
-
candidates.push(path.join(path.dirname(path.dirname(gitPath)), "bin", "bash.exe"));
|
|
852
|
-
for (const cand of candidates) {
|
|
853
|
-
if (isUsableExecutableFile(cand))
|
|
854
|
-
return cand;
|
|
855
|
-
}
|
|
856
|
-
throw new AitermError("Git Bash が見つかりません。Windows ネイティブの pane shell には Git for Windows の bash.exe が必要です" +
|
|
857
|
-
"(System32 の bash.exe は WSL launcher のため使いません)。Git for Windows を導入するか、" +
|
|
858
|
-
"AITERM_BASH に bash.exe のパスを指定してください。", 2);
|
|
859
|
-
}
|
|
860
682
|
export function openSession(name, shell = "bash") {
|
|
861
683
|
shell = resolveWinPaneShell(shell);
|
|
862
684
|
try {
|
|
@@ -1620,13 +1442,15 @@ function codexConfigSummary(configPath) {
|
|
|
1620
1442
|
bits.push(`sandbox_mode=${sandboxMode}`);
|
|
1621
1443
|
return `共有 config: ${bits.join(" / ")}`;
|
|
1622
1444
|
}
|
|
1623
|
-
function createClaudeCorrelationSettings(name, launchId) {
|
|
1445
|
+
function createClaudeCorrelationSettings(name, launchId, model, effort) {
|
|
1624
1446
|
const hookScript = claudeHookScriptPath();
|
|
1625
1447
|
if (!fs.existsSync(hookScript)) {
|
|
1626
1448
|
throw new AitermError(`Claude Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
|
|
1627
1449
|
}
|
|
1628
1450
|
const settings = agentManagedClaudeSettingsPath(name, launchId);
|
|
1629
1451
|
writeJson0600(settings, {
|
|
1452
|
+
...(model ? { model } : {}),
|
|
1453
|
+
...(effort ? { effortLevel: effort } : {}),
|
|
1630
1454
|
hooks: {
|
|
1631
1455
|
Stop: [
|
|
1632
1456
|
{
|
|
@@ -2173,13 +1997,13 @@ function liveWaitLocks(name) {
|
|
|
2173
1997
|
}
|
|
2174
1998
|
return out;
|
|
2175
1999
|
}
|
|
2176
|
-
function createClaudeAgentMetadata(name, cwd, initialPrompt, launchOperationId, launchRequestDigest, lineageContext) {
|
|
2000
|
+
function createClaudeAgentMetadata(name, cwd, initialPrompt, launchOperationId, launchRequestDigest, lineageContext, model, effort) {
|
|
2177
2001
|
const launchId = randomBytes(16).toString("hex");
|
|
2178
2002
|
const eventFile = agentEventPath(name, launchId);
|
|
2179
2003
|
const resultFile = agentClaudeResultPath(name, launchId);
|
|
2180
2004
|
createEmpty0600(eventFile);
|
|
2181
2005
|
createEmpty0600(resultFile);
|
|
2182
|
-
const claudeSettings = createClaudeCorrelationSettings(name, launchId);
|
|
2006
|
+
const claudeSettings = createClaudeCorrelationSettings(name, launchId, model, effort);
|
|
2183
2007
|
const meta = {
|
|
2184
2008
|
kind: "claude",
|
|
2185
2009
|
aiterm_session: name,
|
|
@@ -2341,9 +2165,14 @@ function loadAgentMetadata(name) {
|
|
|
2341
2165
|
};
|
|
2342
2166
|
}
|
|
2343
2167
|
if (m.kind === "codex") {
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2168
|
+
// codex_home は起動時に記録した per-launch の正本を信じる。現在の process env
|
|
2169
|
+
// (realCodexHome())との等値を要求すると、席専用 CODEX_HOME で起動した正当な
|
|
2170
|
+
// session が別の aiterm instance から全部拒否される(2026-08-22 実測: peertable の
|
|
2171
|
+
// 席へ pty_key できず円卓が停止した)。検証は形(絶対パス)だけにする。
|
|
2172
|
+
const recordedHome = m.codex_home;
|
|
2173
|
+
if (m.hook_route !== "shared_codex_home" || m.completion_route !== "codex_transcript"
|
|
2174
|
+
|| typeof recordedHome !== "string" || !path.isAbsolute(recordedHome)) {
|
|
2175
|
+
throw new AitermError("agent metadata の codex_home が不正です(絶対パスの記録が必要)", 2);
|
|
2347
2176
|
}
|
|
2348
2177
|
return {
|
|
2349
2178
|
kind: "codex",
|
|
@@ -2359,18 +2188,22 @@ function loadAgentMetadata(name) {
|
|
|
2359
2188
|
completion_route: "codex_transcript",
|
|
2360
2189
|
...loadAgentLineageFields(m, true),
|
|
2361
2190
|
node_platform: process.platform,
|
|
2362
|
-
codex_home:
|
|
2191
|
+
codex_home: recordedHome,
|
|
2363
2192
|
};
|
|
2364
2193
|
}
|
|
2365
|
-
|
|
2194
|
+
// grok_home も codex_home と同じ理由で per-launch の記録値を正とする(席専用 GROK_HOME)。
|
|
2195
|
+
// 認証正本の照合も記録された home に対して行う——現在の env の home と比較すると、
|
|
2196
|
+
// 席専用 home で起動した session が別 instance から拒否される。
|
|
2197
|
+
const recordedGrokHome = m.grok_home;
|
|
2366
2198
|
if (m.hook_route !== "shared_grok_home" ||
|
|
2367
2199
|
m.completion_route !== "grok_transcript" ||
|
|
2368
|
-
|
|
2200
|
+
typeof recordedGrokHome !== "string" ||
|
|
2201
|
+
!path.isAbsolute(recordedGrokHome) ||
|
|
2369
2202
|
typeof m.vendor_session_id !== "string" ||
|
|
2370
2203
|
!UUID_RE.test(m.vendor_session_id)) {
|
|
2371
|
-
throw new AitermError("agent metadata の
|
|
2204
|
+
throw new AitermError("agent metadata の grok_home が不正です(絶対パスの記録が必要)", 2);
|
|
2372
2205
|
}
|
|
2373
|
-
const expectedAuthPath = resolveAndValidateGrokAuth(
|
|
2206
|
+
const expectedAuthPath = resolveAndValidateGrokAuth(recordedGrokHome);
|
|
2374
2207
|
if ((typeof m.grok_auth_path === "string" ? m.grok_auth_path : null) !== expectedAuthPath) {
|
|
2375
2208
|
throw new AitermError("agent metadata の認証正本が現在の設定と一致しません", 2);
|
|
2376
2209
|
}
|
|
@@ -2388,7 +2221,7 @@ function loadAgentMetadata(name) {
|
|
|
2388
2221
|
completion_route: "grok_transcript",
|
|
2389
2222
|
...loadAgentLineageFields(m, true),
|
|
2390
2223
|
node_platform: process.platform,
|
|
2391
|
-
grok_home:
|
|
2224
|
+
grok_home: recordedGrokHome,
|
|
2392
2225
|
grok_auth_path: expectedAuthPath,
|
|
2393
2226
|
};
|
|
2394
2227
|
}
|
|
@@ -3953,41 +3786,6 @@ function inspectClaudeOperation(meta, operationId, action) {
|
|
|
3953
3786
|
// aiterm の永続端末に、指定モデルの対話エージェント TUI を起動する。以後は pty_read で画面を
|
|
3954
3787
|
// 読み、pty_send で操作する=aiterm の対話パラダイムそのもの。モデルはツールごとに固定し、
|
|
3955
3788
|
// reasoning effort は引数で渡す。CLI 未導入環境は明示エラー(動くフリをしない)。
|
|
3956
|
-
function resolveAgentBin(kind) {
|
|
3957
|
-
const home = process.env.HOME ?? os.homedir();
|
|
3958
|
-
const [envVar, rel, name] = kind === "claude"
|
|
3959
|
-
? ["CLAUDE_BIN", [".local", "bin", "claude"], "claude"]
|
|
3960
|
-
: kind === "codex"
|
|
3961
|
-
? ["CODEX_BIN", [".local", "bin", "codex"], "codex"]
|
|
3962
|
-
: ["GROK_BIN", [".grok", "bin", isWin ? "grok.exe" : "grok"], "grok"];
|
|
3963
|
-
const fromEnv = process.env[envVar];
|
|
3964
|
-
if (fromEnv) {
|
|
3965
|
-
// 明示指定 env は実在を検証する。存在しないパスを黙って返すと、session を作って
|
|
3966
|
-
// `'/typo' ...` を送信し bash が command not found を出すだけで openAgent は「起動した」と
|
|
3967
|
-
// 偽成功を返す(既定パス/PATH 経路は検証するのに env だけ無検証だった非対称の解消・A3)。
|
|
3968
|
-
if (isUsableAgentExecutableFile(fromEnv))
|
|
3969
|
-
return resolveWindowsCodexShim(kind, fromEnv);
|
|
3970
|
-
throw new AitermError(`${envVar} に指定された ${name} が存在しません: ${fromEnv}`, 2);
|
|
3971
|
-
}
|
|
3972
|
-
const cand = path.join(home, ...rel);
|
|
3973
|
-
if (isUsableAgentExecutableFile(cand))
|
|
3974
|
-
return resolveWindowsCodexShim(kind, cand);
|
|
3975
|
-
const w = spawnSync(isWin ? "where" : "which", [name], {
|
|
3976
|
-
encoding: "utf8",
|
|
3977
|
-
timeout: 5000,
|
|
3978
|
-
});
|
|
3979
|
-
if (w.status === 0 && (w.stdout ?? "").trim()) {
|
|
3980
|
-
const found = w.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
3981
|
-
const ordered = isWin
|
|
3982
|
-
? [...found.filter((p) => /\.(?:exe|com|cmd|bat)$/i.test(p)), ...found.filter((p) => !/\.(?:exe|com|cmd|bat)$/i.test(p))]
|
|
3983
|
-
: found;
|
|
3984
|
-
for (const resolved of ordered) {
|
|
3985
|
-
if (isUsableAgentExecutableFile(resolved))
|
|
3986
|
-
return resolveWindowsCodexShim(kind, resolved);
|
|
3987
|
-
}
|
|
3988
|
-
}
|
|
3989
|
-
return null;
|
|
3990
|
-
}
|
|
3991
3789
|
const CLAUDE_AUTH_STATUS_TIMEOUT_MS = 5_000;
|
|
3992
3790
|
function assertClaudeAuthenticationReady(bin) {
|
|
3993
3791
|
const result = spawnAgentControlCommand(bin, ["auth", "status", "--json"], process.cwd(), {
|
|
@@ -4022,117 +3820,8 @@ function assertClaudeAuthenticationReady(bin) {
|
|
|
4022
3820
|
throw new AitermError(`Claude Codeの認証状態を起動前に確認できません${timedOut ? "(5秒でtimeout)" : ""}。sessionは作成していません。` +
|
|
4023
3821
|
"`claude auth status --json` と `claude doctor` が成功することを通常端末で確認してください。", 2);
|
|
4024
3822
|
}
|
|
4025
|
-
function isUsableExecutableFile(candidate) {
|
|
4026
|
-
try {
|
|
4027
|
-
if (!fs.statSync(candidate).isFile())
|
|
4028
|
-
return false;
|
|
4029
|
-
if (isWin)
|
|
4030
|
-
return /\.(?:exe|cmd|bat|com)$/i.test(candidate);
|
|
4031
|
-
fs.accessSync(candidate, fs.constants.X_OK);
|
|
4032
|
-
return true;
|
|
4033
|
-
}
|
|
4034
|
-
catch {
|
|
4035
|
-
return false;
|
|
4036
|
-
}
|
|
4037
|
-
}
|
|
4038
|
-
function isWindowsDrivePath(candidate) {
|
|
4039
|
-
return /^[A-Za-z]:[\\/]/.test(candidate);
|
|
4040
|
-
}
|
|
4041
|
-
function isWindowsNativeExecutable(candidate) {
|
|
4042
|
-
return isWindowsDrivePath(candidate) && /\.(?:exe|com|cmd|bat)$/i.test(candidate);
|
|
4043
|
-
}
|
|
4044
|
-
// Windows の bin 受入: native 実行ファイル(.exe/.cmd/.bat)に加え、pane shell
|
|
4045
|
-
// (Git Bash)が shebang で実行できる script も実在すれば受け入れる。旧 WSL 側
|
|
4046
|
-
// バイナリ検査への黙ったフォールバックは廃止(別 HOME・別 auth の subagent を
|
|
4047
|
-
// 作るため)。native 実行ファイルの強制が要る vendor(grok/composer の実効
|
|
4048
|
-
// sandbox 等)は openAgent 側の専用ゲートが明示エラーで担う。
|
|
4049
|
-
function isUsableAgentExecutableFile(candidate) {
|
|
4050
|
-
if (!isWin)
|
|
4051
|
-
return isUsableExecutableFile(candidate);
|
|
4052
|
-
if (isWindowsNativeExecutable(candidate))
|
|
4053
|
-
return isUsableExecutableFile(candidate);
|
|
4054
|
-
try {
|
|
4055
|
-
return fs.statSync(candidate).isFile();
|
|
4056
|
-
}
|
|
4057
|
-
catch {
|
|
4058
|
-
return false;
|
|
4059
|
-
}
|
|
4060
|
-
}
|
|
4061
|
-
function resolveWindowsCodexShim(kind, candidate) {
|
|
4062
|
-
if (!isWin || kind !== "codex" || !/\.(?:cmd|bat)$/i.test(candidate))
|
|
4063
|
-
return candidate;
|
|
4064
|
-
const packageRoot = path.join(path.dirname(candidate), "node_modules", "@openai", "codex", "node_modules", "@openai");
|
|
4065
|
-
try {
|
|
4066
|
-
for (const platformPackage of fs.readdirSync(packageRoot).filter((name) => name.startsWith("codex-win32-"))) {
|
|
4067
|
-
const vendorRoot = path.join(packageRoot, platformPackage, "vendor");
|
|
4068
|
-
for (const target of fs.readdirSync(vendorRoot)) {
|
|
4069
|
-
const executable = path.join(vendorRoot, target, "bin", "codex.exe");
|
|
4070
|
-
if (isUsableExecutableFile(executable))
|
|
4071
|
-
return executable;
|
|
4072
|
-
}
|
|
4073
|
-
}
|
|
4074
|
-
}
|
|
4075
|
-
catch {
|
|
4076
|
-
/* 下の明示エラーへ */
|
|
4077
|
-
}
|
|
4078
|
-
throw new AitermError(`CODEX_BIN のnpm shimからWindows native codex.exeを解決できません: ${candidate}。` +
|
|
4079
|
-
"@openai/codexを再インストールするか、CODEX_BINへcodex.exeを指定してください", 2);
|
|
4080
|
-
}
|
|
4081
|
-
// pane 内の POSIX shell(Windows は Git Bash 等)へ渡す bin パス。Git Bash は
|
|
4082
|
-
// バックスラッシュをエスケープとして解釈しうるため、ドライブパスは forward slash 形へ。
|
|
4083
|
-
function agentBinForPaneShell(bin) {
|
|
4084
|
-
return isWin && isWindowsDrivePath(bin) ? bin.replace(/\\/g, "/") : bin;
|
|
4085
|
-
}
|
|
4086
|
-
function spawnAgentControlCommand(bin, args, _cwd, options) {
|
|
4087
|
-
// 受入(isUsableAgentExecutableFile)が「使える」と判定した bin は、control command
|
|
4088
|
-
//(`claude auth status --json`/`grok models`)でも同じく実行できなければならない。
|
|
4089
|
-
if (isWin && !/\.(?:exe|com)$/i.test(bin)) {
|
|
4090
|
-
if (/\.(?:cmd|bat)$/i.test(bin)) {
|
|
4091
|
-
// Node は CVE-2024-27980 対処以降、.cmd/.bat の直接 spawn を EINVAL で拒否する。
|
|
4092
|
-
// 受入が .cmd/.bat を許す以上、control 経路は shell 経由で実行する(args は固定語彙)。
|
|
4093
|
-
return spawnSync(bin, args, { ...options, shell: true });
|
|
4094
|
-
}
|
|
4095
|
-
// 受入は pane shell(Git Bash)が shebang で実行できる script も許す。Windows の
|
|
4096
|
-
// CreateProcess は shebang を解さないため、control command も同じ Git Bash で実行する。
|
|
4097
|
-
// これを直接 spawn すると常に失敗し、「受入が通した bin で起動が必ず失敗する」矛盾になる。
|
|
4098
|
-
return spawnSync(resolveWinPaneShell("bash"), [bin, ...args], options);
|
|
4099
|
-
}
|
|
4100
|
-
return spawnSync(bin, args, options);
|
|
4101
|
-
}
|
|
4102
3823
|
const THROUGHLINE_HANDOFF_CONTEXT_SCHEMA = "throughline.handoff_context.v1";
|
|
4103
3824
|
const PORTABLE_FORK_MISSION_SEPARATOR = "\n\n---\n\n## Portable fork mission\n\n";
|
|
4104
|
-
function resolveThroughlineBin() {
|
|
4105
|
-
const fromEnv = process.env.THROUGHLINE_BIN;
|
|
4106
|
-
if (fromEnv)
|
|
4107
|
-
return isUsableExecutableFile(fromEnv) ? fromEnv : null;
|
|
4108
|
-
const resolved = spawnSync(isWin ? "where" : "which", ["throughline"], {
|
|
4109
|
-
encoding: "utf8",
|
|
4110
|
-
}).stdout?.split(/\r?\n/).find(Boolean) ?? null;
|
|
4111
|
-
return resolved && isUsableExecutableFile(resolved) ? resolved : null;
|
|
4112
|
-
}
|
|
4113
|
-
function runThroughlineHandoffContext(bin, sessionId) {
|
|
4114
|
-
if (isWin && /\.(?:cmd|bat)$/i.test(bin)) {
|
|
4115
|
-
const ps1 = path.join(path.dirname(bin), `${path.basename(bin, path.extname(bin))}.ps1`);
|
|
4116
|
-
if (fs.existsSync(ps1)) {
|
|
4117
|
-
return spawnSync("powershell.exe", [
|
|
4118
|
-
"-NoLogo",
|
|
4119
|
-
"-NoProfile",
|
|
4120
|
-
"-NonInteractive",
|
|
4121
|
-
"-ExecutionPolicy",
|
|
4122
|
-
"Bypass",
|
|
4123
|
-
"-File",
|
|
4124
|
-
ps1,
|
|
4125
|
-
"handoff-context",
|
|
4126
|
-
"--session",
|
|
4127
|
-
sessionId,
|
|
4128
|
-
"--json",
|
|
4129
|
-
], { encoding: "utf8" });
|
|
4130
|
-
}
|
|
4131
|
-
}
|
|
4132
|
-
return spawnSync(bin, ["handoff-context", "--session", sessionId, "--json"], {
|
|
4133
|
-
encoding: "utf8",
|
|
4134
|
-
});
|
|
4135
|
-
}
|
|
4136
3825
|
export function composePortableForkPrompt(context, mission) {
|
|
4137
3826
|
return context + PORTABLE_FORK_MISSION_SEPARATOR + mission;
|
|
4138
3827
|
}
|
|
@@ -4470,7 +4159,7 @@ export function openAgent(kind, opts = {}) {
|
|
|
4470
4159
|
const lineageContext = createAgentLineageContext(kind, sid, lineageSeed);
|
|
4471
4160
|
const meta = agentDone
|
|
4472
4161
|
? kind === "claude"
|
|
4473
|
-
? createClaudeAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", launchOperationId, launchRequestDigest, lineageContext)
|
|
4162
|
+
? createClaudeAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", launchOperationId, launchRequestDigest, lineageContext, model, effort)
|
|
4474
4163
|
: kind === "codex"
|
|
4475
4164
|
? createCodexAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", { model, effort }, writeScope, lineageContext)
|
|
4476
4165
|
: createGrokAgentMetadata(kind, sid, cwd, opts.prompt ? "pending" : "none", grokAuthPath, writeScope, lineageContext)
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// aiterm のエラー基盤。AitermError と telemetry 所有の失敗経路だけを持つ末端モジュール。
|
|
2
|
+
import { recordRuntimeError } from "./runtime-error-store.js";
|
|
3
|
+
export class AitermError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(message, code = 1) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class TelemetryOwnedError extends AitermError {
|
|
11
|
+
telemetryCode;
|
|
12
|
+
constructor(message, code, telemetryCode, cause) {
|
|
13
|
+
super(message, code);
|
|
14
|
+
this.name = "TelemetryOwnedError";
|
|
15
|
+
this.telemetryCode = telemetryCode;
|
|
16
|
+
if (cause !== undefined)
|
|
17
|
+
this.cause = cause;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function telemetryOwnedFailure(telemetryCode, error, fallbackCode = 1) {
|
|
21
|
+
if (error instanceof TelemetryOwnedError)
|
|
22
|
+
return error;
|
|
23
|
+
recordRuntimeError(telemetryCode);
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
const code = error instanceof AitermError ? error.code : fallbackCode;
|
|
26
|
+
return new TelemetryOwnedError(message, code, telemetryCode, error);
|
|
27
|
+
}
|
|
28
|
+
export function ownTelemetryFailure(telemetryCode, error, fallbackCode = 1) {
|
|
29
|
+
throw telemetryOwnedFailure(telemetryCode, error, fallbackCode);
|
|
30
|
+
}
|
|
31
|
+
export function ptyDependencyError(message, observe = true) {
|
|
32
|
+
const error = new AitermError(message, 2);
|
|
33
|
+
if (observe)
|
|
34
|
+
ownTelemetryFailure("AITERM.PTY_DEPENDENCY_UNAVAILABLE", error, 2);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// runtime-error-store の OS 依存部分の所有者: パス規約(LOCALAPPDATA / XDG)、
|
|
2
|
+
// Windows DACL(PowerShell)、host profile 判定、安全な bounded read、
|
|
3
|
+
// process start identity 観測、force kill。store 本体は OS 非依存ロジックに保つ。
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
export function defaultRuntimeErrorPaths(options = {}) {
|
|
9
|
+
const platform = options.platform ?? process.platform;
|
|
10
|
+
const home = options.home ?? os.homedir();
|
|
11
|
+
if (platform === "win32") {
|
|
12
|
+
const base = options.localAppData ?? process.env.LOCALAPPDATA ?? path.win32.join(home, "AppData", "Local");
|
|
13
|
+
return {
|
|
14
|
+
configPath: path.win32.join(base, "dotagents", "factory-reporter", "config.json"),
|
|
15
|
+
storePath: path.win32.join(base, "aiterm-mcp", "runtime-errors.json"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const configHome = options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
19
|
+
const stateHome = options.xdgStateHome ?? process.env.XDG_STATE_HOME ?? path.join(home, ".local", "state");
|
|
20
|
+
return {
|
|
21
|
+
configPath: path.join(configHome, "dotagents", "factory-reporter.json"),
|
|
22
|
+
storePath: path.join(stateHome, "aiterm-mcp", "runtime-errors.json"),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const WINDOWS_DACL_VERIFY_SCRIPT = String.raw `
|
|
26
|
+
$ErrorActionPreference='Stop'
|
|
27
|
+
$target=$env:AITERMMCP_ACL_PATH; $kind=$env:AITERMMCP_ACL_KIND
|
|
28
|
+
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
29
|
+
$check=if($kind -eq 'directory'){[IO.Directory]::GetAccessControl($target)}else{[IO.File]::GetAccessControl($target)}
|
|
30
|
+
$ownerSid=$check.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
31
|
+
$rules=@($check.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]))
|
|
32
|
+
if($ownerSid -ne $sid.Value -or $rules.Count -ne 1 -or $rules[0].IdentityReference.Value -ne $sid.Value -or $rules[0].AccessControlType -ne 'Allow' -or $rules[0].IsInherited -or (($rules[0].FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne [Security.AccessControl.FileSystemRights]::FullControl)){exit 9}
|
|
33
|
+
`;
|
|
34
|
+
const WINDOWS_DACL_SCRIPT = String.raw `
|
|
35
|
+
$ErrorActionPreference='Stop'
|
|
36
|
+
$target=$env:AITERMMCP_ACL_PATH; $kind=$env:AITERMMCP_ACL_KIND
|
|
37
|
+
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
38
|
+
if($kind -eq 'directory'){$acl=New-Object Security.AccessControl.DirectorySecurity;$inherit=[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'}else{$acl=New-Object Security.AccessControl.FileSecurity;$inherit=[Security.AccessControl.InheritanceFlags]::None}
|
|
39
|
+
$acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false)
|
|
40
|
+
$rule=New-Object Security.AccessControl.FileSystemAccessRule($sid,[Security.AccessControl.FileSystemRights]::FullControl,$inherit,[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)
|
|
41
|
+
$acl.AddAccessRule($rule); if($kind -eq 'directory'){[IO.Directory]::SetAccessControl($target,$acl)}else{[IO.File]::SetAccessControl($target,$acl)}
|
|
42
|
+
` + WINDOWS_DACL_VERIFY_SCRIPT;
|
|
43
|
+
export function windowsPrivateDaclCommand(target, kind = "directory") {
|
|
44
|
+
return {
|
|
45
|
+
command: "powershell.exe",
|
|
46
|
+
args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", WINDOWS_DACL_SCRIPT],
|
|
47
|
+
env: { ...process.env, AITERMMCP_ACL_PATH: target, AITERMMCP_ACL_KIND: kind },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function windowsPrivateDaclVerifyCommand(target, kind = "file") {
|
|
51
|
+
return {
|
|
52
|
+
command: "powershell.exe",
|
|
53
|
+
args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", WINDOWS_DACL_VERIFY_SCRIPT],
|
|
54
|
+
env: { ...process.env, AITERMMCP_ACL_PATH: target, AITERMMCP_ACL_KIND: kind },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function expectedHostProfile(platform) {
|
|
58
|
+
if (platform === "darwin")
|
|
59
|
+
return "mac";
|
|
60
|
+
if (platform === "win32")
|
|
61
|
+
return "windows-native";
|
|
62
|
+
return process.env.WSL_DISTRO_NAME || /microsoft/i.test(os.release()) ? "wsl" : "server";
|
|
63
|
+
}
|
|
64
|
+
function assertPrivatePosixStat(info, expectedMode, label) {
|
|
65
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)
|
|
66
|
+
throw new Error(`${label} が安全な regular file ではありません`);
|
|
67
|
+
if (typeof process.getuid !== "function" || info.uid !== process.getuid())
|
|
68
|
+
throw new Error(`${label} owner が不正です`);
|
|
69
|
+
if ((info.mode & 0o777) !== expectedMode)
|
|
70
|
+
throw new Error(`${label} mode が不正です`);
|
|
71
|
+
}
|
|
72
|
+
export function readBoundedFile(file, maxBytes, platform, requirePrivate) {
|
|
73
|
+
if (platform === "win32") {
|
|
74
|
+
const before = fs.lstatSync(file);
|
|
75
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes)
|
|
76
|
+
throw new Error("file shape/size が不正です");
|
|
77
|
+
if (requirePrivate) {
|
|
78
|
+
const command = windowsPrivateDaclVerifyCommand(file);
|
|
79
|
+
const verified = spawnSync(command.command, command.args, { encoding: "utf8", windowsHide: true, timeout: 5000, maxBuffer: 16 * 1024, env: command.env });
|
|
80
|
+
if (verified.error || verified.status !== 0)
|
|
81
|
+
throw new Error("file DACL が不正です");
|
|
82
|
+
}
|
|
83
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
|
84
|
+
try {
|
|
85
|
+
const after = fs.fstatSync(fd);
|
|
86
|
+
if (before.dev !== after.dev || before.ino !== after.ino || after.size > maxBytes)
|
|
87
|
+
throw new Error("file が read 中に置換されました");
|
|
88
|
+
return fs.readFileSync(fd, "utf8");
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
fs.closeSync(fd);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const before = fs.lstatSync(file);
|
|
95
|
+
if (requirePrivate)
|
|
96
|
+
assertPrivatePosixStat(before, 0o600, "runtime store/config");
|
|
97
|
+
else if (!before.isFile() || before.isSymbolicLink())
|
|
98
|
+
throw new Error("file が regular ではありません");
|
|
99
|
+
if (before.size > maxBytes)
|
|
100
|
+
throw new Error("file が大きすぎます");
|
|
101
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
102
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | noFollow);
|
|
103
|
+
try {
|
|
104
|
+
const after = fs.fstatSync(fd);
|
|
105
|
+
if (before.dev !== after.dev || before.ino !== after.ino)
|
|
106
|
+
throw new Error("file が read 中に置換されました");
|
|
107
|
+
if (requirePrivate)
|
|
108
|
+
assertPrivatePosixStat(after, 0o600, "runtime store/config");
|
|
109
|
+
return fs.readFileSync(fd, "utf8");
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
fs.closeSync(fd);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function processStartIdentity(pid, platform, timeoutMs = 1000) {
|
|
116
|
+
if (!Number.isInteger(pid) || pid < 1)
|
|
117
|
+
return null;
|
|
118
|
+
if (platform === "linux") {
|
|
119
|
+
try {
|
|
120
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
121
|
+
const close = stat.lastIndexOf(")");
|
|
122
|
+
const fields = stat.slice(close + 2).split(" ");
|
|
123
|
+
return fields[19] ? `linux:${fields[19]}` : null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (platform === "win32") {
|
|
130
|
+
const script = "$p=Get-Process -Id $env:AITERMMCP_PROCESS_ID -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks";
|
|
131
|
+
const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
|
|
132
|
+
encoding: "utf8", timeout: timeoutMs, maxBuffer: 4096, windowsHide: true,
|
|
133
|
+
env: { ...process.env, AITERMMCP_PROCESS_ID: String(pid) },
|
|
134
|
+
});
|
|
135
|
+
const value = result.status === 0 ? (result.stdout ?? "").trim() : "";
|
|
136
|
+
return /^\d+$/.test(value) ? `win32:${value}` : null;
|
|
137
|
+
}
|
|
138
|
+
// lstart の日付書式は LC_TIME で変わり、locale の違う観測者間で identity が割れる
|
|
139
|
+
// (caveat: ps-locale-lstart-lc-time-ascii-argv-lc-ctype-digest。Lattice 0.63.4 と同じ固定)
|
|
140
|
+
const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
141
|
+
encoding: "utf8", timeout: 1000, maxBuffer: 4096, env: { ...process.env, LC_ALL: "C" },
|
|
142
|
+
});
|
|
143
|
+
const value = result.status === 0 ? (result.stdout ?? "").trim() : "";
|
|
144
|
+
return value ? `${platform}:${value}` : null;
|
|
145
|
+
}
|
|
146
|
+
export function forceKill(child) {
|
|
147
|
+
if (!Number.isSafeInteger(child.pid) || !child.pid)
|
|
148
|
+
return;
|
|
149
|
+
if (process.platform !== "win32") {
|
|
150
|
+
try {
|
|
151
|
+
child.kill("SIGKILL");
|
|
152
|
+
}
|
|
153
|
+
catch { /* already exited */ }
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// taskkill 自体の起動が混雑した Windows runner で遅れても、deadline を越えた
|
|
157
|
+
// worker 本体の副作用を許さない。まず Node のハンドルから即時停止し、その後に
|
|
158
|
+
// taskkill /T で worker が残した子孫だけを回収する。
|
|
159
|
+
try {
|
|
160
|
+
child.kill("SIGKILL");
|
|
161
|
+
}
|
|
162
|
+
catch { /* already exited */ }
|
|
163
|
+
const killer = spawn("taskkill.exe", ["/pid", String(child.pid), "/T", "/F"], {
|
|
164
|
+
stdio: "ignore", windowsHide: true,
|
|
165
|
+
});
|
|
166
|
+
killer.once("error", () => { });
|
|
167
|
+
killer.unref();
|
|
168
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
|
-
import * as os from "node:os";
|
|
5
4
|
import * as path from "node:path";
|
|
6
5
|
import { createRequire } from "node:module";
|
|
6
|
+
import { defaultRuntimeErrorPaths, windowsPrivateDaclCommand, expectedHostProfile, readBoundedFile, processStartIdentity, forceKill } from "./runtime-error-os.js";
|
|
7
|
+
export { defaultRuntimeErrorPaths, windowsPrivateDaclCommand, windowsPrivateDaclVerifyCommand } from "./runtime-error-os.js";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
9
10
|
const STORE_SCHEMA = "aiterm-mcp.runtime-errors.v1";
|
|
@@ -56,113 +57,6 @@ export function validateRuntimeObservation(value) {
|
|
|
56
57
|
}
|
|
57
58
|
return { code: value.code };
|
|
58
59
|
}
|
|
59
|
-
export function defaultRuntimeErrorPaths(options = {}) {
|
|
60
|
-
const platform = options.platform ?? process.platform;
|
|
61
|
-
const home = options.home ?? os.homedir();
|
|
62
|
-
if (platform === "win32") {
|
|
63
|
-
const base = options.localAppData ?? process.env.LOCALAPPDATA ?? path.win32.join(home, "AppData", "Local");
|
|
64
|
-
return {
|
|
65
|
-
configPath: path.win32.join(base, "dotagents", "factory-reporter", "config.json"),
|
|
66
|
-
storePath: path.win32.join(base, "aiterm-mcp", "runtime-errors.json"),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
const configHome = options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
70
|
-
const stateHome = options.xdgStateHome ?? process.env.XDG_STATE_HOME ?? path.join(home, ".local", "state");
|
|
71
|
-
return {
|
|
72
|
-
configPath: path.join(configHome, "dotagents", "factory-reporter.json"),
|
|
73
|
-
storePath: path.join(stateHome, "aiterm-mcp", "runtime-errors.json"),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
const WINDOWS_DACL_VERIFY_SCRIPT = String.raw `
|
|
77
|
-
$ErrorActionPreference='Stop'
|
|
78
|
-
$target=$env:AITERMMCP_ACL_PATH; $kind=$env:AITERMMCP_ACL_KIND
|
|
79
|
-
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
80
|
-
$check=if($kind -eq 'directory'){[IO.Directory]::GetAccessControl($target)}else{[IO.File]::GetAccessControl($target)}
|
|
81
|
-
$ownerSid=$check.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
82
|
-
$rules=@($check.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]))
|
|
83
|
-
if($ownerSid -ne $sid.Value -or $rules.Count -ne 1 -or $rules[0].IdentityReference.Value -ne $sid.Value -or $rules[0].AccessControlType -ne 'Allow' -or $rules[0].IsInherited -or (($rules[0].FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne [Security.AccessControl.FileSystemRights]::FullControl)){exit 9}
|
|
84
|
-
`;
|
|
85
|
-
const WINDOWS_DACL_SCRIPT = String.raw `
|
|
86
|
-
$ErrorActionPreference='Stop'
|
|
87
|
-
$target=$env:AITERMMCP_ACL_PATH; $kind=$env:AITERMMCP_ACL_KIND
|
|
88
|
-
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
89
|
-
if($kind -eq 'directory'){$acl=New-Object Security.AccessControl.DirectorySecurity;$inherit=[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'}else{$acl=New-Object Security.AccessControl.FileSecurity;$inherit=[Security.AccessControl.InheritanceFlags]::None}
|
|
90
|
-
$acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false)
|
|
91
|
-
$rule=New-Object Security.AccessControl.FileSystemAccessRule($sid,[Security.AccessControl.FileSystemRights]::FullControl,$inherit,[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)
|
|
92
|
-
$acl.AddAccessRule($rule); if($kind -eq 'directory'){[IO.Directory]::SetAccessControl($target,$acl)}else{[IO.File]::SetAccessControl($target,$acl)}
|
|
93
|
-
` + WINDOWS_DACL_VERIFY_SCRIPT;
|
|
94
|
-
export function windowsPrivateDaclCommand(target, kind = "directory") {
|
|
95
|
-
return {
|
|
96
|
-
command: "powershell.exe",
|
|
97
|
-
args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", WINDOWS_DACL_SCRIPT],
|
|
98
|
-
env: { ...process.env, AITERMMCP_ACL_PATH: target, AITERMMCP_ACL_KIND: kind },
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
export function windowsPrivateDaclVerifyCommand(target, kind = "file") {
|
|
102
|
-
return {
|
|
103
|
-
command: "powershell.exe",
|
|
104
|
-
args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", WINDOWS_DACL_VERIFY_SCRIPT],
|
|
105
|
-
env: { ...process.env, AITERMMCP_ACL_PATH: target, AITERMMCP_ACL_KIND: kind },
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
function expectedHostProfile(platform) {
|
|
109
|
-
if (platform === "darwin")
|
|
110
|
-
return "mac";
|
|
111
|
-
if (platform === "win32")
|
|
112
|
-
return "windows-native";
|
|
113
|
-
return process.env.WSL_DISTRO_NAME || /microsoft/i.test(os.release()) ? "wsl" : "server";
|
|
114
|
-
}
|
|
115
|
-
function assertPrivatePosixStat(info, expectedMode, label) {
|
|
116
|
-
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)
|
|
117
|
-
throw new Error(`${label} が安全な regular file ではありません`);
|
|
118
|
-
if (typeof process.getuid !== "function" || info.uid !== process.getuid())
|
|
119
|
-
throw new Error(`${label} owner が不正です`);
|
|
120
|
-
if ((info.mode & 0o777) !== expectedMode)
|
|
121
|
-
throw new Error(`${label} mode が不正です`);
|
|
122
|
-
}
|
|
123
|
-
function readBoundedFile(file, maxBytes, platform, requirePrivate) {
|
|
124
|
-
if (platform === "win32") {
|
|
125
|
-
const before = fs.lstatSync(file);
|
|
126
|
-
if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes)
|
|
127
|
-
throw new Error("file shape/size が不正です");
|
|
128
|
-
if (requirePrivate) {
|
|
129
|
-
const command = windowsPrivateDaclVerifyCommand(file);
|
|
130
|
-
const verified = spawnSync(command.command, command.args, { encoding: "utf8", windowsHide: true, timeout: 5000, maxBuffer: 16 * 1024, env: command.env });
|
|
131
|
-
if (verified.error || verified.status !== 0)
|
|
132
|
-
throw new Error("file DACL が不正です");
|
|
133
|
-
}
|
|
134
|
-
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
|
135
|
-
try {
|
|
136
|
-
const after = fs.fstatSync(fd);
|
|
137
|
-
if (before.dev !== after.dev || before.ino !== after.ino || after.size > maxBytes)
|
|
138
|
-
throw new Error("file が read 中に置換されました");
|
|
139
|
-
return fs.readFileSync(fd, "utf8");
|
|
140
|
-
}
|
|
141
|
-
finally {
|
|
142
|
-
fs.closeSync(fd);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
const before = fs.lstatSync(file);
|
|
146
|
-
if (requirePrivate)
|
|
147
|
-
assertPrivatePosixStat(before, 0o600, "runtime store/config");
|
|
148
|
-
else if (!before.isFile() || before.isSymbolicLink())
|
|
149
|
-
throw new Error("file が regular ではありません");
|
|
150
|
-
if (before.size > maxBytes)
|
|
151
|
-
throw new Error("file が大きすぎます");
|
|
152
|
-
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
153
|
-
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | noFollow);
|
|
154
|
-
try {
|
|
155
|
-
const after = fs.fstatSync(fd);
|
|
156
|
-
if (before.dev !== after.dev || before.ino !== after.ino)
|
|
157
|
-
throw new Error("file が read 中に置換されました");
|
|
158
|
-
if (requirePrivate)
|
|
159
|
-
assertPrivatePosixStat(after, 0o600, "runtime store/config");
|
|
160
|
-
return fs.readFileSync(fd, "utf8");
|
|
161
|
-
}
|
|
162
|
-
finally {
|
|
163
|
-
fs.closeSync(fd);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
60
|
function validateCanonicalConfig(config, platform) {
|
|
167
61
|
if (!isObject(config) || !exactKeys(config, ["schema_version", "host", "collection", "reporting"]))
|
|
168
62
|
return false;
|
|
@@ -281,33 +175,6 @@ function validateState(value, maxRecords = 256) {
|
|
|
281
175
|
records: typed.map((record) => projectRecord(record)),
|
|
282
176
|
};
|
|
283
177
|
}
|
|
284
|
-
function processStartIdentity(pid, platform, timeoutMs = 1000) {
|
|
285
|
-
if (!Number.isInteger(pid) || pid < 1)
|
|
286
|
-
return null;
|
|
287
|
-
if (platform === "linux") {
|
|
288
|
-
try {
|
|
289
|
-
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
290
|
-
const close = stat.lastIndexOf(")");
|
|
291
|
-
const fields = stat.slice(close + 2).split(" ");
|
|
292
|
-
return fields[19] ? `linux:${fields[19]}` : null;
|
|
293
|
-
}
|
|
294
|
-
catch {
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
if (platform === "win32") {
|
|
299
|
-
const script = "$p=Get-Process -Id $env:AITERMMCP_PROCESS_ID -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks";
|
|
300
|
-
const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
|
|
301
|
-
encoding: "utf8", timeout: timeoutMs, maxBuffer: 4096, windowsHide: true,
|
|
302
|
-
env: { ...process.env, AITERMMCP_PROCESS_ID: String(pid) },
|
|
303
|
-
});
|
|
304
|
-
const value = result.status === 0 ? (result.stdout ?? "").trim() : "";
|
|
305
|
-
return /^\d+$/.test(value) ? `win32:${value}` : null;
|
|
306
|
-
}
|
|
307
|
-
const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1000, maxBuffer: 4096 });
|
|
308
|
-
const value = result.status === 0 ? (result.stdout ?? "").trim() : "";
|
|
309
|
-
return value ? `${platform}:${value}` : null;
|
|
310
|
-
}
|
|
311
178
|
export class RuntimeErrorStore {
|
|
312
179
|
configPath;
|
|
313
180
|
storePath;
|
|
@@ -840,29 +707,6 @@ export async function runtimeErrorStoreDiagnostic(options = {}) {
|
|
|
840
707
|
});
|
|
841
708
|
});
|
|
842
709
|
}
|
|
843
|
-
function forceKill(child) {
|
|
844
|
-
if (!Number.isSafeInteger(child.pid) || !child.pid)
|
|
845
|
-
return;
|
|
846
|
-
if (process.platform !== "win32") {
|
|
847
|
-
try {
|
|
848
|
-
child.kill("SIGKILL");
|
|
849
|
-
}
|
|
850
|
-
catch { /* already exited */ }
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
// taskkill 自体の起動が混雑した Windows runner で遅れても、deadline を越えた
|
|
854
|
-
// worker 本体の副作用を許さない。まず Node のハンドルから即時停止し、その後に
|
|
855
|
-
// taskkill /T で worker が残した子孫だけを回収する。
|
|
856
|
-
try {
|
|
857
|
-
child.kill("SIGKILL");
|
|
858
|
-
}
|
|
859
|
-
catch { /* already exited */ }
|
|
860
|
-
const killer = spawn("taskkill.exe", ["/pid", String(child.pid), "/T", "/F"], {
|
|
861
|
-
stdio: "ignore", windowsHide: true,
|
|
862
|
-
});
|
|
863
|
-
killer.once("error", () => { });
|
|
864
|
-
killer.unref();
|
|
865
|
-
}
|
|
866
710
|
function processExists(pid) {
|
|
867
711
|
try {
|
|
868
712
|
process.kill(pid, 0);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// 端末多重化 runtime の所有者。tmux(POSIX)/ psmux(Windows native)をどう見つけ、
|
|
2
|
+
// どの socket / namespace で、どの locale で叩くかはこのモジュールだけが知る。
|
|
3
|
+
// OS 分岐(isWin)と観測・起動規約の正本。
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { AitermError, ptyDependencyError } from "./errors.js";
|
|
10
|
+
// Windows ネイティブには tmux が無いため、tmux CLI 互換の native psmux を叩く
|
|
11
|
+
// (POSIX = Linux/WSL2/macOS は従来どおり tmux を直接叩く。WSL 橋は e3f5fc8 で全廃)。
|
|
12
|
+
export const isWin = process.platform === "win32";
|
|
13
|
+
// .log/.offset/.lastcmd を置くディレクトリ(Node が直接読み書きする)。
|
|
14
|
+
// POSIX は従来どおり。Windows は TMPDIR→TEMP→os.tmpdir() の順で Windows 側の一時領域に置く。
|
|
15
|
+
export const SOCKDIR = path.join(process.env.TMPDIR ?? (isWin ? process.env.TEMP ?? os.tmpdir() : "/tmp"), "claude-tmux-sockets");
|
|
16
|
+
// tmux -S に渡すソケットパス(POSIX はログと同じツリーに置く)。
|
|
17
|
+
// Windows は native psmux を使い、-S でなく -L server namespace で隔離する
|
|
18
|
+
// (psmux の -S は黙って既定 namespace へ落ちるため使わない・実測 2026-08-16)。
|
|
19
|
+
// SOCKDIR から短い安定名を導出し、TMPDIR ごとの隔離(テスト)と再起動跨ぎ再接続を両立する。
|
|
20
|
+
export const SOCK = path.join(SOCKDIR, "claude.sock");
|
|
21
|
+
export const WIN_NS = `aiterm-${createHash("sha1").update(SOCKDIR).digest("hex").slice(0, 12)}`;
|
|
22
|
+
// Windows で最初の呼び出し前に一度だけ native psmux の可用性を確かめ、失敗は原因別に投げる。
|
|
23
|
+
// psmux は tmux CLI 互換の Windows ネイティブ実装(ConPTY・WSL 不要)。AITERM_PSMUX で
|
|
24
|
+
// バイナリを明示上書きできる(POSIX の AITERM_TMUX に対応)。
|
|
25
|
+
function psmuxBin() {
|
|
26
|
+
return process.env.AITERM_PSMUX || "psmux";
|
|
27
|
+
}
|
|
28
|
+
let winPsmuxOk = false;
|
|
29
|
+
function ensureWinPsmux(observe = true) {
|
|
30
|
+
if (winPsmuxOk)
|
|
31
|
+
return;
|
|
32
|
+
const r = spawnSync(psmuxBin(), ["-V"], { encoding: "utf8", timeout: 10000 });
|
|
33
|
+
if (r.error) {
|
|
34
|
+
const code = r.error.code;
|
|
35
|
+
if (code === "ENOENT")
|
|
36
|
+
ptyDependencyError("psmux が見つかりません。Windows ネイティブでは psmux が必要です(導入例: winget install marlocarlo.psmux)。既存の psmux を使う場合は AITERM_PSMUX でパスを指定してください。", observe);
|
|
37
|
+
ptyDependencyError(`psmux を起動できませんでした(${code ?? "unknown"})。`, observe);
|
|
38
|
+
}
|
|
39
|
+
if (r.status !== 0)
|
|
40
|
+
ptyDependencyError("psmux -V が失敗しました。`psmux -V` が通るか確認してください。", observe);
|
|
41
|
+
winPsmuxOk = true;
|
|
42
|
+
}
|
|
43
|
+
// tmux が見つからないときの説明。macOS は tmux を同梱せず、Homebrew の bin は GUI 起動時の PATH に
|
|
44
|
+
// 入らないため、原因と対処(導入・自動探索・AITERM_TMUX 上書き)を正直に提示する。
|
|
45
|
+
function tmuxMissingMessage() {
|
|
46
|
+
const head = "tmux が見つかりません(PATH 上に存在しません)。aiterm は実行時に tmux を必要とします。";
|
|
47
|
+
const hint = process.platform === "darwin"
|
|
48
|
+
? "macOS では `brew install tmux` で導入してください。GUI から起動された場合、Homebrew の bin " +
|
|
49
|
+
"(Apple Silicon: /opt/homebrew/bin、Intel: /usr/local/bin)が PATH に含まれないことがあります" +
|
|
50
|
+
"(その場合 aiterm が自動で探索します)。"
|
|
51
|
+
: "(例: Debian/Ubuntu は `sudo apt install tmux`)。";
|
|
52
|
+
const override = "別の場所にある tmux を使うには AITERM_TMUX=/path/to/tmux を指定してください。";
|
|
53
|
+
return `${head}${hint}${override}`;
|
|
54
|
+
}
|
|
55
|
+
// POSIX(Linux/WSL2/macOS) 用の tmux バイナリ解決。Windows の ensureWinBridge() に対応する事前確認。
|
|
56
|
+
// 解決順: AITERM_TMUX(明示指定)→ PATH 上の tmux → Homebrew 既定パス。一度だけ実行しキャッシュする。
|
|
57
|
+
// 見つからなければ tmuxMissingMessage で投げる(黙ってフォールバックせず、原因が見えるようにする)。
|
|
58
|
+
let tmuxBin = null;
|
|
59
|
+
export function resolveTmux(observe = true) {
|
|
60
|
+
if (tmuxBin)
|
|
61
|
+
return tmuxBin;
|
|
62
|
+
const override = process.env.AITERM_TMUX;
|
|
63
|
+
if (override) {
|
|
64
|
+
const r = spawnSync(override, ["-V"], { encoding: "utf8", timeout: 5000 });
|
|
65
|
+
if (!r.error && r.status === 0)
|
|
66
|
+
return (tmuxBin = override);
|
|
67
|
+
ptyDependencyError(`AITERM_TMUX に指定された tmux を起動できません: ${override}(\`${override} -V\` が通りません)`, observe);
|
|
68
|
+
}
|
|
69
|
+
// CLI/開発時は PATH 上の tmux をそのまま使う(最優先)。
|
|
70
|
+
const onPath = spawnSync("tmux", ["-V"], { encoding: "utf8", timeout: 5000 });
|
|
71
|
+
if (!onPath.error && onPath.status === 0)
|
|
72
|
+
return (tmuxBin = "tmux");
|
|
73
|
+
// GUI 起動などで PATH に Homebrew の bin が無い場合、既定の場所を探す。
|
|
74
|
+
// 使う場合は黙らず stderr へ告知する(stdout は JSON-RPC 専用=index.ts の制約)。
|
|
75
|
+
for (const cand of ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux"]) {
|
|
76
|
+
try {
|
|
77
|
+
fs.accessSync(cand, fs.constants.X_OK);
|
|
78
|
+
console.error(`aiterm: tmux が PATH 上に無いため ${cand} を使用します`);
|
|
79
|
+
return (tmuxBin = cand);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* 次の候補へ */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
ptyDependencyError(tmuxMissingMessage(), observe);
|
|
86
|
+
}
|
|
87
|
+
// tmux は locale が C/POSIX/未設定だと UTF-8 を扱えない: server は send-keys/paste のマルチバイト
|
|
88
|
+
// 入力を破壊し(文字消失・周辺バイトの並べ替えを実測)、client は format 出力のタブ等を "_" へ
|
|
89
|
+
// サニタイズする。GUI 起動の MCP client は LANG を持たないことが多く、その環境で立った tmux server は
|
|
90
|
+
// 以後すべての入力を壊すため、UTF-8 locale が確定しない場合だけ LC_CTYPE を明示注入する。
|
|
91
|
+
// 利用者が C/POSIX 以外を明示設定している場合はその選択を尊重して触らない。
|
|
92
|
+
export function tmuxSpawnEnv() {
|
|
93
|
+
const effective = process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "";
|
|
94
|
+
// 素の "C"/"POSIX" だけを壊れた既定とみなす。"C.UTF-8" を含む charset 付きの明示設定は尊重する。
|
|
95
|
+
if (effective && !/^(C|POSIX)$/i.test(effective))
|
|
96
|
+
return undefined;
|
|
97
|
+
const env = { ...process.env, LC_CTYPE: process.platform === "darwin" ? "UTF-8" : "C.UTF-8" };
|
|
98
|
+
// LC_ALL は LC_CTYPE より優先されるため、C/POSIX の LC_ALL が残ると注入が無効になる
|
|
99
|
+
delete env.LC_ALL;
|
|
100
|
+
return env;
|
|
101
|
+
}
|
|
102
|
+
export function tmuxCommandWithInput(observe, input, ...args) {
|
|
103
|
+
// maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)や多セッションの list-sessions で
|
|
104
|
+
// 頭打ちになり stdout が切れる/空になる。Python の subprocess.run は無制限だったので 64MiB へ広げる。
|
|
105
|
+
// Windows は tmux CLI 互換の native psmux を -L namespace 隔離で叩く(WSL 非依存)。
|
|
106
|
+
let r;
|
|
107
|
+
const spawnOpts = { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, input, env: tmuxSpawnEnv() };
|
|
108
|
+
if (isWin) {
|
|
109
|
+
ensureWinPsmux(observe);
|
|
110
|
+
r = spawnSync(psmuxBin(), ["-L", WIN_NS, ...args], spawnOpts);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
// resolveTmux() は tmux を解決できなければ明確な AitermError を投げる(POSIX 版の事前確認)。
|
|
114
|
+
r = spawnSync(resolveTmux(observe), ["-S", SOCK, ...args], spawnOpts);
|
|
115
|
+
}
|
|
116
|
+
// ENOBUFS(出力が 64MiB 超)を「code=1 の失敗」へ握り潰すと部分/空 stdout を正常扱いしてしまう。区別して投げる。
|
|
117
|
+
// EXPECTED-FAILURE: 外部システム境界(tmux 出力過大)
|
|
118
|
+
if (r.error && r.error.code === "ENOBUFS") {
|
|
119
|
+
throw new AitermError(`tmux 出力が 64MiB を超えました(${args[0]})。範囲を絞って読んでください。`, 2);
|
|
120
|
+
}
|
|
121
|
+
// 解決済み tmux が実行時に消えた(アンインストール等)場合の ENOENT を、空 stderr の code=1 へ握り潰さない。
|
|
122
|
+
// 通常は resolveTmux() が事前に弾くため発火しないが、mid-run 消滅に対する正直な防御。
|
|
123
|
+
if (!isWin && r.error && r.error.code === "ENOENT") {
|
|
124
|
+
tmuxBin = null; // 次回 resolveTmux で再解決を許す
|
|
125
|
+
ptyDependencyError(tmuxMissingMessage(), observe);
|
|
126
|
+
}
|
|
127
|
+
return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
128
|
+
}
|
|
129
|
+
export function tmuxCommand(observe, ...args) {
|
|
130
|
+
return tmuxCommandWithInput(observe, undefined, ...args);
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiterm-mcp",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.3",
|
|
4
4
|
"mcpName": "io.github.kitepon/aiterm-mcp",
|
|
5
5
|
"description": "Persistent tmux terminal MCP for launching and driving Claude, Codex, Grok, or Composer from any MCP client, cross-vendor or same-vendor. Also runs durable PTY sessions for SSH, containers, and REPLs.",
|
|
6
6
|
"keywords": [
|