aiterm-mcp 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,836 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { createRequire } from "node:module";
7
+ import { fileURLToPath } from "node:url";
8
+ const pkg = createRequire(import.meta.url)("../package.json");
9
+ const STORE_SCHEMA = "aiterm-mcp.runtime-errors.v1";
10
+ const STATE_SCHEMA = "1.0";
11
+ const MAX_CONFIG_BYTES = 16 * 1024;
12
+ const MAX_STORE_BYTES = 1024 * 1024;
13
+ const WORKER_TIMEOUT_MS = 2_000;
14
+ export const RUNTIME_ERROR_DEFINITIONS = Object.freeze({
15
+ "AITERM.PTY_DEPENDENCY_UNAVAILABLE": Object.freeze({
16
+ component: "pty-dependency", message_template: "PTY dependency is unavailable", severity: "high",
17
+ }),
18
+ "AITERM.PERSISTENCE_WRITE_FAILED": Object.freeze({
19
+ component: "persistence", message_template: "PTY persistence operation failed", severity: "high",
20
+ }),
21
+ "AITERM.VENDOR_LAUNCHER_FAILED": Object.freeze({
22
+ component: "vendor-launcher", message_template: "Optional vendor launcher failed", severity: "warn",
23
+ }),
24
+ });
25
+ const TOP_KEYS = ["schema_version", "cursor", "acknowledged_cursor", "records"];
26
+ const RECORD_KEYS = [
27
+ "product", "product_version", "component", "error_code", "message_template", "severity", "fingerprint",
28
+ "occurrence_count", "first_seen", "last_seen", "state_schema_version", "os", "arch", "status",
29
+ "resolved_at", "reason_code", "sequence",
30
+ ];
31
+ const DIAGNOSTIC_KEYS = ["status", "collection", "record_count", "unacknowledged_count"];
32
+ const ARCHES = new Set(["x64", "arm64", "arm", "ia32"]);
33
+ const EMPTY_STATE = () => ({ schema_version: STORE_SCHEMA, cursor: 0, acknowledged_cursor: 0, records: [] });
34
+ function isObject(value) {
35
+ return value !== null && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+ function exactKeys(value, keys) {
38
+ const actual = Object.keys(value);
39
+ return actual.length === keys.length && actual.every((key) => keys.includes(key));
40
+ }
41
+ function validUtc(value) {
42
+ return typeof value === "string" && value.endsWith("Z") && Number.isFinite(Date.parse(value));
43
+ }
44
+ export function validateRuntimeObservation(value) {
45
+ if (!isObject(value))
46
+ throw new Error("runtime observation は object 必須です");
47
+ if (!exactKeys(value, ["code"]))
48
+ throw new Error("runtime observation に allowlist 外 field があります");
49
+ if (typeof value.code !== "string" || !(value.code in RUNTIME_ERROR_DEFINITIONS)) {
50
+ throw new Error("runtime observation code は allowlist 外です");
51
+ }
52
+ return { code: value.code };
53
+ }
54
+ export function defaultRuntimeErrorPaths(options = {}) {
55
+ const platform = options.platform ?? process.platform;
56
+ const home = options.home ?? os.homedir();
57
+ if (platform === "win32") {
58
+ const base = options.localAppData ?? process.env.LOCALAPPDATA ?? path.win32.join(home, "AppData", "Local");
59
+ return {
60
+ configPath: path.win32.join(base, "dotagents", "factory-reporter", "config.json"),
61
+ storePath: path.win32.join(base, "aiterm-mcp", "runtime-errors.json"),
62
+ };
63
+ }
64
+ const configHome = options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
65
+ const stateHome = options.xdgStateHome ?? process.env.XDG_STATE_HOME ?? path.join(home, ".local", "state");
66
+ return {
67
+ configPath: path.join(configHome, "dotagents", "factory-reporter.json"),
68
+ storePath: path.join(stateHome, "aiterm-mcp", "runtime-errors.json"),
69
+ };
70
+ }
71
+ const WINDOWS_DACL_VERIFY_SCRIPT = String.raw `
72
+ $ErrorActionPreference='Stop'
73
+ $target=$env:AITERMMCP_ACL_PATH; $kind=$env:AITERMMCP_ACL_KIND
74
+ $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
75
+ $check=if($kind -eq 'directory'){[IO.Directory]::GetAccessControl($target)}else{[IO.File]::GetAccessControl($target)}
76
+ $ownerSid=$check.GetOwner([Security.Principal.SecurityIdentifier]).Value
77
+ $rules=@($check.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]))
78
+ 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}
79
+ `;
80
+ const WINDOWS_DACL_SCRIPT = String.raw `
81
+ $ErrorActionPreference='Stop'
82
+ $target=$env:AITERMMCP_ACL_PATH; $kind=$env:AITERMMCP_ACL_KIND
83
+ $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
84
+ 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}
85
+ $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false)
86
+ $rule=New-Object Security.AccessControl.FileSystemAccessRule($sid,[Security.AccessControl.FileSystemRights]::FullControl,$inherit,[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)
87
+ $acl.AddAccessRule($rule); if($kind -eq 'directory'){[IO.Directory]::SetAccessControl($target,$acl)}else{[IO.File]::SetAccessControl($target,$acl)}
88
+ ` + WINDOWS_DACL_VERIFY_SCRIPT;
89
+ export function windowsPrivateDaclCommand(target, kind = "directory") {
90
+ return {
91
+ command: "powershell.exe",
92
+ args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", WINDOWS_DACL_SCRIPT],
93
+ env: { ...process.env, AITERMMCP_ACL_PATH: target, AITERMMCP_ACL_KIND: kind },
94
+ };
95
+ }
96
+ export function windowsPrivateDaclVerifyCommand(target, kind = "file") {
97
+ return {
98
+ command: "powershell.exe",
99
+ args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", WINDOWS_DACL_VERIFY_SCRIPT],
100
+ env: { ...process.env, AITERMMCP_ACL_PATH: target, AITERMMCP_ACL_KIND: kind },
101
+ };
102
+ }
103
+ function expectedHostProfile(platform) {
104
+ if (platform === "darwin")
105
+ return "mac";
106
+ if (platform === "win32")
107
+ return "windows-native";
108
+ return process.env.WSL_DISTRO_NAME || /microsoft/i.test(os.release()) ? "wsl" : "server";
109
+ }
110
+ function assertPrivatePosixStat(info, expectedMode, label) {
111
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)
112
+ throw new Error(`${label} が安全な regular file ではありません`);
113
+ if (typeof process.getuid !== "function" || info.uid !== process.getuid())
114
+ throw new Error(`${label} owner が不正です`);
115
+ if ((info.mode & 0o777) !== expectedMode)
116
+ throw new Error(`${label} mode が不正です`);
117
+ }
118
+ function readBoundedFile(file, maxBytes, platform, requirePrivate) {
119
+ if (platform === "win32") {
120
+ const before = fs.lstatSync(file);
121
+ if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes)
122
+ throw new Error("file shape/size が不正です");
123
+ if (requirePrivate) {
124
+ const command = windowsPrivateDaclVerifyCommand(file);
125
+ const verified = spawnSync(command.command, command.args, { encoding: "utf8", windowsHide: true, timeout: 5000, maxBuffer: 16 * 1024, env: command.env });
126
+ if (verified.error || verified.status !== 0)
127
+ throw new Error("file DACL が不正です");
128
+ }
129
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
130
+ try {
131
+ const after = fs.fstatSync(fd);
132
+ if (before.dev !== after.dev || before.ino !== after.ino || after.size > maxBytes)
133
+ throw new Error("file が read 中に置換されました");
134
+ return fs.readFileSync(fd, "utf8");
135
+ }
136
+ finally {
137
+ fs.closeSync(fd);
138
+ }
139
+ }
140
+ const before = fs.lstatSync(file);
141
+ if (requirePrivate)
142
+ assertPrivatePosixStat(before, 0o600, "runtime store/config");
143
+ else if (!before.isFile() || before.isSymbolicLink())
144
+ throw new Error("file が regular ではありません");
145
+ if (before.size > maxBytes)
146
+ throw new Error("file が大きすぎます");
147
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0;
148
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | noFollow);
149
+ try {
150
+ const after = fs.fstatSync(fd);
151
+ if (before.dev !== after.dev || before.ino !== after.ino)
152
+ throw new Error("file が read 中に置換されました");
153
+ if (requirePrivate)
154
+ assertPrivatePosixStat(after, 0o600, "runtime store/config");
155
+ return fs.readFileSync(fd, "utf8");
156
+ }
157
+ finally {
158
+ fs.closeSync(fd);
159
+ }
160
+ }
161
+ function validateCanonicalConfig(config, platform) {
162
+ if (!isObject(config) || !exactKeys(config, ["schema_version", "host", "collection", "reporting"]))
163
+ return false;
164
+ if (config.schema_version !== "1.0" || !isObject(config.host) || !exactKeys(config.host, ["id", "profile"]))
165
+ return false;
166
+ if (typeof config.host.id !== "string" || config.host.id.length < 1 || config.host.id.length > 64
167
+ || !/^[a-z0-9][a-z0-9._-]*$/.test(config.host.id))
168
+ return false;
169
+ if (config.host.profile !== expectedHostProfile(platform))
170
+ return false;
171
+ if (!isObject(config.collection) || !exactKeys(config.collection, ["enabled"])
172
+ || typeof config.collection.enabled !== "boolean")
173
+ return false;
174
+ if (!isObject(config.reporting))
175
+ return false;
176
+ const reportingKeys = Object.keys(config.reporting);
177
+ if (reportingKeys.some((key) => !["enabled", "endpoint", "credential_file"].includes(key))
178
+ || !reportingKeys.includes("enabled") || typeof config.reporting.enabled !== "boolean")
179
+ return false;
180
+ if ("endpoint" in config.reporting) {
181
+ if (typeof config.reporting.endpoint !== "string" || config.reporting.endpoint.length > 2048
182
+ || !/^https?:\/\//.test(config.reporting.endpoint))
183
+ return false;
184
+ try {
185
+ if (!["http:", "https:"].includes(new URL(config.reporting.endpoint).protocol))
186
+ return false;
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ }
192
+ if ("credential_file" in config.reporting
193
+ && (typeof config.reporting.credential_file !== "string"
194
+ || config.reporting.credential_file.length < 1 || config.reporting.credential_file.length > 4096))
195
+ return false;
196
+ if (config.reporting.enabled && (!("endpoint" in config.reporting) || !("credential_file" in config.reporting)))
197
+ return false;
198
+ return true;
199
+ }
200
+ function collectionStatus(configPath, platform) {
201
+ let text;
202
+ try {
203
+ text = readBoundedFile(configPath, MAX_CONFIG_BYTES, platform, true);
204
+ }
205
+ catch (error) {
206
+ return error.code === "ENOENT" ? "disabled" : "malformed";
207
+ }
208
+ try {
209
+ const config = JSON.parse(text);
210
+ if (!validateCanonicalConfig(config, platform))
211
+ return "malformed";
212
+ return config.collection.enabled ? "enabled" : "disabled";
213
+ }
214
+ catch {
215
+ return "malformed";
216
+ }
217
+ }
218
+ function platformName(platform) {
219
+ return platform === "win32" ? "windows" : platform === "darwin" ? "darwin" : "linux";
220
+ }
221
+ function fingerprintFor(code) {
222
+ const definition = RUNTIME_ERROR_DEFINITIONS[code];
223
+ return createHash("sha256").update(["aiterm-mcp", definition.component, code, definition.message_template].join("\0")).digest("hex");
224
+ }
225
+ function projectRecord(record) {
226
+ return Object.fromEntries(RECORD_KEYS.map((key) => [key, record[key]]));
227
+ }
228
+ function validateRecord(value, cursor) {
229
+ if (!isObject(value) || !exactKeys(value, RECORD_KEYS))
230
+ return null;
231
+ if (value.product !== "aiterm-mcp" || typeof value.product_version !== "string"
232
+ || !/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$/.test(value.product_version))
233
+ return null;
234
+ if (typeof value.error_code !== "string" || !(value.error_code in RUNTIME_ERROR_DEFINITIONS))
235
+ return null;
236
+ const code = value.error_code;
237
+ const def = RUNTIME_ERROR_DEFINITIONS[code];
238
+ if (value.component !== def.component || value.message_template !== def.message_template || value.severity !== def.severity
239
+ || value.fingerprint !== fingerprintFor(code))
240
+ return null;
241
+ if (!Number.isSafeInteger(value.occurrence_count) || Number(value.occurrence_count) < 1
242
+ || !validUtc(value.first_seen) || !validUtc(value.last_seen)
243
+ || Date.parse(value.first_seen) > Date.parse(value.last_seen))
244
+ return null;
245
+ if (value.state_schema_version !== STATE_SCHEMA || !["darwin", "linux", "windows"].includes(String(value.os))
246
+ || typeof value.arch !== "string" || !ARCHES.has(value.arch)
247
+ || !["open", "resolved"].includes(String(value.status)))
248
+ return null;
249
+ if ((value.status === "open" && (value.resolved_at !== null || value.reason_code !== null))
250
+ || (value.status === "resolved" && (!validUtc(value.resolved_at)
251
+ || Date.parse(value.resolved_at) < Date.parse(value.last_seen)
252
+ || value.reason_code !== "operator_resolved")))
253
+ return null;
254
+ if (!Number.isSafeInteger(value.sequence) || Number(value.sequence) < 1 || Number(value.sequence) > cursor)
255
+ return null;
256
+ return projectRecord(value);
257
+ }
258
+ function validateState(value, maxRecords = 256) {
259
+ if (!isObject(value) || !exactKeys(value, TOP_KEYS) || value.schema_version !== STORE_SCHEMA
260
+ || !Number.isSafeInteger(value.cursor) || Number(value.cursor) < 0
261
+ || !Number.isSafeInteger(value.acknowledged_cursor) || Number(value.acknowledged_cursor) < 0
262
+ || Number(value.acknowledged_cursor) > Number(value.cursor)
263
+ || !Array.isArray(value.records) || value.records.length > maxRecords)
264
+ return null;
265
+ const records = value.records.map((record) => validateRecord(record, Number(value.cursor)));
266
+ if (records.some((record) => record === null))
267
+ return null;
268
+ const typed = records;
269
+ if (new Set(typed.map((record) => record.fingerprint)).size !== typed.length
270
+ || new Set(typed.map((record) => record.sequence)).size !== typed.length)
271
+ return null;
272
+ return {
273
+ schema_version: STORE_SCHEMA,
274
+ cursor: Number(value.cursor),
275
+ acknowledged_cursor: Number(value.acknowledged_cursor),
276
+ records: typed.map((record) => projectRecord(record)),
277
+ };
278
+ }
279
+ function processStartIdentity(pid, platform) {
280
+ if (!Number.isInteger(pid) || pid < 1)
281
+ return null;
282
+ if (platform === "linux") {
283
+ try {
284
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
285
+ const close = stat.lastIndexOf(")");
286
+ const fields = stat.slice(close + 2).split(" ");
287
+ return fields[19] ? `linux:${fields[19]}` : null;
288
+ }
289
+ catch {
290
+ return null;
291
+ }
292
+ }
293
+ if (platform === "win32") {
294
+ const script = "$p=Get-Process -Id $env:AITERMMCP_PROCESS_ID -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks";
295
+ const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
296
+ encoding: "utf8", timeout: 1000, maxBuffer: 4096, windowsHide: true,
297
+ env: { ...process.env, AITERMMCP_PROCESS_ID: String(pid) },
298
+ });
299
+ const value = result.status === 0 ? (result.stdout ?? "").trim() : "";
300
+ return /^\d+$/.test(value) ? `win32:${value}` : null;
301
+ }
302
+ const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1000, maxBuffer: 4096 });
303
+ const value = result.status === 0 ? (result.stdout ?? "").trim() : "";
304
+ return value ? `${platform}:${value}` : null;
305
+ }
306
+ export class RuntimeErrorStore {
307
+ configPath;
308
+ storePath;
309
+ platform;
310
+ arch;
311
+ productVersion;
312
+ now;
313
+ stderr;
314
+ maxRecords;
315
+ windowsAclTimeoutMs;
316
+ constructor(options = {}) {
317
+ const defaults = defaultRuntimeErrorPaths(options);
318
+ this.configPath = options.configPath ?? defaults.configPath;
319
+ this.storePath = options.storePath ?? defaults.storePath;
320
+ this.platform = options.platform ?? process.platform;
321
+ if (!ARCHES.has(options.arch ?? process.arch))
322
+ throw new Error("arch が不正です");
323
+ this.arch = (options.arch ?? process.arch);
324
+ this.productVersion = options.productVersion ?? pkg.version;
325
+ this.now = options.now ?? (() => new Date());
326
+ this.stderr = options.stderr ?? ((line) => process.stderr.write(line));
327
+ this.maxRecords = options.maxRecords ?? 256;
328
+ this.windowsAclTimeoutMs = options.windowsAclTimeoutMs ?? 5000;
329
+ if (!Number.isInteger(this.maxRecords) || this.maxRecords < 1 || this.maxRecords > 256)
330
+ throw new Error("maxRecords は 1..256 必須です");
331
+ }
332
+ collectionStatus() { return collectionStatus(this.configPath, this.platform); }
333
+ assertPrivateDirectory(dir) {
334
+ const info = fs.lstatSync(dir);
335
+ if (!info.isDirectory() || info.isSymbolicLink())
336
+ throw new Error("runtime error store directory が安全ではありません");
337
+ if (this.platform !== "win32") {
338
+ if (typeof process.getuid !== "function" || info.uid !== process.getuid() || (info.mode & 0o777) !== 0o700) {
339
+ throw new Error("runtime error store directory owner/mode が不正です");
340
+ }
341
+ }
342
+ }
343
+ readState() {
344
+ const dir = path.dirname(this.storePath);
345
+ try {
346
+ this.assertPrivateDirectory(dir);
347
+ }
348
+ catch (error) {
349
+ if (error.code === "ENOENT")
350
+ return EMPTY_STATE();
351
+ throw error;
352
+ }
353
+ if (this.platform === "win32") {
354
+ this.applyWindowsDacl(dir, "directory");
355
+ if (fs.existsSync(this.storePath))
356
+ this.applyWindowsDacl(this.storePath, "file");
357
+ }
358
+ let text;
359
+ try {
360
+ text = readBoundedFile(this.storePath, MAX_STORE_BYTES, this.platform, this.platform !== "win32");
361
+ }
362
+ catch (error) {
363
+ if (error.code === "ENOENT")
364
+ return EMPTY_STATE();
365
+ throw error;
366
+ }
367
+ const state = validateState(JSON.parse(text), this.maxRecords);
368
+ if (!state)
369
+ throw new Error("runtime error store schema が不正です");
370
+ return state;
371
+ }
372
+ applyWindowsDacl(target, kind) {
373
+ const command = windowsPrivateDaclCommand(target, kind);
374
+ const result = spawnSync(command.command, command.args, {
375
+ encoding: "utf8", windowsHide: true, timeout: this.windowsAclTimeoutMs, maxBuffer: 16 * 1024, env: command.env,
376
+ });
377
+ if (result.error || result.status !== 0)
378
+ throw new Error("Windows private DACL の適用/readback に失敗しました");
379
+ }
380
+ ensurePrivateDirectory() {
381
+ const dir = path.dirname(this.storePath);
382
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
383
+ if (this.platform === "win32")
384
+ this.applyWindowsDacl(dir, "directory");
385
+ else
386
+ fs.chmodSync(dir, 0o700);
387
+ this.assertPrivateDirectory(dir);
388
+ }
389
+ atomicWrite(state) {
390
+ const validated = validateState(state, this.maxRecords);
391
+ if (!validated)
392
+ throw new Error("書込 state が不正です");
393
+ this.ensurePrivateDirectory();
394
+ const dir = path.dirname(this.storePath);
395
+ const temp = path.join(dir, `.${path.basename(this.storePath)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
396
+ let fd = null;
397
+ try {
398
+ fd = fs.openSync(temp, "wx", 0o600);
399
+ fs.writeFileSync(fd, JSON.stringify(validated) + "\n", "utf8");
400
+ fs.fsyncSync(fd);
401
+ fs.closeSync(fd);
402
+ fd = null;
403
+ fs.renameSync(temp, this.storePath);
404
+ if (this.platform === "win32")
405
+ this.applyWindowsDacl(this.storePath, "file");
406
+ else
407
+ fs.chmodSync(this.storePath, 0o600);
408
+ }
409
+ finally {
410
+ if (fd !== null)
411
+ try {
412
+ fs.closeSync(fd);
413
+ }
414
+ catch { /* noop */ }
415
+ try {
416
+ fs.unlinkSync(temp);
417
+ }
418
+ catch { /* renamed or cleanup */ }
419
+ }
420
+ }
421
+ readLock(lock) {
422
+ let text;
423
+ if (this.platform === "win32") {
424
+ this.applyWindowsDacl(lock, "file");
425
+ text = readBoundedFile(lock, 4096, this.platform, false);
426
+ }
427
+ else {
428
+ const before = fs.lstatSync(lock);
429
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink < 1 || before.nlink > 2
430
+ || before.uid !== process.getuid() || (before.mode & 0o777) !== 0o600 || before.size > 4096) {
431
+ throw new Error("runtime error store lock が不正です");
432
+ }
433
+ const fd = fs.openSync(lock, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0));
434
+ try {
435
+ const after = fs.fstatSync(fd);
436
+ if (before.dev !== after.dev || before.ino !== after.ino || after.nlink < 1 || after.nlink > 2) {
437
+ throw new Error("runtime error store lock が置換されました");
438
+ }
439
+ text = fs.readFileSync(fd, "utf8");
440
+ }
441
+ finally {
442
+ fs.closeSync(fd);
443
+ }
444
+ }
445
+ const parsed = JSON.parse(text);
446
+ if (!isObject(parsed) || !exactKeys(parsed, ["pid", "start_id", "token"])
447
+ || !Number.isInteger(parsed.pid) || Number(parsed.pid) < 1
448
+ || typeof parsed.start_id !== "string" || parsed.start_id.length > 256
449
+ || typeof parsed.token !== "string" || !/^[0-9a-f]{32}$/.test(parsed.token))
450
+ throw new Error("runtime error store lock が不正です");
451
+ return { pid: Number(parsed.pid), start_id: parsed.start_id, token: parsed.token };
452
+ }
453
+ publishOwnerFile(target, owner) {
454
+ const queue = path.dirname(target);
455
+ const temporary = `${queue}.publish.${owner.token}.${randomBytes(8).toString("hex")}.tmp`;
456
+ let fd = fs.openSync(temporary, "wx", 0o600);
457
+ try {
458
+ fs.writeFileSync(fd, JSON.stringify(owner) + "\n", "utf8");
459
+ fs.fsyncSync(fd);
460
+ fs.closeSync(fd);
461
+ fd = -1;
462
+ if (this.platform === "win32")
463
+ this.applyWindowsDacl(temporary, "file");
464
+ fs.renameSync(temporary, target);
465
+ }
466
+ finally {
467
+ if (fd >= 0)
468
+ try {
469
+ fs.closeSync(fd);
470
+ }
471
+ catch { /* noop */ }
472
+ try {
473
+ fs.unlinkSync(temporary);
474
+ }
475
+ catch { /* rename済みまたはcleanup済み */ }
476
+ }
477
+ }
478
+ withLock(fn) {
479
+ this.ensurePrivateDirectory();
480
+ const queue = `${this.storePath}.lock-queue`;
481
+ try {
482
+ fs.mkdirSync(queue, { mode: 0o700 });
483
+ }
484
+ catch (error) {
485
+ if (error.code !== "EEXIST")
486
+ throw error;
487
+ }
488
+ if (this.platform === "win32")
489
+ this.applyWindowsDacl(queue, "directory");
490
+ else {
491
+ const info = fs.lstatSync(queue);
492
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid()
493
+ || (info.mode & 0o777) !== 0o700)
494
+ throw new Error("runtime error lock queue のowner/modeが不正です");
495
+ }
496
+ const startId = processStartIdentity(process.pid, this.platform);
497
+ if (!startId)
498
+ throw new Error("process start identity を取得できません");
499
+ const owner = { pid: process.pid, start_id: startId, token: randomBytes(16).toString("hex") };
500
+ const ticketPattern = /^(\d{16})-([0-9a-f]{32})\.ticket$/;
501
+ const choosingPattern = /^choosing-([0-9a-f]{32})\.json$/;
502
+ const choosing = path.join(queue, `choosing-${owner.token}.json`);
503
+ let ticket = null;
504
+ try {
505
+ this.publishOwnerFile(choosing, owner);
506
+ let maximum = 0;
507
+ for (const name of fs.readdirSync(queue)) {
508
+ const match = ticketPattern.exec(name);
509
+ if (!match) {
510
+ if (choosingPattern.test(name))
511
+ continue;
512
+ throw new Error("runtime error lock queue に不正なentryがあります");
513
+ }
514
+ maximum = Math.max(maximum, Number(match[1]));
515
+ }
516
+ if (!Number.isSafeInteger(maximum) || maximum >= 9_999_999_999_999_999) {
517
+ throw new Error("runtime error lock ticket が上限に達しました");
518
+ }
519
+ const ticketNumber = maximum + 1;
520
+ const ticketName = `${String(ticketNumber).padStart(16, "0")}-${owner.token}.ticket`;
521
+ ticket = path.join(queue, ticketName);
522
+ this.publishOwnerFile(ticket, owner);
523
+ fs.unlinkSync(choosing);
524
+ const deadline = Date.now() + 1_500;
525
+ for (;;) {
526
+ const names = fs.readdirSync(queue).sort();
527
+ let hasLiveChoosing = false;
528
+ for (const name of names.filter((candidate) => choosingPattern.test(candidate))) {
529
+ const currentPath = path.join(queue, name);
530
+ let current;
531
+ try {
532
+ current = this.readLock(currentPath);
533
+ }
534
+ catch (error) {
535
+ if (error.code === "ENOENT"
536
+ || (error instanceof Error && error.message.includes("置換されました")))
537
+ continue;
538
+ throw error;
539
+ }
540
+ if (name !== `choosing-${current.token}.json`)
541
+ throw new Error("runtime error choosing entry が不正です");
542
+ const identity = processStartIdentity(current.pid, this.platform);
543
+ const live = identity === current.start_id || (!identity && processExists(current.pid));
544
+ if (live)
545
+ hasLiveChoosing = true;
546
+ else {
547
+ try {
548
+ fs.unlinkSync(currentPath);
549
+ }
550
+ catch (error) {
551
+ if (error.code !== "ENOENT")
552
+ throw error;
553
+ }
554
+ }
555
+ }
556
+ if (hasLiveChoosing) {
557
+ if (Date.now() >= deadline)
558
+ throw new Error("runtime error store is busy");
559
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
560
+ continue;
561
+ }
562
+ let firstLive = null;
563
+ const ticketNames = fs.readdirSync(queue).sort();
564
+ for (const name of ticketNames.filter((candidate) => ticketPattern.test(candidate))) {
565
+ const currentPath = path.join(queue, name);
566
+ let current;
567
+ try {
568
+ current = this.readLock(currentPath);
569
+ }
570
+ catch (error) {
571
+ if (error.code === "ENOENT"
572
+ || (error instanceof Error && error.message.includes("置換されました")))
573
+ continue;
574
+ throw error;
575
+ }
576
+ if (!name.endsWith(`-${current.token}.ticket`))
577
+ throw new Error("runtime error lock ticket が不正です");
578
+ const identity = processStartIdentity(current.pid, this.platform);
579
+ const live = identity === current.start_id || (!identity && processExists(current.pid));
580
+ if (!live) {
581
+ try {
582
+ fs.unlinkSync(currentPath);
583
+ }
584
+ catch (error) {
585
+ if (error.code !== "ENOENT")
586
+ throw error;
587
+ }
588
+ continue;
589
+ }
590
+ firstLive = name;
591
+ break;
592
+ }
593
+ if (firstLive === ticketName)
594
+ break;
595
+ if (Date.now() >= deadline)
596
+ throw new Error("runtime error store is busy");
597
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
598
+ }
599
+ return fn();
600
+ }
601
+ finally {
602
+ try {
603
+ fs.unlinkSync(choosing);
604
+ }
605
+ catch { /* publish前failureまたは既にticket化 */ }
606
+ if (ticket) {
607
+ try {
608
+ const current = this.readLock(ticket);
609
+ if (current.token === owner.token && current.pid === owner.pid && current.start_id === owner.start_id) {
610
+ fs.unlinkSync(ticket);
611
+ }
612
+ }
613
+ catch { /* 自分の固有ticketだけを片付ける */ }
614
+ }
615
+ }
616
+ }
617
+ compactAcknowledgedResolved(state) {
618
+ if (state.records.length < this.maxRecords)
619
+ return;
620
+ state.records = state.records.filter((record) => !(record.status === "resolved" && record.sequence <= state.acknowledged_cursor));
621
+ }
622
+ record(value) {
623
+ const observation = validateRuntimeObservation(value);
624
+ if (this.collectionStatus() !== "enabled")
625
+ return false;
626
+ return this.withLock(() => {
627
+ const state = this.readState();
628
+ const definition = RUNTIME_ERROR_DEFINITIONS[observation.code];
629
+ const fingerprint = fingerprintFor(observation.code);
630
+ let record = state.records.find((item) => item.fingerprint === fingerprint);
631
+ if (!record) {
632
+ this.compactAcknowledgedResolved(state);
633
+ if (state.records.length >= this.maxRecords)
634
+ throw new Error("runtime error store capacity reached; unacknowledged records are preserved");
635
+ }
636
+ const seen = this.now().toISOString();
637
+ const sequence = state.cursor + 1;
638
+ if (record) {
639
+ record.occurrence_count += 1;
640
+ record.last_seen = seen;
641
+ record.status = "open";
642
+ record.resolved_at = null;
643
+ record.reason_code = null;
644
+ record.sequence = sequence;
645
+ }
646
+ else {
647
+ record = {
648
+ product: "aiterm-mcp", product_version: this.productVersion, component: definition.component,
649
+ error_code: observation.code, message_template: definition.message_template, severity: definition.severity,
650
+ fingerprint, occurrence_count: 1, first_seen: seen, last_seen: seen, state_schema_version: STATE_SCHEMA,
651
+ os: platformName(this.platform), arch: this.arch, status: "open",
652
+ resolved_at: null, reason_code: null, sequence,
653
+ };
654
+ state.records.push(record);
655
+ }
656
+ state.cursor = sequence;
657
+ this.atomicWrite(state);
658
+ return true;
659
+ });
660
+ }
661
+ tryRecord(value) {
662
+ const observation = validateRuntimeObservation(value);
663
+ try {
664
+ return this.record(observation);
665
+ }
666
+ catch {
667
+ this.stderr("aiterm: runtime error store unavailable\n");
668
+ return false;
669
+ }
670
+ }
671
+ snapshot() {
672
+ const collection = this.collectionStatus();
673
+ const state = collection === "enabled" ? this.readState() : EMPTY_STATE();
674
+ return {
675
+ collection, schema_version: state.schema_version, cursor: state.cursor,
676
+ acknowledged_cursor: state.acknowledged_cursor,
677
+ records: state.records.map((record) => projectRecord(record)),
678
+ };
679
+ }
680
+ changeStatus(fingerprint, status) {
681
+ if (!/^[0-9a-f]{64}$/.test(fingerprint))
682
+ throw new Error("fingerprint が不正です");
683
+ if (this.collectionStatus() !== "enabled")
684
+ return false;
685
+ return this.withLock(() => {
686
+ const state = this.readState();
687
+ const record = state.records.find((item) => item.fingerprint === fingerprint);
688
+ if (!record)
689
+ return false;
690
+ if (record.status === status)
691
+ return true;
692
+ state.cursor += 1;
693
+ record.status = status;
694
+ record.resolved_at = status === "resolved" ? this.now().toISOString() : null;
695
+ record.reason_code = status === "resolved" ? "operator_resolved" : null;
696
+ record.sequence = state.cursor;
697
+ this.atomicWrite(state);
698
+ return true;
699
+ });
700
+ }
701
+ resolve(fingerprint) { return this.changeStatus(fingerprint, "resolved"); }
702
+ reopen(fingerprint) { return this.changeStatus(fingerprint, "open"); }
703
+ acknowledge(cursor) {
704
+ if (!Number.isSafeInteger(cursor) || cursor < 0)
705
+ throw new Error("ack cursor が不正です");
706
+ if (this.collectionStatus() !== "enabled")
707
+ return this.snapshot();
708
+ this.withLock(() => {
709
+ const state = this.readState();
710
+ if (cursor < state.acknowledged_cursor)
711
+ throw new Error("ack cursor must be monotonic");
712
+ if (cursor > state.cursor)
713
+ throw new Error("ack cursor is ahead of current cursor");
714
+ state.acknowledged_cursor = cursor;
715
+ this.atomicWrite(state);
716
+ });
717
+ return this.snapshot();
718
+ }
719
+ diagnostic() {
720
+ const collection = this.collectionStatus();
721
+ if (collection === "disabled")
722
+ return { status: "not_applicable", collection, record_count: 0, unacknowledged_count: 0 };
723
+ if (collection === "malformed")
724
+ return { status: "unverified", collection, record_count: null, unacknowledged_count: null };
725
+ try {
726
+ const state = this.readState();
727
+ return { status: "ready", collection, record_count: state.records.length,
728
+ unacknowledged_count: state.records.filter((record) => record.sequence > state.acknowledged_cursor).length };
729
+ }
730
+ catch {
731
+ return { status: "unverified", collection, record_count: null, unacknowledged_count: null };
732
+ }
733
+ }
734
+ }
735
+ const WORKER = fileURLToPath(new URL("./runtime-error-worker.js", import.meta.url));
736
+ function fixedStoreFailure(stderr) { stderr("aiterm: runtime error store unavailable\n"); }
737
+ export function recordRuntimeError(code, options = {}) {
738
+ validateRuntimeObservation({ code });
739
+ const stderr = options.stderr ?? ((line) => process.stderr.write(line));
740
+ let reported = false;
741
+ const report = () => { if (!reported) {
742
+ reported = true;
743
+ fixedStoreFailure(stderr);
744
+ } };
745
+ try {
746
+ const child = spawn(process.execPath, [options.workerPath ?? WORKER, "record", code], {
747
+ stdio: "ignore", windowsHide: true, env: process.env,
748
+ });
749
+ const timer = setTimeout(() => { report(); forceKill(child); }, options.timeoutMs ?? WORKER_TIMEOUT_MS);
750
+ timer.unref();
751
+ child.once("error", report);
752
+ child.once("exit", (exitCode, signal) => { clearTimeout(timer); if (exitCode !== 0 || signal)
753
+ report(); });
754
+ child.unref();
755
+ return true;
756
+ }
757
+ catch {
758
+ report();
759
+ return false;
760
+ }
761
+ }
762
+ function validDiagnostic(value) {
763
+ if (!isObject(value) || !exactKeys(value, DIAGNOSTIC_KEYS))
764
+ return false;
765
+ return ["ready", "not_applicable", "unverified"].includes(String(value.status))
766
+ && ["enabled", "disabled", "malformed"].includes(String(value.collection))
767
+ && [value.record_count, value.unacknowledged_count].every((item) => item === null || (Number.isInteger(item) && Number(item) >= 0));
768
+ }
769
+ export async function runtimeErrorStoreDiagnostic(options = {}) {
770
+ const fallback = { status: "unverified", collection: "malformed", record_count: null, unacknowledged_count: null };
771
+ return await new Promise((resolve) => {
772
+ let settled = false;
773
+ let stdout = "";
774
+ const finish = (value) => { if (!settled) {
775
+ settled = true;
776
+ resolve(value);
777
+ } };
778
+ let child;
779
+ try {
780
+ child = spawn(process.execPath, [options.workerPath ?? WORKER, "diagnostic"], {
781
+ stdio: ["ignore", "pipe", "ignore"], windowsHide: true, env: process.env,
782
+ });
783
+ }
784
+ catch {
785
+ finish(fallback);
786
+ return;
787
+ }
788
+ const timer = setTimeout(() => { forceKill(child); finish(fallback); }, options.timeoutMs ?? WORKER_TIMEOUT_MS);
789
+ child.stdout.setEncoding("utf8");
790
+ child.stdout.on("data", (chunk) => { if (stdout.length <= 4096)
791
+ stdout += chunk; });
792
+ child.once("error", () => { clearTimeout(timer); finish(fallback); });
793
+ child.once("close", (code) => {
794
+ clearTimeout(timer);
795
+ if (code !== 0 || stdout.length > 4096) {
796
+ finish(fallback);
797
+ return;
798
+ }
799
+ try {
800
+ const parsed = JSON.parse(stdout);
801
+ finish(validDiagnostic(parsed) ? parsed : fallback);
802
+ }
803
+ catch {
804
+ finish(fallback);
805
+ }
806
+ });
807
+ });
808
+ }
809
+ function forceKill(child) {
810
+ if (!Number.isSafeInteger(child.pid) || !child.pid)
811
+ return;
812
+ if (process.platform !== "win32") {
813
+ try {
814
+ child.kill("SIGKILL");
815
+ }
816
+ catch { /* already exited */ }
817
+ return;
818
+ }
819
+ const killer = spawn("taskkill.exe", ["/pid", String(child.pid), "/T", "/F"], {
820
+ stdio: "ignore", windowsHide: true,
821
+ });
822
+ killer.once("error", () => { try {
823
+ child.kill("SIGKILL");
824
+ }
825
+ catch { /* already exited */ } });
826
+ killer.unref();
827
+ }
828
+ function processExists(pid) {
829
+ try {
830
+ process.kill(pid, 0);
831
+ return true;
832
+ }
833
+ catch (error) {
834
+ return error.code !== "ESRCH";
835
+ }
836
+ }