wowdump 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/adapters.js +101 -0
- package/dist/agent.js +1335 -0
- package/dist/analysis-path.js +38 -0
- package/dist/analysis-process-log.js +146 -0
- package/dist/broker-client.js +411 -0
- package/dist/broker-codec.js +148 -0
- package/dist/broker-core.js +1045 -0
- package/dist/broker-gateway.js +447 -0
- package/dist/broker-ledger.js +196 -0
- package/dist/broker-main.js +291 -0
- package/dist/broker-protocol.js +119 -0
- package/dist/broker-runtime.js +1283 -0
- package/dist/broker-server.js +466 -0
- package/dist/build-bundle-loader.js +183 -0
- package/dist/build-bundle.js +11 -0
- package/dist/discovery.js +59 -0
- package/dist/dry-run.js +38 -0
- package/dist/error-log.js +71 -0
- package/dist/focus-errors.js +63 -0
- package/dist/focus-service.js +1855 -0
- package/dist/focused-session.js +1357 -0
- package/dist/frida-runtime.js +711 -0
- package/dist/mcp-main.js +51 -0
- package/dist/mcp.js +924 -0
- package/dist/observability.js +41 -0
- package/dist/process-log-lock.js +181 -0
- package/dist/processes.js +47 -0
- package/dist/runtime-config.js +399 -0
- package/dist/session.js +145 -0
- package/dist/storage.js +12 -0
- package/dist/types.js +26 -0
- package/dist/wow-analysis.js +1430 -0
- package/package.json +64 -13
- package/resources/builds/retail/12.0.7.68974/build-profile.json +290 -0
- package/resources/builds/retail/12.0.7.68974/data-sources.json +1633 -0
- package/resources/builds/retail/12.0.7.68974/lua-targets.jsonl +5130 -0
- package/resources/builds/retail/12.0.7.68974/manifest.json +63 -0
- package/resources/builds/retail/12.0.7.68974/signatures.json +260 -0
- package/index.js +0 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { BrokerBootstrapCoordinator, WindowsBrokerBootstrapPlatform } from "./broker-client.js";
|
|
9
|
+
import { BROKER_MANAGED_COMMAND } from "./frida-runtime.js";
|
|
10
|
+
import { BrokerClient, NamedPipeClientTransport } from "./broker-client.js";
|
|
11
|
+
import { BrokerProtocolError, canonicalArtifactRoot, FrameDecoder, pipeNameForSid } from "./broker-protocol.js";
|
|
12
|
+
const FOCUS_READ_OPERATIONS = new Set([
|
|
13
|
+
"wow_focus_read",
|
|
14
|
+
"wow_watch_read",
|
|
15
|
+
"wow_focus_status",
|
|
16
|
+
"wow_watch_status"
|
|
17
|
+
]);
|
|
18
|
+
/** MCP-side Frida executor. It never imports the Frida runtime implementation. */
|
|
19
|
+
export class BrokerFridaGateway {
|
|
20
|
+
clientId = randomUUID();
|
|
21
|
+
client;
|
|
22
|
+
transport;
|
|
23
|
+
started = false;
|
|
24
|
+
scriptTargets = new Map();
|
|
25
|
+
sessionTargets = new Map();
|
|
26
|
+
pipeName;
|
|
27
|
+
requestTimeoutMs;
|
|
28
|
+
connectTimeoutMs;
|
|
29
|
+
options;
|
|
30
|
+
activeLauncher;
|
|
31
|
+
sid;
|
|
32
|
+
artifactRoot;
|
|
33
|
+
connectPromise;
|
|
34
|
+
closePromise;
|
|
35
|
+
releaseReceipt;
|
|
36
|
+
constructor(options = {}) {
|
|
37
|
+
this.options = options;
|
|
38
|
+
this.sid = options.sid ?? process.env.WOW_BROKER_SID ?? defaultBrokerSid();
|
|
39
|
+
this.pipeName = options.pipeName ?? pipeNameForSid(this.sid);
|
|
40
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
41
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? 10_000;
|
|
42
|
+
this.artifactRoot = canonicalArtifactRoot(options.artifactDir ?? process.env.WOWDUMP_RUNTIME_DIR ?? defaultRuntimeRoot());
|
|
43
|
+
}
|
|
44
|
+
async execute(request, context) {
|
|
45
|
+
const remembered = typeof request.scriptId === "string"
|
|
46
|
+
? this.scriptTargets.get(request.scriptId)
|
|
47
|
+
: typeof request.sessionId === "string" ? this.sessionTargets.get(request.sessionId) : undefined;
|
|
48
|
+
const pid = context?.pid ?? request.pid ?? remembered?.pid ?? null;
|
|
49
|
+
const buildKey = context?.buildKey ?? request.buildKey ?? remembered?.buildKey ?? null;
|
|
50
|
+
const requestId = suppliedRequestId(request) ?? randomUUID();
|
|
51
|
+
const managed = request[BROKER_MANAGED_COMMAND];
|
|
52
|
+
const wireRequest = { ...request };
|
|
53
|
+
const rememberedResourceId = remembered?.resourceId;
|
|
54
|
+
if (rememberedResourceId && !wireRequest.resourceId && (request.operation === "detach" || request.operation === "script_unload"))
|
|
55
|
+
wireRequest.resourceId = rememberedResourceId;
|
|
56
|
+
if (managed)
|
|
57
|
+
wireRequest.mutation = managedMutationContract(this.clientId, requestId, pid, buildKey, managed.purpose, managed.irreversibleReason);
|
|
58
|
+
else if (isRecord(request.mutation))
|
|
59
|
+
wireRequest.mutation = bindMutation(request.mutation, this.clientId, requestId, pid, buildKey);
|
|
60
|
+
if (isRecord(request.launch))
|
|
61
|
+
wireRequest.launch = bindLaunch(request.launch, this.clientId, requestId);
|
|
62
|
+
const effectiveContext = context ?? (remembered ? {
|
|
63
|
+
pid: remembered.pid ?? undefined,
|
|
64
|
+
buildKey: remembered.buildKey ?? undefined,
|
|
65
|
+
executable: remembered.executable,
|
|
66
|
+
processStartTime: remembered.processStartTime,
|
|
67
|
+
moduleBase: remembered.moduleBase,
|
|
68
|
+
moduleIdentity: remembered.moduleIdentity
|
|
69
|
+
} : undefined);
|
|
70
|
+
const payload = {
|
|
71
|
+
request: jsonSafe(wireRequest),
|
|
72
|
+
context: effectiveContext ? jsonSafe({
|
|
73
|
+
pid: effectiveContext.pid,
|
|
74
|
+
buildKey: effectiveContext.buildKey,
|
|
75
|
+
flavor: effectiveContext.flavor,
|
|
76
|
+
executable: effectiveContext.executable,
|
|
77
|
+
processStartTime: effectiveContext.processStartTime,
|
|
78
|
+
moduleBase: effectiveContext.moduleBase,
|
|
79
|
+
moduleIdentity: effectiveContext.moduleIdentity
|
|
80
|
+
}) : null
|
|
81
|
+
};
|
|
82
|
+
const result = await this.requestWithRecovery("frida_command", {
|
|
83
|
+
requestId,
|
|
84
|
+
pid,
|
|
85
|
+
buildKey,
|
|
86
|
+
payload,
|
|
87
|
+
timeoutMs: request.timeoutMs ?? this.requestTimeoutMs
|
|
88
|
+
}, isMutatingFridaRequest(request));
|
|
89
|
+
const normalized = (isRecord(result) ? result : { value: result });
|
|
90
|
+
const identity = isRecord(normalized.identity) ? normalized.identity : {};
|
|
91
|
+
const identityFields = {
|
|
92
|
+
executable: typeof identity.executable === "string" ? identity.executable : typeof normalized.executable === "string" ? normalized.executable : undefined,
|
|
93
|
+
processStartTime: typeof identity.processStartTime === "string" ? identity.processStartTime : typeof normalized.processStartTime === "string" ? normalized.processStartTime : undefined,
|
|
94
|
+
moduleBase: typeof identity.moduleBase === "string" ? identity.moduleBase : typeof normalized.moduleBase === "string" ? normalized.moduleBase : undefined,
|
|
95
|
+
moduleIdentity: typeof identity.moduleIdentity === "string" ? identity.moduleIdentity : typeof normalized.moduleIdentity === "string" ? normalized.moduleIdentity : undefined
|
|
96
|
+
};
|
|
97
|
+
if (request.operation === "attach" && typeof normalized.sessionId === "string")
|
|
98
|
+
this.sessionTargets.set(normalized.sessionId, { pid, buildKey, resourceId: typeof normalized.resourceId === "string" ? normalized.resourceId : undefined, ...identityFields });
|
|
99
|
+
if (request.operation === "script_load" && typeof normalized.scriptId === "string")
|
|
100
|
+
this.scriptTargets.set(normalized.scriptId, { pid, buildKey, resourceId: typeof normalized.resourceId === "string" ? normalized.resourceId : undefined, ...identityFields });
|
|
101
|
+
if (request.operation === "script_unload" && typeof request.scriptId === "string")
|
|
102
|
+
this.scriptTargets.delete(request.scriptId);
|
|
103
|
+
if (request.operation === "detach" && typeof request.sessionId === "string")
|
|
104
|
+
this.sessionTargets.delete(request.sessionId);
|
|
105
|
+
return normalized;
|
|
106
|
+
}
|
|
107
|
+
async invoke(operation, input = {}) {
|
|
108
|
+
const pid = Number(input.pid);
|
|
109
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
110
|
+
throw new BrokerProtocolError("PID_REQUIRED", `${operation} requires a positive pid`);
|
|
111
|
+
const buildKey = typeof input.buildKey === "string" && input.buildKey.trim() ? input.buildKey : undefined;
|
|
112
|
+
if (!buildKey)
|
|
113
|
+
throw new BrokerProtocolError("PID_BUILD_MISMATCH", `${operation} requires buildKey`);
|
|
114
|
+
const requestId = randomUUID();
|
|
115
|
+
const result = await this.requestWithRecovery(operation, {
|
|
116
|
+
requestId,
|
|
117
|
+
pid,
|
|
118
|
+
buildKey,
|
|
119
|
+
payload: jsonSafe(input),
|
|
120
|
+
timeoutMs: typeof input.durationMs === "number"
|
|
121
|
+
? Math.max(this.requestTimeoutMs, Math.min(input.durationMs + 10_000, 86_410_000))
|
|
122
|
+
: this.requestTimeoutMs
|
|
123
|
+
}, !FOCUS_READ_OPERATIONS.has(operation));
|
|
124
|
+
if (!isRecord(result))
|
|
125
|
+
return { value: result };
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
async status() {
|
|
129
|
+
try {
|
|
130
|
+
await this.ensureConnected(false);
|
|
131
|
+
const result = await this.client.request("broker_status", { payload: {}, timeoutMs: this.requestTimeoutMs });
|
|
132
|
+
this.validateReadyResult(result);
|
|
133
|
+
return isRecord(result) ? result : { status: result };
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return { status: "stopped", error: errorText(error), pipeName: this.pipeName };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async start() {
|
|
140
|
+
await this.ensureConnected(true);
|
|
141
|
+
return { status: "ready", pipeName: this.pipeName, clientId: this.clientId, artifactRoot: this.artifactRoot };
|
|
142
|
+
}
|
|
143
|
+
async reconnect() {
|
|
144
|
+
await this.closeTransport();
|
|
145
|
+
await this.ensureConnected(true);
|
|
146
|
+
return { status: "ready", pipeName: this.pipeName, clientId: this.clientId, artifactRoot: this.artifactRoot };
|
|
147
|
+
}
|
|
148
|
+
async close() {
|
|
149
|
+
if (this.closePromise)
|
|
150
|
+
return this.closePromise;
|
|
151
|
+
this.closePromise = this.closeInternal().finally(() => { this.closePromise = undefined; });
|
|
152
|
+
return this.closePromise;
|
|
153
|
+
}
|
|
154
|
+
get brokerPipeName() { return this.pipeName; }
|
|
155
|
+
get brokerClientId() { return this.clientId; }
|
|
156
|
+
get lastReleaseReceipt() { return this.releaseReceipt; }
|
|
157
|
+
async requestWithRecovery(operation, options, mutationLike) {
|
|
158
|
+
await this.ensureConnected(true);
|
|
159
|
+
try {
|
|
160
|
+
return await this.client.request(operation, options);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (!isRecoverableTransportError(error))
|
|
164
|
+
throw error;
|
|
165
|
+
await this.closeTransport();
|
|
166
|
+
await this.ensureConnected(true);
|
|
167
|
+
if (!mutationLike)
|
|
168
|
+
return this.client.request(operation, options);
|
|
169
|
+
const status = await this.client.request("request_status", {
|
|
170
|
+
requestId: randomUUID(),
|
|
171
|
+
pid: options.pid,
|
|
172
|
+
buildKey: options.buildKey,
|
|
173
|
+
payload: { requestId: options.requestId },
|
|
174
|
+
timeoutMs: options.timeoutMs
|
|
175
|
+
});
|
|
176
|
+
if (!isRecord(status))
|
|
177
|
+
throw new BrokerProtocolError("MUTATION_OUTCOME_UNKNOWN", `request ${options.requestId} has no durable outcome`);
|
|
178
|
+
const state = typeof status.state === "string" ? status.state : "outcome_unknown";
|
|
179
|
+
if ((state === "completed" || state === "rolled_back") && Object.hasOwn(status, "result"))
|
|
180
|
+
return status.result;
|
|
181
|
+
if (state === "outcome_unknown")
|
|
182
|
+
throw new BrokerProtocolError("MUTATION_OUTCOME_UNKNOWN", `request ${options.requestId} outcome is unknown`, "Inspect request_status evidence before issuing a new mutation.");
|
|
183
|
+
throw new BrokerProtocolError("REQUEST_TIMEOUT", `request ${options.requestId} remains ${state}`, "Query request_status again; do not replay the mutation.");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async ensureConnected(autoStart) {
|
|
187
|
+
if (this.client)
|
|
188
|
+
return;
|
|
189
|
+
if (this.connectPromise)
|
|
190
|
+
return this.connectPromise;
|
|
191
|
+
this.connectPromise = this.connectInternal(autoStart).finally(() => { this.connectPromise = undefined; });
|
|
192
|
+
return this.connectPromise;
|
|
193
|
+
}
|
|
194
|
+
async connectInternal(autoStart) {
|
|
195
|
+
if (this.client)
|
|
196
|
+
return;
|
|
197
|
+
const transportFactory = this.options.transportFactory ?? (pipe => new NamedPipeClientTransport(pipe));
|
|
198
|
+
let transport = transportFactory(this.pipeName);
|
|
199
|
+
try {
|
|
200
|
+
const statusFrame = await withTimeout(transport.request(encodeStatusFrame(this.clientId), this.connectTimeoutMs), this.connectTimeoutMs);
|
|
201
|
+
this.validateReadyFrame(statusFrame);
|
|
202
|
+
this.transport = transport;
|
|
203
|
+
this.client = new BrokerClient(transport, this.clientId);
|
|
204
|
+
this.started = true;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
catch (firstError) {
|
|
208
|
+
await transport.close().catch(() => undefined);
|
|
209
|
+
if (firstError instanceof BrokerProtocolError && firstError.code === "BROKER_ARTIFACT_ROOT_MISMATCH")
|
|
210
|
+
throw firstError;
|
|
211
|
+
if (!autoStart)
|
|
212
|
+
throw firstError;
|
|
213
|
+
const launcher = this.options.launcher
|
|
214
|
+
?? (process.env.WOW_BROKER_TEST_FIXTURE === "1"
|
|
215
|
+
? new ProcessBrokerLauncher(this.options)
|
|
216
|
+
: new CoordinatorBrokerLauncher(this.options, this.sid));
|
|
217
|
+
this.activeLauncher = launcher;
|
|
218
|
+
try {
|
|
219
|
+
await launcher.start(this.pipeName);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
await launcher.stop().catch(() => undefined);
|
|
223
|
+
this.activeLauncher = undefined;
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
const deadline = Date.now() + this.connectTimeoutMs;
|
|
227
|
+
while (Date.now() < deadline) {
|
|
228
|
+
transport = transportFactory(this.pipeName);
|
|
229
|
+
try {
|
|
230
|
+
const statusFrame = await withTimeout(transport.request(encodeStatusFrame(this.clientId), this.connectTimeoutMs), 1_000);
|
|
231
|
+
this.validateReadyFrame(statusFrame);
|
|
232
|
+
this.transport = transport;
|
|
233
|
+
this.client = new BrokerClient(transport, this.clientId);
|
|
234
|
+
this.started = true;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
await transport.close().catch(() => undefined);
|
|
239
|
+
if (error instanceof BrokerProtocolError && error.code === "BROKER_ARTIFACT_ROOT_MISMATCH")
|
|
240
|
+
throw error;
|
|
241
|
+
await delay(50);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
throw new Error(`Broker did not become ready on ${this.pipeName}: ${errorText(firstError)}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async closeInternal() {
|
|
248
|
+
await this.connectPromise?.catch(() => undefined);
|
|
249
|
+
try {
|
|
250
|
+
if (this.client) {
|
|
251
|
+
const value = await this.client.request("client_release", { payload: {}, timeoutMs: Math.min(this.requestTimeoutMs, 5_000) });
|
|
252
|
+
if (!isRecord(value) || value.released !== true || value.clientId !== this.clientId || value.remainingResourceRefs !== 0 || !Array.isArray(value.residualResourceIds) || value.residualResourceIds.length !== 0) {
|
|
253
|
+
throw new BrokerProtocolError("CLEANUP_TIMEOUT", `Broker client release did not prove zero references: ${JSON.stringify(value)}`, "Inspect Broker resources and retry client release.");
|
|
254
|
+
}
|
|
255
|
+
this.releaseReceipt = { ...value };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await this.closeTransport();
|
|
260
|
+
this.scriptTargets.clear();
|
|
261
|
+
this.sessionTargets.clear();
|
|
262
|
+
await this.activeLauncher?.stop().catch(() => undefined);
|
|
263
|
+
this.activeLauncher = undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async closeTransport() {
|
|
267
|
+
await this.transport?.close().catch(() => undefined);
|
|
268
|
+
this.transport = undefined;
|
|
269
|
+
this.client = undefined;
|
|
270
|
+
this.started = false;
|
|
271
|
+
}
|
|
272
|
+
validateReadyFrame(frame) {
|
|
273
|
+
const response = new FrameDecoder().push(frame)[0];
|
|
274
|
+
if (!response?.ok)
|
|
275
|
+
throw new BrokerProtocolError("BROKER_ARTIFACT_ROOT_MISMATCH", response?.error?.message ?? "Broker ready identity is unavailable", "Restart Broker with the configured artifact root.");
|
|
276
|
+
this.validateReadyResult(response.result);
|
|
277
|
+
}
|
|
278
|
+
validateReadyResult(value) {
|
|
279
|
+
if (!isRecord(value) || value.status !== "ready" || typeof value.artifactRoot !== "string") {
|
|
280
|
+
throw new BrokerProtocolError("BROKER_ARTIFACT_ROOT_MISMATCH", "Broker status omitted canonical artifactRoot", "Restart Broker with the configured artifact root.");
|
|
281
|
+
}
|
|
282
|
+
let actual;
|
|
283
|
+
try {
|
|
284
|
+
actual = canonicalArtifactRoot(value.artifactRoot);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
actual = "";
|
|
288
|
+
}
|
|
289
|
+
if (actual !== this.artifactRoot)
|
|
290
|
+
throw new BrokerProtocolError("BROKER_ARTIFACT_ROOT_MISMATCH", `Broker artifact root mismatch: expected ${this.artifactRoot}, got ${value.artifactRoot}`, "Connect to the Broker serving the configured artifact root.");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Bootstrap-coordinator launcher used by production MCP/collector entries. */
|
|
294
|
+
export class CoordinatorBrokerLauncher {
|
|
295
|
+
options;
|
|
296
|
+
sid;
|
|
297
|
+
coordinator;
|
|
298
|
+
artifactRoot;
|
|
299
|
+
ready;
|
|
300
|
+
constructor(options, sid) {
|
|
301
|
+
this.options = options;
|
|
302
|
+
this.sid = sid;
|
|
303
|
+
this.coordinator = new BrokerBootstrapCoordinator(options.bootstrapPlatform ?? new WindowsBrokerBootstrapPlatform());
|
|
304
|
+
this.artifactRoot = canonicalArtifactRoot(options.artifactDir ?? process.env.WOWDUMP_RUNTIME_DIR ?? defaultRuntimeRoot());
|
|
305
|
+
}
|
|
306
|
+
async start(pipeName) {
|
|
307
|
+
const { entry, workdir } = resolveBrokerLaunchPaths(this.options);
|
|
308
|
+
const ready = await this.coordinator.start({
|
|
309
|
+
sid: this.sid,
|
|
310
|
+
pipeName,
|
|
311
|
+
executable: process.execPath,
|
|
312
|
+
workdir,
|
|
313
|
+
artifactRoot: this.artifactRoot,
|
|
314
|
+
argv: [entry],
|
|
315
|
+
timeoutMs: this.options.connectTimeoutMs
|
|
316
|
+
});
|
|
317
|
+
this.ready = ready;
|
|
318
|
+
}
|
|
319
|
+
async stop() {
|
|
320
|
+
// Broker shutdown is requested by client_release; elevated children are
|
|
321
|
+
// intentionally not force-killed from the MCP process.
|
|
322
|
+
this.ready = undefined;
|
|
323
|
+
}
|
|
324
|
+
get readyIdentity() { return this.ready; }
|
|
325
|
+
}
|
|
326
|
+
/** Explicit legacy launcher kept for injected test fixtures only. */
|
|
327
|
+
export class ProcessBrokerLauncher {
|
|
328
|
+
options;
|
|
329
|
+
child;
|
|
330
|
+
artifactRoot;
|
|
331
|
+
constructor(options) {
|
|
332
|
+
this.options = options;
|
|
333
|
+
this.artifactRoot = canonicalArtifactRoot(options.artifactDir ?? process.env.WOWDUMP_RUNTIME_DIR ?? defaultRuntimeRoot());
|
|
334
|
+
}
|
|
335
|
+
async start(pipeName) {
|
|
336
|
+
if (this.child && !this.child.killed)
|
|
337
|
+
return;
|
|
338
|
+
const { entry, workdir } = resolveBrokerLaunchPaths(this.options);
|
|
339
|
+
if (!existsSync(entry))
|
|
340
|
+
throw new Error(`Broker executable is missing: ${entry}`);
|
|
341
|
+
this.child = spawn(process.execPath, [entry, "--pipe", pipeName], {
|
|
342
|
+
cwd: workdir,
|
|
343
|
+
env: { ...process.env, WOWDUMP_RUNTIME_DIR: this.artifactRoot, WOW_BROKER_ARTIFACT_ROOT: this.artifactRoot },
|
|
344
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
345
|
+
windowsHide: true,
|
|
346
|
+
detached: false
|
|
347
|
+
});
|
|
348
|
+
this.child.stderr?.on("data", chunk => process.stderr.write(`broker: ${String(chunk)}`));
|
|
349
|
+
await new Promise((resolvePromise, reject) => {
|
|
350
|
+
const child = this.child;
|
|
351
|
+
const onError = (error) => { child.off("exit", onExit); reject(error); };
|
|
352
|
+
const onExit = (code) => { child.off("error", onError); if (code !== null && code !== 0)
|
|
353
|
+
reject(new Error(`Broker exited during startup (${code})`)); };
|
|
354
|
+
child.once("error", onError);
|
|
355
|
+
child.once("exit", onExit);
|
|
356
|
+
setTimeout(() => { child.off("error", onError); child.off("exit", onExit); resolvePromise(); }, 100);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async stop() {
|
|
360
|
+
const child = this.child;
|
|
361
|
+
this.child = undefined;
|
|
362
|
+
if (!child || child.killed)
|
|
363
|
+
return;
|
|
364
|
+
child.kill();
|
|
365
|
+
await new Promise(resolvePromise => child.once("exit", () => resolvePromise()));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
export function resolveBrokerLaunchPaths(options = {}) {
|
|
369
|
+
const moduleDistRoot = dirname(fileURLToPath(import.meta.url));
|
|
370
|
+
const workdir = options.brokerWorkdir
|
|
371
|
+
? resolve(options.brokerWorkdir)
|
|
372
|
+
: moduleDistRoot;
|
|
373
|
+
const configuredEntry = options.brokerEntry ?? "broker-main.js";
|
|
374
|
+
const entry = isAbsolute(configuredEntry)
|
|
375
|
+
? configuredEntry
|
|
376
|
+
: resolve(workdir, configuredEntry);
|
|
377
|
+
return { entry, workdir };
|
|
378
|
+
}
|
|
379
|
+
function encodeStatusFrame(clientId) {
|
|
380
|
+
// Imported lazily to keep the gateway's public surface small.
|
|
381
|
+
const body = Buffer.from(JSON.stringify({ schemaVersion: 1, protocolVersion: "2026-07-28", requestId: randomUUID(), clientId, operation: "broker_status", pid: null, buildKey: null, payload: {} }), "utf8");
|
|
382
|
+
const frame = Buffer.allocUnsafe(body.length + 4);
|
|
383
|
+
frame.writeUInt32LE(body.length, 0);
|
|
384
|
+
body.copy(frame, 4);
|
|
385
|
+
return frame;
|
|
386
|
+
}
|
|
387
|
+
function jsonSafe(value) {
|
|
388
|
+
return JSON.parse(JSON.stringify(value, (_key, child) => typeof child === "bigint" ? child.toString() : child === undefined ? null : child));
|
|
389
|
+
}
|
|
390
|
+
function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
391
|
+
function errorText(error) { return error instanceof Error ? error.message : String(error); }
|
|
392
|
+
function isRecoverableTransportError(error) {
|
|
393
|
+
return !(error instanceof BrokerProtocolError) || error.code === "REQUEST_TIMEOUT" || error.code === "BROKER_STOPPED";
|
|
394
|
+
}
|
|
395
|
+
function isMutatingFridaRequest(request) {
|
|
396
|
+
return request.operation === "spawn" || isRecord(request.mutation) || isRecord(request.launch) || [
|
|
397
|
+
"write_memory", "protect_memory", "resume", "kill", "script_load", "script_unload", "script_call", "host_call"
|
|
398
|
+
].includes(request.operation ?? "");
|
|
399
|
+
}
|
|
400
|
+
function managedMutationContract(clientId, requestId, pid, buildKey, purpose, irreversibleReason) {
|
|
401
|
+
return {
|
|
402
|
+
mutationPlanId: `broker-managed:${requestId}`,
|
|
403
|
+
confirmation: { clientId, confirmedAt: new Date().toISOString(), nonce: randomUUID(), irreversibleAcknowledged: true },
|
|
404
|
+
target: { pid, buildKey },
|
|
405
|
+
expectedEffect: {},
|
|
406
|
+
rollback: { mode: "irreversible", irreversibleReason: irreversibleReason ?? "Broker-managed observation instrumentation has resource-ledger cleanup evidence." },
|
|
407
|
+
evidencePlan: { before: {}, after: {}, rollback: {} },
|
|
408
|
+
audit: { actor: "broker-managed-mcp", reason: purpose, requestId }
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function suppliedRequestId(request) {
|
|
412
|
+
return typeof request.requestId === "string" && request.requestId.trim() ? request.requestId : undefined;
|
|
413
|
+
}
|
|
414
|
+
function bindMutation(value, clientId, requestId, pid, buildKey) {
|
|
415
|
+
const confirmation = isRecord(value.confirmation) ? value.confirmation : {};
|
|
416
|
+
const audit = isRecord(value.audit) ? value.audit : {};
|
|
417
|
+
return { ...value, confirmation: { ...confirmation, clientId }, audit: { ...audit, requestId }, target: { pid, buildKey } };
|
|
418
|
+
}
|
|
419
|
+
function bindLaunch(value, clientId, requestId) {
|
|
420
|
+
const confirmation = isRecord(value.confirmation) ? value.confirmation : {};
|
|
421
|
+
const audit = isRecord(value.audit) ? value.audit : {};
|
|
422
|
+
return { ...value, confirmation: { ...confirmation, clientId }, audit: { ...audit, requestId } };
|
|
423
|
+
}
|
|
424
|
+
function delay(ms) { return new Promise(resolvePromise => setTimeout(resolvePromise, ms)); }
|
|
425
|
+
function defaultRuntimeRoot() { return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "wowdump"); }
|
|
426
|
+
function defaultBrokerSid() {
|
|
427
|
+
if (process.platform !== "win32")
|
|
428
|
+
return process.env.USERNAME ?? process.env.USER ?? "default";
|
|
429
|
+
try {
|
|
430
|
+
const output = execFileSync("whoami.exe", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
|
|
431
|
+
const sid = output.match(/S-\d-(?:\d+-)+\d+/i)?.[0];
|
|
432
|
+
if (sid)
|
|
433
|
+
return sid.toUpperCase();
|
|
434
|
+
}
|
|
435
|
+
catch { /* reported as a configuration error below */ }
|
|
436
|
+
throw new Error("Unable to resolve the current Windows SID for Broker pipe isolation; set WOW_BROKER_SID explicitly.");
|
|
437
|
+
}
|
|
438
|
+
async function withTimeout(promise, timeoutMs) {
|
|
439
|
+
let timer;
|
|
440
|
+
try {
|
|
441
|
+
return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Broker connection timeout")), timeoutMs); })]);
|
|
442
|
+
}
|
|
443
|
+
finally {
|
|
444
|
+
if (timer)
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, truncateSync, writeSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
const LEDGER_STATES = new Set([
|
|
5
|
+
"validated", "before_captured", "executing", "executed", "after_validated", "completed",
|
|
6
|
+
"before_failed", "rollback_started", "rollback_executed", "rollback_validated", "rolled_back",
|
|
7
|
+
"rollback_unavailable", "rollback_failed", "irreversible_failed", "outcome_unknown"
|
|
8
|
+
]);
|
|
9
|
+
const TERMINAL_STATES = new Set([
|
|
10
|
+
"completed", "before_failed", "rolled_back", "rollback_unavailable", "rollback_failed",
|
|
11
|
+
"irreversible_failed", "outcome_unknown"
|
|
12
|
+
]);
|
|
13
|
+
export class FileBrokerLedgerStore {
|
|
14
|
+
path;
|
|
15
|
+
fd;
|
|
16
|
+
closed = false;
|
|
17
|
+
constructor(path) {
|
|
18
|
+
this.path = path;
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
20
|
+
this.fd = openSync(path, "a+", 0o600);
|
|
21
|
+
}
|
|
22
|
+
load() {
|
|
23
|
+
try {
|
|
24
|
+
const entries = new Map();
|
|
25
|
+
if (!existsSync(this.path))
|
|
26
|
+
return entries;
|
|
27
|
+
const content = readFileSync(this.path);
|
|
28
|
+
const hasTerminalNewline = content.length === 0 || content.at(-1) === 0x0a;
|
|
29
|
+
const lines = content.toString("utf8").split("\n");
|
|
30
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
31
|
+
const line = lines[index];
|
|
32
|
+
if (!line)
|
|
33
|
+
continue;
|
|
34
|
+
let record;
|
|
35
|
+
try {
|
|
36
|
+
record = JSON.parse(line);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
const isFinalTail = index === lines.length - 1 && !hasTerminalNewline;
|
|
40
|
+
if (isFinalTail) {
|
|
41
|
+
const tailOffset = content.lastIndexOf(0x0a) + 1;
|
|
42
|
+
const tail = content.subarray(tailOffset);
|
|
43
|
+
if (isIncompleteJsonTail(tail)) {
|
|
44
|
+
quarantineTail(this.path, tailOffset, tail);
|
|
45
|
+
truncateSync(this.path, tailOffset);
|
|
46
|
+
fsyncSync(this.fd);
|
|
47
|
+
return entries;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`invalid Broker ledger JSON at line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
|
|
51
|
+
}
|
|
52
|
+
if (record.schemaVersion !== 1 || (record.kind !== "upsert" && record.kind !== "delete")) {
|
|
53
|
+
throw new Error(`invalid Broker ledger record at line ${index + 1}`);
|
|
54
|
+
}
|
|
55
|
+
if (record.kind === "delete") {
|
|
56
|
+
if (typeof record.requestId !== "string")
|
|
57
|
+
throw new Error(`invalid Broker ledger delete at line ${index + 1}`);
|
|
58
|
+
entries.delete(record.requestId);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const candidateRequestId = record.entry && typeof record.entry === "object" && typeof record.entry.requestId === "string"
|
|
62
|
+
? record.entry.requestId
|
|
63
|
+
: undefined;
|
|
64
|
+
const prior = candidateRequestId === undefined ? undefined : entries.get(candidateRequestId);
|
|
65
|
+
if (prior && !extendsHistory(prior, record.entry)) {
|
|
66
|
+
throw new Error(`non-monotonic Broker ledger history at line ${index + 1}`);
|
|
67
|
+
}
|
|
68
|
+
validateEntry(record.entry, index + 1);
|
|
69
|
+
entries.set(record.entry.requestId, structuredClone(record.entry));
|
|
70
|
+
}
|
|
71
|
+
return entries;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.close();
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
persist(entry) {
|
|
79
|
+
this.append({ schemaVersion: 1, kind: "upsert", entry });
|
|
80
|
+
}
|
|
81
|
+
remove(requestId) {
|
|
82
|
+
this.append({ schemaVersion: 1, kind: "delete", requestId });
|
|
83
|
+
}
|
|
84
|
+
close() {
|
|
85
|
+
if (this.closed)
|
|
86
|
+
return;
|
|
87
|
+
closeSync(this.fd);
|
|
88
|
+
this.closed = true;
|
|
89
|
+
}
|
|
90
|
+
append(record) {
|
|
91
|
+
const bytes = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
|
|
92
|
+
// One append syscall keeps records indivisible when callers converge on the same ledger.
|
|
93
|
+
const written = writeSync(this.fd, bytes, 0, bytes.length);
|
|
94
|
+
if (written !== bytes.length)
|
|
95
|
+
throw new Error(`short Broker ledger append: wrote ${written} of ${bytes.length} bytes`);
|
|
96
|
+
fsyncSync(this.fd);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function quarantineTail(path, byteOffset, tail) {
|
|
100
|
+
const sha256 = createHash("sha256").update(tail).digest("hex");
|
|
101
|
+
const quarantinePath = `${path}.tail-${sha256}.quarantine.json`;
|
|
102
|
+
const evidence = Buffer.from(`${JSON.stringify({
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
kind: "broker-ledger-incomplete-tail",
|
|
105
|
+
sourcePath: path,
|
|
106
|
+
byteOffset,
|
|
107
|
+
bytes: tail.length,
|
|
108
|
+
sha256,
|
|
109
|
+
encoding: "base64",
|
|
110
|
+
data: tail.toString("base64")
|
|
111
|
+
})}\n`, "utf8");
|
|
112
|
+
if (existsSync(quarantinePath)) {
|
|
113
|
+
if (!readFileSync(quarantinePath).equals(evidence))
|
|
114
|
+
throw new Error(`Broker ledger quarantine conflict: ${quarantinePath}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const fd = openSync(quarantinePath, "wx", 0o600);
|
|
118
|
+
try {
|
|
119
|
+
let offset = 0;
|
|
120
|
+
while (offset < evidence.length)
|
|
121
|
+
offset += writeSync(fd, evidence, offset, evidence.length - offset);
|
|
122
|
+
fsyncSync(fd);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
closeSync(fd);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function isIncompleteJsonTail(tail) {
|
|
129
|
+
const text = tail.toString("utf8").trimStart();
|
|
130
|
+
if (!text.startsWith("{"))
|
|
131
|
+
return false;
|
|
132
|
+
const stack = [];
|
|
133
|
+
let inString = false;
|
|
134
|
+
let escaped = false;
|
|
135
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
136
|
+
const character = text[index];
|
|
137
|
+
if (inString) {
|
|
138
|
+
if (escaped)
|
|
139
|
+
escaped = false;
|
|
140
|
+
else if (character === "\\")
|
|
141
|
+
escaped = true;
|
|
142
|
+
else if (character === '"')
|
|
143
|
+
inString = false;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (character === '"')
|
|
147
|
+
inString = true;
|
|
148
|
+
else if (character === "{" || character === "[")
|
|
149
|
+
stack.push(character);
|
|
150
|
+
else if (character === "}" || character === "]") {
|
|
151
|
+
const expected = character === "}" ? "{" : "[";
|
|
152
|
+
if (stack.pop() !== expected)
|
|
153
|
+
return false;
|
|
154
|
+
if (stack.length === 0 && text.slice(index + 1).trim())
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return inString || escaped || stack.length > 0;
|
|
159
|
+
}
|
|
160
|
+
function extendsHistory(prior, next) {
|
|
161
|
+
if (prior.payloadHash !== next.payloadHash || next.transitions.length < prior.transitions.length)
|
|
162
|
+
return false;
|
|
163
|
+
return prior.transitions.every((transition, index) => {
|
|
164
|
+
const candidate = next.transitions[index];
|
|
165
|
+
return candidate?.state === transition.state && candidate.timestamp === transition.timestamp;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function validateEntry(entry, line) {
|
|
169
|
+
if (!entry || typeof entry !== "object" || typeof entry.requestId !== "string" || typeof entry.clientId !== "string" ||
|
|
170
|
+
typeof entry.operation !== "string" || typeof entry.payloadHash !== "string" || typeof entry.state !== "string" ||
|
|
171
|
+
typeof entry.terminal !== "boolean" || !Array.isArray(entry.transitions) || !Array.isArray(entry.evidenceRefs)) {
|
|
172
|
+
throw new Error(`invalid Broker ledger entry at line ${line}`);
|
|
173
|
+
}
|
|
174
|
+
if (!entry.requestId || !entry.clientId || !entry.operation || !entry.payloadHash || !LEDGER_STATES.has(entry.state)) {
|
|
175
|
+
throw new Error(`invalid Broker ledger entry identity or state at line ${line}`);
|
|
176
|
+
}
|
|
177
|
+
if (!Number.isFinite(entry.acceptedAt) || !entry.evidenceRefs.every(reference => typeof reference === "string")) {
|
|
178
|
+
throw new Error(`invalid Broker ledger entry evidence at line ${line}`);
|
|
179
|
+
}
|
|
180
|
+
if (entry.transitions.length === 0 || entry.transitions.at(-1)?.state !== entry.state) {
|
|
181
|
+
throw new Error(`inconsistent Broker ledger transition history at line ${line}`);
|
|
182
|
+
}
|
|
183
|
+
let previousTimestamp = Number.NEGATIVE_INFINITY;
|
|
184
|
+
for (const transition of entry.transitions) {
|
|
185
|
+
if (!transition || typeof transition !== "object" || !LEDGER_STATES.has(transition.state) ||
|
|
186
|
+
!Number.isFinite(transition.timestamp) || transition.timestamp < previousTimestamp ||
|
|
187
|
+
!Array.isArray(transition.evidenceRefs) || !transition.evidenceRefs.every(reference => typeof reference === "string")) {
|
|
188
|
+
throw new Error(`invalid Broker ledger transition history at line ${line}`);
|
|
189
|
+
}
|
|
190
|
+
previousTimestamp = transition.timestamp;
|
|
191
|
+
}
|
|
192
|
+
if (entry.terminal !== TERMINAL_STATES.has(entry.state) ||
|
|
193
|
+
(entry.terminal ? !Number.isFinite(entry.terminalAt) : entry.terminalAt !== undefined)) {
|
|
194
|
+
throw new Error(`inconsistent Broker ledger terminal state at line ${line}`);
|
|
195
|
+
}
|
|
196
|
+
}
|