wowdump 0.2.1 → 0.3.1
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/LICENSE +21 -21
- package/README.md +56 -118
- package/dist/agent.js +5 -8
- package/dist/cli.js +497 -0
- package/dist/discovery.js +1 -12
- package/dist/dry-run.js +0 -2
- package/dist/focused-session.js +61 -1329
- package/dist/frida-runtime.js +51 -73
- package/dist/frida-worker.js +100 -0
- package/dist/ghidra.js +769 -0
- package/dist/main.js +66 -0
- package/dist/processes.js +2 -5
- package/dist/reader-broker.js +460 -0
- package/dist/reader-client.js +1 -0
- package/dist/reader-main.js +67 -0
- package/dist/session.js +17 -120
- package/dist/toolchain.js +594 -0
- package/dist/windows-launcher.js +207 -0
- package/dist/windows-reader.js +102 -0
- package/dist/wow-analysis.js +211 -236
- package/package.json +18 -35
- package/skills/wowdump/SKILL.md +15 -0
- package/skills/wowdump/commands.md +44 -0
- package/dist/analysis-path.js +0 -38
- package/dist/analysis-process-log.js +0 -146
- package/dist/broker-client.js +0 -411
- package/dist/broker-codec.js +0 -148
- package/dist/broker-core.js +0 -1045
- package/dist/broker-gateway.js +0 -447
- package/dist/broker-ledger.js +0 -196
- package/dist/broker-main.js +0 -291
- package/dist/broker-protocol.js +0 -119
- package/dist/broker-runtime.js +0 -1283
- package/dist/broker-server.js +0 -466
- package/dist/build-bundle-loader.js +0 -183
- package/dist/build-bundle.js +0 -11
- package/dist/focus-errors.js +0 -63
- package/dist/focus-service.js +0 -1855
- package/dist/mcp-main.js +0 -51
- package/dist/mcp.js +0 -924
- package/dist/process-log-lock.js +0 -195
- package/dist/runtime-config.js +0 -399
- package/resources/builds/retail/12.0.7.68974/build-profile.json +0 -290
- package/resources/builds/retail/12.0.7.68974/data-sources.json +0 -1633
- package/resources/builds/retail/12.0.7.68974/lua-targets.jsonl +0 -5130
- package/resources/builds/retail/12.0.7.68974/manifest.json +0 -63
- package/resources/builds/retail/12.0.7.68974/signatures.json +0 -260
package/dist/main.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { buildAdapterRegistry } from "./adapters.js";
|
|
3
|
+
import { discoverInstalls } from "./discovery.js";
|
|
4
|
+
import { createDryRunReport } from "./dry-run.js";
|
|
5
|
+
import { listWowProcesses } from "./processes.js";
|
|
6
|
+
const gameRoot = process.env.WOW_ROOT ?? "D:/Game/World of Warcraft";
|
|
7
|
+
const agentFile = resolve(process.env.WOW_AGENT ?? "dist/agent.js");
|
|
8
|
+
const output = resolve(process.env.WOW_ERRORS ?? "data/errors.jsonl");
|
|
9
|
+
const pollMs = Number(process.env.WOW_POLL_MS ?? 1500);
|
|
10
|
+
const installs = discoverInstalls(gameRoot);
|
|
11
|
+
if (process.argv.includes("--discover")) {
|
|
12
|
+
console.log(JSON.stringify(installs, null, 2));
|
|
13
|
+
}
|
|
14
|
+
else if (process.argv.includes("--dry-run")) {
|
|
15
|
+
try {
|
|
16
|
+
const processes = await listWowProcesses(installs);
|
|
17
|
+
console.log(JSON.stringify(createDryRunReport(installs, processes, buildAdapterRegistry), null, 2));
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
console.error("process scan:", error);
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
await runCollector();
|
|
26
|
+
}
|
|
27
|
+
async function runCollector() {
|
|
28
|
+
const [{ ProcessSession }, { JsonlStore }] = await Promise.all([
|
|
29
|
+
import("./session.js"),
|
|
30
|
+
import("./storage.js")
|
|
31
|
+
]);
|
|
32
|
+
console.log(`Discovered ${installs.length} WoW installs under ${gameRoot}`);
|
|
33
|
+
const store = new JsonlStore(output);
|
|
34
|
+
const sessions = new Map();
|
|
35
|
+
async function poll() {
|
|
36
|
+
const processes = await listWowProcesses(installs).catch(error => {
|
|
37
|
+
console.error("process scan:", error);
|
|
38
|
+
return [];
|
|
39
|
+
});
|
|
40
|
+
const live = new Set(processes.map(process => process.pid));
|
|
41
|
+
for (const [pid, session] of sessions) {
|
|
42
|
+
if (!live.has(pid)) {
|
|
43
|
+
await session.detach();
|
|
44
|
+
sessions.delete(pid);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const process of processes) {
|
|
48
|
+
if (sessions.has(process.pid))
|
|
49
|
+
continue;
|
|
50
|
+
const session = new ProcessSession(process, agentFile, store);
|
|
51
|
+
await session.attach()
|
|
52
|
+
.then(() => sessions.set(process.pid, session))
|
|
53
|
+
.catch(error => console.error(`attach ${process.pid}:`, error));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await poll();
|
|
57
|
+
const timer = setInterval(() => void poll(), pollMs);
|
|
58
|
+
const shutdown = async () => {
|
|
59
|
+
clearInterval(timer);
|
|
60
|
+
for (const session of sessions.values())
|
|
61
|
+
await session.detach();
|
|
62
|
+
process.exit(0);
|
|
63
|
+
};
|
|
64
|
+
process.once("SIGINT", shutdown);
|
|
65
|
+
process.once("SIGTERM", shutdown);
|
|
66
|
+
}
|
package/dist/processes.js
CHANGED
|
@@ -29,10 +29,7 @@ export function parseWowProcessRows(stdout, installs) {
|
|
|
29
29
|
pid,
|
|
30
30
|
executable,
|
|
31
31
|
install,
|
|
32
|
-
commandLine: String(row.CommandLine ?? "")
|
|
33
|
-
...(typeof row.StartTime === "string" && row.StartTime.length > 0
|
|
34
|
-
? { startTime: row.StartTime }
|
|
35
|
-
: {})
|
|
32
|
+
commandLine: String(row.CommandLine ?? "")
|
|
36
33
|
}];
|
|
37
34
|
});
|
|
38
35
|
}
|
|
@@ -41,7 +38,7 @@ export async function listWowProcesses(installs) {
|
|
|
41
38
|
"-NoProfile",
|
|
42
39
|
"-NonInteractive",
|
|
43
40
|
"-Command",
|
|
44
|
-
"Get-CimInstance Win32_Process -Filter \"Name = 'Wow.exe'\" | Select-Object Name,ProcessId,ExecutablePath,CommandLine
|
|
41
|
+
"Get-CimInstance Win32_Process -Filter \"Name = 'Wow.exe'\" | Select-Object Name,ProcessId,ExecutablePath,CommandLine | ConvertTo-Json -Compress"
|
|
45
42
|
], { windowsHide: true });
|
|
46
43
|
return parseWowProcessRows(stdout, installs);
|
|
47
44
|
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 20 * 60 * 1000;
|
|
4
|
+
export const DEFAULT_RIGHTS = Object.freeze([
|
|
5
|
+
"PROCESS_QUERY_INFORMATION",
|
|
6
|
+
"PROCESS_VM_READ"
|
|
7
|
+
]);
|
|
8
|
+
function hexAddress(value) {
|
|
9
|
+
const normalized = value.trim();
|
|
10
|
+
if (!/^0x[0-9a-f]+$/i.test(normalized)) {
|
|
11
|
+
throw new ReaderProtocolError("INVALID_ADDRESS", "address must be a hexadecimal 0x-prefixed string");
|
|
12
|
+
}
|
|
13
|
+
const address = BigInt(normalized);
|
|
14
|
+
if (address < 0n || address > 0xffffffffffffffffn) {
|
|
15
|
+
throw new ReaderProtocolError("INVALID_ADDRESS", "address is outside the 64-bit range");
|
|
16
|
+
}
|
|
17
|
+
return address;
|
|
18
|
+
}
|
|
19
|
+
function boundedSize(value, name, maximum = 16 * 1024 * 1024) {
|
|
20
|
+
const size = Number(value);
|
|
21
|
+
if (!Number.isSafeInteger(size) || size <= 0 || size > maximum) {
|
|
22
|
+
throw new ReaderProtocolError("INVALID_SIZE", `${name} must be an integer between 1 and ${maximum}`);
|
|
23
|
+
}
|
|
24
|
+
return size;
|
|
25
|
+
}
|
|
26
|
+
function boundedPid(value) {
|
|
27
|
+
const pid = Number(value);
|
|
28
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fffffff) {
|
|
29
|
+
throw new ReaderProtocolError("INVALID_PID", "pid must be a positive Windows process id");
|
|
30
|
+
}
|
|
31
|
+
return pid;
|
|
32
|
+
}
|
|
33
|
+
function toHex(bytes) {
|
|
34
|
+
return Buffer.from(bytes).toString("hex");
|
|
35
|
+
}
|
|
36
|
+
function bytesFromResult(result) {
|
|
37
|
+
if (!result || !(result.bytes instanceof Uint8Array)) {
|
|
38
|
+
throw new ReaderProtocolError("NATIVE_RESULT", "native reader returned no byte buffer");
|
|
39
|
+
}
|
|
40
|
+
const bytesRead = result.bytesRead === undefined ? result.bytes.byteLength : Number(result.bytesRead);
|
|
41
|
+
if (!Number.isSafeInteger(bytesRead) || bytesRead < 0 || bytesRead > result.bytes.byteLength) {
|
|
42
|
+
throw new ReaderProtocolError("NATIVE_RESULT", "native reader returned an invalid bytesRead value");
|
|
43
|
+
}
|
|
44
|
+
return { bytes: result.bytes.subarray(0, bytesRead), bytesRead };
|
|
45
|
+
}
|
|
46
|
+
export class ReaderProtocolError extends Error {
|
|
47
|
+
code;
|
|
48
|
+
details;
|
|
49
|
+
constructor(code, message, details) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "ReaderProtocolError";
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.details = details;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Placeholder backend used until the platform adapter is installed. Keeping
|
|
58
|
+
* this explicit makes an unavailable native layer observable to the caller.
|
|
59
|
+
*/
|
|
60
|
+
export class UnavailableNativeReader {
|
|
61
|
+
diagnostic() {
|
|
62
|
+
throw new ReaderProtocolError("NATIVE_BACKEND_UNAVAILABLE", "Windows native reader backend is not installed", {
|
|
63
|
+
platform: process.platform,
|
|
64
|
+
requestedRights: [...DEFAULT_RIGHTS],
|
|
65
|
+
elevation: this.elevationStatus()
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
openProcess(_pid, _rights) {
|
|
69
|
+
return Promise.reject(this.diagnostic());
|
|
70
|
+
}
|
|
71
|
+
readProcessMemory(_handle, _address, _size) {
|
|
72
|
+
return Promise.reject(this.diagnostic());
|
|
73
|
+
}
|
|
74
|
+
elevationStatus() {
|
|
75
|
+
return {
|
|
76
|
+
isWindows: process.platform === "win32",
|
|
77
|
+
elevated: false,
|
|
78
|
+
state: "unknown",
|
|
79
|
+
reason: "native elevation probe is not installed"
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export class ReaderBroker {
|
|
84
|
+
idleTimeoutMs;
|
|
85
|
+
backend;
|
|
86
|
+
now;
|
|
87
|
+
onShutdown;
|
|
88
|
+
handles = new Map();
|
|
89
|
+
watches = new Map();
|
|
90
|
+
idleTimer;
|
|
91
|
+
watchCounter = 0;
|
|
92
|
+
stopped = false;
|
|
93
|
+
constructor(options = {}) {
|
|
94
|
+
this.backend = options.backend ?? new UnavailableNativeReader();
|
|
95
|
+
this.idleTimeoutMs = Math.max(1, Math.floor(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS));
|
|
96
|
+
this.now = options.now ?? Date.now;
|
|
97
|
+
this.onShutdown = options.onShutdown;
|
|
98
|
+
this.armIdleTimer();
|
|
99
|
+
}
|
|
100
|
+
get isStopped() {
|
|
101
|
+
return this.stopped;
|
|
102
|
+
}
|
|
103
|
+
get activeHandleCount() {
|
|
104
|
+
return this.handles.size;
|
|
105
|
+
}
|
|
106
|
+
get activeWatchCount() {
|
|
107
|
+
return [...this.watches.values()].filter(watch => !watch.stopped).length;
|
|
108
|
+
}
|
|
109
|
+
/** Handle one already-decoded request. Suitable for a CLI client or tests. */
|
|
110
|
+
async handle(request) {
|
|
111
|
+
const command = typeof request?.command === "string" ? request.command : "unknown";
|
|
112
|
+
const base = { id: request?.id, ok: true, command, timestamp: this.now() };
|
|
113
|
+
if (this.stopped) {
|
|
114
|
+
return { ...base, ok: false, error: { code: "BROKER_STOPPED", message: "reader broker is stopped" } };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const result = await this.dispatch(request);
|
|
118
|
+
this.armIdleTimer();
|
|
119
|
+
return { ...base, ...result, ok: true };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
this.armIdleTimer();
|
|
123
|
+
return { ...base, ok: false, error: serializeError(error, this.backend) };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async shutdown(reason = "request") {
|
|
127
|
+
if (this.stopped)
|
|
128
|
+
return;
|
|
129
|
+
this.stopped = true;
|
|
130
|
+
if (this.idleTimer)
|
|
131
|
+
clearTimeout(this.idleTimer);
|
|
132
|
+
for (const watch of this.watches.values())
|
|
133
|
+
watch.stopped = true;
|
|
134
|
+
this.watches.clear();
|
|
135
|
+
const handles = [...this.handles.values()];
|
|
136
|
+
this.handles.clear();
|
|
137
|
+
for (const handle of handles) {
|
|
138
|
+
await Promise.resolve(handle.close()).catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
await Promise.resolve(this.backend.close?.()).catch(() => undefined);
|
|
141
|
+
await Promise.resolve(this.onShutdown?.(reason)).catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
armIdleTimer() {
|
|
144
|
+
if (this.stopped)
|
|
145
|
+
return;
|
|
146
|
+
if (this.idleTimer)
|
|
147
|
+
clearTimeout(this.idleTimer);
|
|
148
|
+
this.idleTimer = setTimeout(() => {
|
|
149
|
+
void this.shutdown("idle");
|
|
150
|
+
}, this.idleTimeoutMs);
|
|
151
|
+
// A broker should not keep an otherwise idle CLI event loop alive in tests.
|
|
152
|
+
this.idleTimer.unref?.();
|
|
153
|
+
}
|
|
154
|
+
async dispatch(request) {
|
|
155
|
+
switch (request.command) {
|
|
156
|
+
case "status":
|
|
157
|
+
return {
|
|
158
|
+
pid: process.pid,
|
|
159
|
+
idleTimeoutMs: this.idleTimeoutMs,
|
|
160
|
+
activeHandles: this.handles.size,
|
|
161
|
+
activeWatches: this.activeWatchCount,
|
|
162
|
+
elevation: await Promise.resolve(this.backend.elevationStatus?.() ?? { state: "unknown" })
|
|
163
|
+
};
|
|
164
|
+
case "open":
|
|
165
|
+
return this.open(request);
|
|
166
|
+
case "close":
|
|
167
|
+
return this.close(request);
|
|
168
|
+
case "read":
|
|
169
|
+
return this.read(request);
|
|
170
|
+
case "watch.start":
|
|
171
|
+
return this.watchStart(request);
|
|
172
|
+
case "watch.poll":
|
|
173
|
+
return this.watchPoll(request);
|
|
174
|
+
case "watch.stop":
|
|
175
|
+
return this.watchStop(request);
|
|
176
|
+
case "shutdown":
|
|
177
|
+
await this.shutdown("request");
|
|
178
|
+
return { stopped: true };
|
|
179
|
+
default:
|
|
180
|
+
throw new ReaderProtocolError("UNKNOWN_COMMAND", `unsupported reader command: ${String(request?.command)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async getHandle(pid, requested) {
|
|
184
|
+
const existing = this.handles.get(pid);
|
|
185
|
+
if (existing && (!requested || requested === existing.id))
|
|
186
|
+
return existing;
|
|
187
|
+
if (requested && existing?.id !== requested) {
|
|
188
|
+
throw new ReaderProtocolError("HANDLE_MISMATCH", `handle ${requested} is not open for pid ${pid}`);
|
|
189
|
+
}
|
|
190
|
+
const handle = await this.backend.openProcess(pid, DEFAULT_RIGHTS);
|
|
191
|
+
this.handles.set(pid, handle);
|
|
192
|
+
return handle;
|
|
193
|
+
}
|
|
194
|
+
async open(request) {
|
|
195
|
+
const pid = boundedPid(request.pid);
|
|
196
|
+
const rights = request.rights?.length ? [...request.rights] : [...DEFAULT_RIGHTS];
|
|
197
|
+
const handle = await this.backend.openProcess(pid, rights);
|
|
198
|
+
const previous = this.handles.get(pid);
|
|
199
|
+
if (previous && previous !== handle)
|
|
200
|
+
await Promise.resolve(previous.close()).catch(() => undefined);
|
|
201
|
+
this.handles.set(pid, handle);
|
|
202
|
+
return { pid, handle: handle.id, rights };
|
|
203
|
+
}
|
|
204
|
+
async close(request) {
|
|
205
|
+
const pid = request.pid === undefined ? undefined : boundedPid(request.pid);
|
|
206
|
+
const handle = pid === undefined
|
|
207
|
+
? [...this.handles.values()].find(candidate => candidate.id === request.handle)
|
|
208
|
+
: this.handles.get(pid);
|
|
209
|
+
if (!handle)
|
|
210
|
+
return { closed: false, pid, handle: request.handle };
|
|
211
|
+
await Promise.resolve(handle.close());
|
|
212
|
+
if (pid !== undefined)
|
|
213
|
+
this.handles.delete(pid);
|
|
214
|
+
else
|
|
215
|
+
for (const [key, candidate] of this.handles)
|
|
216
|
+
if (candidate === handle)
|
|
217
|
+
this.handles.delete(key);
|
|
218
|
+
for (const watch of this.watches.values()) {
|
|
219
|
+
if (watch.handle === handle)
|
|
220
|
+
watch.stopped = true;
|
|
221
|
+
}
|
|
222
|
+
return { closed: true, pid, handle: handle.id };
|
|
223
|
+
}
|
|
224
|
+
async read(request) {
|
|
225
|
+
const pid = boundedPid(request.pid);
|
|
226
|
+
const address = hexAddress(request.address);
|
|
227
|
+
const size = boundedSize(request.size, "size");
|
|
228
|
+
const handle = await this.getHandle(pid, request.handle);
|
|
229
|
+
const startedAt = this.now();
|
|
230
|
+
const native = bytesFromResult(await this.backend.readProcessMemory(handle, address, size));
|
|
231
|
+
return {
|
|
232
|
+
pid,
|
|
233
|
+
handle: handle.id,
|
|
234
|
+
address: `0x${address.toString(16)}`,
|
|
235
|
+
requestedSize: size,
|
|
236
|
+
bytesRead: native.bytesRead,
|
|
237
|
+
dataHex: toHex(native.bytes),
|
|
238
|
+
complete: native.bytesRead === size,
|
|
239
|
+
startedAt,
|
|
240
|
+
finishedAt: this.now()
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
async watchStart(request) {
|
|
244
|
+
const pid = boundedPid(request.pid);
|
|
245
|
+
const address = hexAddress(request.address);
|
|
246
|
+
const size = boundedSize(request.size, "size", 1024 * 1024);
|
|
247
|
+
const intervalMs = Math.min(60 * 60 * 1000, Math.max(10, Math.floor(Number(request.intervalMs ?? 1000))));
|
|
248
|
+
const maxSamples = Math.min(100000, Math.max(1, Math.floor(Number(request.maxSamples ?? 0) || 100000)));
|
|
249
|
+
const handle = await this.getHandle(pid, request.handle);
|
|
250
|
+
const id = `watch-${++this.watchCounter}`;
|
|
251
|
+
this.watches.set(id, {
|
|
252
|
+
id,
|
|
253
|
+
pid,
|
|
254
|
+
address,
|
|
255
|
+
size,
|
|
256
|
+
intervalMs,
|
|
257
|
+
changeOnly: request.changeOnly === true,
|
|
258
|
+
maxSamples,
|
|
259
|
+
handle,
|
|
260
|
+
sequence: 0,
|
|
261
|
+
nextDue: this.now(),
|
|
262
|
+
stopped: false
|
|
263
|
+
});
|
|
264
|
+
return { watchId: id, pid, handle: handle.id, address: `0x${address.toString(16)}`, size, intervalMs, changeOnly: request.changeOnly === true, maxSamples };
|
|
265
|
+
}
|
|
266
|
+
async watchPoll(request) {
|
|
267
|
+
if (!request.watchId || typeof request.watchId !== "string")
|
|
268
|
+
throw new ReaderProtocolError("INVALID_WATCH", "watchId is required");
|
|
269
|
+
const watch = this.watches.get(request.watchId);
|
|
270
|
+
if (!watch || watch.stopped)
|
|
271
|
+
return { watchId: request.watchId, stopped: true, events: [] };
|
|
272
|
+
const now = this.now();
|
|
273
|
+
if (now < watch.nextDue)
|
|
274
|
+
return { watchId: watch.id, stopped: false, events: [], nextDue: watch.nextDue };
|
|
275
|
+
watch.nextDue = now + watch.intervalMs;
|
|
276
|
+
if (watch.sequence >= watch.maxSamples) {
|
|
277
|
+
watch.stopped = true;
|
|
278
|
+
return { watchId: watch.id, stopped: true, reason: "max_samples", events: [] };
|
|
279
|
+
}
|
|
280
|
+
if (this.backend.isProcessAlive && !(await this.backend.isProcessAlive(watch.pid))) {
|
|
281
|
+
watch.stopped = true;
|
|
282
|
+
return { watchId: watch.id, stopped: true, reason: "target_exited", events: [{ type: "target_exited", pid: watch.pid, sequence: watch.sequence + 1, timestamp: now }] };
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const native = bytesFromResult(await this.backend.readProcessMemory(watch.handle, watch.address, watch.size));
|
|
286
|
+
const changed = !watch.lastBytes || !equalBytes(watch.lastBytes, native.bytes);
|
|
287
|
+
watch.sequence += 1;
|
|
288
|
+
const event = {
|
|
289
|
+
type: changed ? "snapshot" : "unchanged",
|
|
290
|
+
watchId: watch.id,
|
|
291
|
+
pid: watch.pid,
|
|
292
|
+
sequence: watch.sequence,
|
|
293
|
+
timestamp: this.now(),
|
|
294
|
+
address: `0x${watch.address.toString(16)}`,
|
|
295
|
+
requestedSize: watch.size,
|
|
296
|
+
bytesRead: native.bytesRead,
|
|
297
|
+
complete: native.bytesRead === watch.size,
|
|
298
|
+
dataHex: toHex(native.bytes),
|
|
299
|
+
changed
|
|
300
|
+
};
|
|
301
|
+
const previous = watch.lastBytes;
|
|
302
|
+
watch.lastBytes = new Uint8Array(native.bytes);
|
|
303
|
+
if (watch.changeOnly && !changed)
|
|
304
|
+
return { watchId: watch.id, stopped: false, events: [], sequence: watch.sequence };
|
|
305
|
+
return { watchId: watch.id, stopped: false, events: [event], previousHex: previous ? toHex(previous) : undefined, sequence: watch.sequence };
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
watch.stopped = true;
|
|
309
|
+
return { watchId: watch.id, stopped: true, reason: "reader_error", events: [{ type: "reader_error", watchId: watch.id, pid: watch.pid, sequence: watch.sequence + 1, timestamp: this.now(), error: serializeError(error, this.backend) }] };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async watchStop(request) {
|
|
313
|
+
if (!request.watchId || typeof request.watchId !== "string")
|
|
314
|
+
throw new ReaderProtocolError("INVALID_WATCH", "watchId is required");
|
|
315
|
+
const watch = this.watches.get(request.watchId);
|
|
316
|
+
if (!watch)
|
|
317
|
+
return { watchId: request.watchId, stopped: false };
|
|
318
|
+
watch.stopped = true;
|
|
319
|
+
this.watches.delete(request.watchId);
|
|
320
|
+
return { watchId: request.watchId, stopped: true, sequence: watch.sequence };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function equalBytes(left, right) {
|
|
324
|
+
if (left.byteLength !== right.byteLength)
|
|
325
|
+
return false;
|
|
326
|
+
for (let index = 0; index < left.byteLength; index += 1)
|
|
327
|
+
if (left[index] !== right[index])
|
|
328
|
+
return false;
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
function serializeError(error, backend) {
|
|
332
|
+
if (error instanceof ReaderProtocolError) {
|
|
333
|
+
return { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) };
|
|
334
|
+
}
|
|
335
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
336
|
+
return {
|
|
337
|
+
code: "READER_ERROR",
|
|
338
|
+
message,
|
|
339
|
+
elevation: backend.elevationStatus ? backend.elevationStatus() : { state: "unknown" }
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/** Start a JSONL broker process over stdio. */
|
|
343
|
+
export function serveReaderBroker(options = {}) {
|
|
344
|
+
const broker = new ReaderBroker({ ...options, attachStdio: false });
|
|
345
|
+
if (options.attachStdio !== false) {
|
|
346
|
+
const input = options.input ?? stdin;
|
|
347
|
+
const output = options.output ?? stdout;
|
|
348
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
349
|
+
// Preserve JSONL request order so open/read/close sequences cannot race.
|
|
350
|
+
let pending = Promise.resolve();
|
|
351
|
+
rl.on("line", line => {
|
|
352
|
+
pending = pending.then(async () => {
|
|
353
|
+
if (!line.trim())
|
|
354
|
+
return;
|
|
355
|
+
let request;
|
|
356
|
+
try {
|
|
357
|
+
request = JSON.parse(line);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
const response = {
|
|
361
|
+
ok: false,
|
|
362
|
+
command: "parse",
|
|
363
|
+
timestamp: Date.now(),
|
|
364
|
+
error: { code: "INVALID_JSON", message: error instanceof Error ? error.message : String(error) }
|
|
365
|
+
};
|
|
366
|
+
output.write(`${JSON.stringify(response)}\n`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const response = await broker.handle(request);
|
|
370
|
+
output.write(`${JSON.stringify(response)}\n`);
|
|
371
|
+
if (request.command === "shutdown")
|
|
372
|
+
rl.close();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
const close = () => { void broker.shutdown("request"); };
|
|
376
|
+
input.once("close", close);
|
|
377
|
+
input.once("error", close);
|
|
378
|
+
}
|
|
379
|
+
return broker;
|
|
380
|
+
}
|
|
381
|
+
/** Small typed client used by the CLI; transport owns process/UAC reuse. */
|
|
382
|
+
export class ReaderClient {
|
|
383
|
+
transport;
|
|
384
|
+
requestTimeoutMs;
|
|
385
|
+
counter = 0;
|
|
386
|
+
constructor(options) {
|
|
387
|
+
this.transport = options.transport;
|
|
388
|
+
this.requestTimeoutMs = Math.max(1, Math.floor(options.requestTimeoutMs ?? 30_000));
|
|
389
|
+
}
|
|
390
|
+
async request(request) {
|
|
391
|
+
const id = request.id ?? `req-${++this.counter}`;
|
|
392
|
+
const line = await withTimeout(this.transport.request(JSON.stringify({ ...request, id })), this.requestTimeoutMs);
|
|
393
|
+
const response = JSON.parse(line);
|
|
394
|
+
if (!response || typeof response !== "object" || typeof response.ok !== "boolean") {
|
|
395
|
+
throw new ReaderProtocolError("INVALID_RESPONSE", "reader broker returned an invalid JSONL response");
|
|
396
|
+
}
|
|
397
|
+
return response;
|
|
398
|
+
}
|
|
399
|
+
close() {
|
|
400
|
+
return Promise.resolve(this.transport.close?.());
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function withTimeout(promise, timeoutMs) {
|
|
404
|
+
return new Promise((resolve, reject) => {
|
|
405
|
+
const timer = setTimeout(() => reject(new ReaderProtocolError("REQUEST_TIMEOUT", `reader request timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
406
|
+
timer.unref?.();
|
|
407
|
+
promise.then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
export function createElevatedLaunchSpec(file, args = []) {
|
|
411
|
+
if (!file.trim())
|
|
412
|
+
throw new TypeError("elevated broker file must be non-empty");
|
|
413
|
+
return Object.freeze({ file, args: [...args], verb: "runas", windowsHide: false });
|
|
414
|
+
}
|
|
415
|
+
/** Reuse one broker client and invoke the launcher only after the first miss. */
|
|
416
|
+
export class ReaderBrokerPool {
|
|
417
|
+
client;
|
|
418
|
+
launching;
|
|
419
|
+
launcher;
|
|
420
|
+
launchSpec;
|
|
421
|
+
requestTimeoutMs;
|
|
422
|
+
constructor(launcher, launchSpec, requestTimeoutMs) {
|
|
423
|
+
this.launcher = launcher;
|
|
424
|
+
this.launchSpec = launchSpec;
|
|
425
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
426
|
+
}
|
|
427
|
+
async request(request) {
|
|
428
|
+
const client = await this.getClient();
|
|
429
|
+
try {
|
|
430
|
+
return await client.request(request);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
// A dead broker is discarded; the next request gets a fresh UAC launch.
|
|
434
|
+
this.client = undefined;
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async close() {
|
|
439
|
+
const client = this.client;
|
|
440
|
+
this.client = undefined;
|
|
441
|
+
await client?.close();
|
|
442
|
+
}
|
|
443
|
+
async getClient() {
|
|
444
|
+
if (this.client)
|
|
445
|
+
return this.client;
|
|
446
|
+
if (!this.launching) {
|
|
447
|
+
this.launching = this.launcher.launch(this.launchSpec).then(result => {
|
|
448
|
+
this.client = new ReaderClient({ transport: result.transport, requestTimeoutMs: this.requestTimeoutMs });
|
|
449
|
+
return this.client;
|
|
450
|
+
}).catch(error => {
|
|
451
|
+
throw new ReaderProtocolError("ELEVATION_FAILED", error instanceof Error ? error.message : String(error), {
|
|
452
|
+
verb: this.launchSpec.verb,
|
|
453
|
+
requestedRights: [...DEFAULT_RIGHTS],
|
|
454
|
+
elevation: "launch rejected or cancelled"
|
|
455
|
+
});
|
|
456
|
+
}).finally(() => { this.launching = undefined; });
|
|
457
|
+
}
|
|
458
|
+
return this.launching;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ReaderClient, ReaderBrokerPool, ReaderProtocolError, createElevatedLaunchSpec } from "./reader-broker.js";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { serveReaderBroker } from "./reader-broker.js";
|
|
6
|
+
import { createWindowsNativeReader } from "./windows-reader.js";
|
|
7
|
+
function argument(name) {
|
|
8
|
+
const index = process.argv.indexOf(name);
|
|
9
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
10
|
+
}
|
|
11
|
+
const listen = argument("--listen");
|
|
12
|
+
const port = Number(argument("--port") ?? "0");
|
|
13
|
+
const token = argument("--token");
|
|
14
|
+
const metadata = argument("--metadata");
|
|
15
|
+
let server;
|
|
16
|
+
const broker = serveReaderBroker({
|
|
17
|
+
attachStdio: !listen,
|
|
18
|
+
backend: process.env.WOWDUMP_USE_FAKE_READER === "1" ? undefined : createWindowsNativeReader(),
|
|
19
|
+
onShutdown: async () => {
|
|
20
|
+
server?.close();
|
|
21
|
+
if (metadata)
|
|
22
|
+
await rm(metadata, { force: true }).catch(() => undefined);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
if (listen) {
|
|
26
|
+
if (listen !== "127.0.0.1" || !token || !metadata || !Number.isSafeInteger(port) || port < 0 || port > 65535) {
|
|
27
|
+
throw new Error("--listen requires 127.0.0.1, a valid --port, --token and --metadata");
|
|
28
|
+
}
|
|
29
|
+
server = createServer(socket => {
|
|
30
|
+
socket.setEncoding("utf8");
|
|
31
|
+
let buffer = "";
|
|
32
|
+
let pending = Promise.resolve();
|
|
33
|
+
socket.on("data", chunk => {
|
|
34
|
+
buffer += chunk;
|
|
35
|
+
while (buffer.includes("\n")) {
|
|
36
|
+
const newline = buffer.indexOf("\n");
|
|
37
|
+
const line = buffer.slice(0, newline);
|
|
38
|
+
buffer = buffer.slice(newline + 1);
|
|
39
|
+
pending = pending.then(async () => {
|
|
40
|
+
try {
|
|
41
|
+
const envelope = JSON.parse(line);
|
|
42
|
+
if (envelope.token !== token || !envelope.request) {
|
|
43
|
+
socket.write(`${JSON.stringify({ ok: false, command: "auth", timestamp: Date.now(), error: { code: "AUTH_FAILED", message: "invalid reader broker token" } })}\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
socket.write(`${JSON.stringify(await broker.handle(envelope.request))}\n`);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
socket.write(`${JSON.stringify({ ok: false, command: "parse", timestamp: Date.now(), error: { code: "INVALID_JSON", message: error instanceof Error ? error.message : String(error) } })}\n`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
server.listen(port, listen, async () => {
|
|
56
|
+
await mkdir(dirname(metadata), { recursive: true });
|
|
57
|
+
const address = server?.address();
|
|
58
|
+
if (!address || typeof address === "string")
|
|
59
|
+
throw new Error("reader broker did not acquire a TCP endpoint");
|
|
60
|
+
await writeFile(metadata, `${JSON.stringify({ schema: "wowdump.reader-broker.v1", host: listen, port: address.port, token, pid: process.pid })}\n`, { encoding: "utf8", mode: 0o600 });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const shutdown = () => {
|
|
64
|
+
void broker.shutdown("request").catch(() => process.exitCode = 1);
|
|
65
|
+
};
|
|
66
|
+
process.once("SIGINT", shutdown);
|
|
67
|
+
process.once("SIGTERM", shutdown);
|