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,1283 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { buildAdapterRegistry } from "./adapters.js";
|
|
6
|
+
import { BrokerWireCodec } from "./broker-codec.js";
|
|
7
|
+
import { BrokerProtocolError } from "./broker-protocol.js";
|
|
8
|
+
import { FocusRequestError, isAttachmentFatalError } from "./focus-errors.js";
|
|
9
|
+
const MUTATION_POLICIES = {
|
|
10
|
+
write_memory: { rollbackModes: ["reversible", "compensating"], rollbackOperations: ["write_memory"] },
|
|
11
|
+
protect_memory: { rollbackModes: ["reversible", "compensating"], rollbackOperations: ["protect_memory"] },
|
|
12
|
+
resume: { rollbackModes: ["compensating", "irreversible"], rollbackOperations: ["kill"] },
|
|
13
|
+
kill: { rollbackModes: ["irreversible"], rollbackOperations: [] },
|
|
14
|
+
script_load: { rollbackModes: ["compensating", "irreversible"], rollbackOperations: ["script_unload"] },
|
|
15
|
+
script_unload: { rollbackModes: ["compensating", "irreversible"], rollbackOperations: ["script_load"] },
|
|
16
|
+
script_call: { rollbackModes: ["reversible", "compensating", "irreversible"], rollbackOperations: ["script_call", "write_memory", "protect_memory"] },
|
|
17
|
+
host_call: { rollbackModes: ["reversible", "compensating", "irreversible"], rollbackOperations: ["host_call"] }
|
|
18
|
+
};
|
|
19
|
+
const MUTATING_OPERATIONS = new Set(Object.keys(MUTATION_POLICIES));
|
|
20
|
+
const RELEASE_OPERATIONS = {
|
|
21
|
+
detach: "attachment",
|
|
22
|
+
script_unload: "script",
|
|
23
|
+
trace_stop: "trace",
|
|
24
|
+
session_stop: "session",
|
|
25
|
+
handle_release: "handle"
|
|
26
|
+
};
|
|
27
|
+
const BROKER_ATTACHMENT_OWNER = "__broker_attachment_owner__";
|
|
28
|
+
const READ_ONLY_EVIDENCE_OPERATIONS = new Set(["devices", "processes", "applications", "modules", "exports", "ranges", "read_memory", "scan_memory"]);
|
|
29
|
+
const FOCUS_OPERATIONS = new Set([
|
|
30
|
+
"wow_focus_start", "wow_focus_read", "wow_focus_status", "wow_focus_pause", "wow_focus_resume", "wow_focus_stop",
|
|
31
|
+
"wow_watch_start", "wow_watch_read", "wow_watch_status", "wow_watch_stop", "wow_session_checkpoint"
|
|
32
|
+
]);
|
|
33
|
+
const FOCUS_START_OPERATIONS = new Set(["wow_focus_start", "wow_watch_start"]);
|
|
34
|
+
const FOCUS_STOP_OPERATIONS = new Set(["wow_focus_stop", "wow_watch_stop"]);
|
|
35
|
+
/** Broker-owned Frida runtime; no Frida object crosses IPC. */
|
|
36
|
+
export class BrokerFridaRuntime {
|
|
37
|
+
executor;
|
|
38
|
+
readyIdentity;
|
|
39
|
+
hostAllowlist;
|
|
40
|
+
processResolver;
|
|
41
|
+
focusService;
|
|
42
|
+
fatalHandlers;
|
|
43
|
+
sessionResources = new Map();
|
|
44
|
+
scriptResources = new Map();
|
|
45
|
+
confirmationNonces = new Set();
|
|
46
|
+
attachmentIdentities = new Map();
|
|
47
|
+
captures = new Map();
|
|
48
|
+
releasedCaptures = new Map();
|
|
49
|
+
captureResourceRoles = new Map();
|
|
50
|
+
brokerAttachments = new Map();
|
|
51
|
+
strictIdentity;
|
|
52
|
+
evidenceDirectory;
|
|
53
|
+
codec = new BrokerWireCodec();
|
|
54
|
+
constructor(executor, readyIdentity, hostAllowlist = [
|
|
55
|
+
{
|
|
56
|
+
buildKey: "*",
|
|
57
|
+
methods: ["enumerateDevices", "enumerateProcesses", "enumerateApplications"],
|
|
58
|
+
sideEffect: "read",
|
|
59
|
+
argumentTypes: [],
|
|
60
|
+
maxDurationMs: 5_000,
|
|
61
|
+
rollbackModes: ["reversible", "compensating"]
|
|
62
|
+
}
|
|
63
|
+
], evidenceDirectory = join(tmpdir(), "wowdump-frida-broker-evidence"), processResolver, focusService) {
|
|
64
|
+
this.executor = executor;
|
|
65
|
+
this.readyIdentity = readyIdentity;
|
|
66
|
+
this.hostAllowlist = hostAllowlist;
|
|
67
|
+
this.processResolver = processResolver;
|
|
68
|
+
this.focusService = focusService;
|
|
69
|
+
this.evidenceDirectory = resolve(evidenceDirectory);
|
|
70
|
+
this.strictIdentity = processResolver !== undefined || process.env.WOW_BROKER_STRICT_IDENTITY === "1";
|
|
71
|
+
if (typeof this.focusService?.setFatalHandler === "function")
|
|
72
|
+
this.focusService.setFatalHandler(event => this.handleFocusFatal(event));
|
|
73
|
+
if (typeof this.focusService?.setDurationExpiredHandler === "function")
|
|
74
|
+
this.focusService.setDurationExpiredHandler(event => this.handleDurationExpired(event));
|
|
75
|
+
}
|
|
76
|
+
bindFatalHandlers(handlers) { this.fatalHandlers = handlers; }
|
|
77
|
+
focusedSessionIds(pid, buildKey) {
|
|
78
|
+
return [...this.captures.values()]
|
|
79
|
+
.filter(capture => (pid === undefined || capture.pid === pid) && (buildKey === undefined || capture.buildKey === buildKey))
|
|
80
|
+
.map(capture => capture.controlSessionId)
|
|
81
|
+
.sort();
|
|
82
|
+
}
|
|
83
|
+
async handleFocusFatal(event) {
|
|
84
|
+
if (event.failureClass === "capture_fatal") {
|
|
85
|
+
const capture = this.captureForServiceSession(event.sessionId);
|
|
86
|
+
if (capture && capture.pid === event.pid && capture.buildKey === event.buildKey)
|
|
87
|
+
return this.fatalHandlers?.captureFatal(event.sessionId, event.pid, event.buildKey, event.error);
|
|
88
|
+
await this.focusService?.cleanupSession(event.sessionId, "capture_fatal");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (event.failureClass === "attachment_fatal")
|
|
92
|
+
return this.fatalHandlers?.attachmentFatal(event.pid, event.buildKey);
|
|
93
|
+
await this.fatalHandlers?.brokerFatal(`focused session ${event.sessionId} reported broker_fatal`);
|
|
94
|
+
}
|
|
95
|
+
async handleDurationExpired(event) {
|
|
96
|
+
const capture = this.captureForServiceSession(event.sessionId);
|
|
97
|
+
if (capture && capture.pid === event.pid && capture.buildKey === event.buildKey && this.fatalHandlers)
|
|
98
|
+
return this.fatalHandlers.durationExpired(event.sessionId, event.pid, event.buildKey);
|
|
99
|
+
await this.focusService?.cleanupSession(event.sessionId, "duration_expired");
|
|
100
|
+
}
|
|
101
|
+
async execute(request, context) {
|
|
102
|
+
if (request.operation === "broker_status") {
|
|
103
|
+
let processes = [];
|
|
104
|
+
try {
|
|
105
|
+
processes = toJson((await this.executor.execute({ operation: "processes" })).processes ?? []);
|
|
106
|
+
}
|
|
107
|
+
catch { /* status remains attach-free if discovery fails */ }
|
|
108
|
+
return { status: "ready", protocolVersion: "2026-07-28", ...(this.readyIdentity ?? {}), processes, focused: toJson(typeof this.focusService?.statusSnapshot === "function" ? this.focusService.statusSnapshot() : []) };
|
|
109
|
+
}
|
|
110
|
+
if (request.operation === "broker_processes")
|
|
111
|
+
return toJson(await this.executor.execute({ operation: "processes" }));
|
|
112
|
+
if (request.operation === "client_release" || request.operation === "broker_stop")
|
|
113
|
+
return { accepted: true, operation: request.operation };
|
|
114
|
+
if (FOCUS_OPERATIONS.has(request.operation))
|
|
115
|
+
return this.executeFocus(request, context);
|
|
116
|
+
if (request.operation !== "frida_command")
|
|
117
|
+
throw new Error(`unsupported Broker operation ${request.operation}`);
|
|
118
|
+
const payload = request.payload;
|
|
119
|
+
const rawRequest = isRecord(payload.request) ? payload.request : payload;
|
|
120
|
+
const operationHint = typeof rawRequest.operation === "string" ? rawRequest.operation : "";
|
|
121
|
+
const decodedRequest = this.codec.decode(rawRequest, { clientId: request.clientId, pid: request.pid, buildKey: request.buildKey }, operationHint);
|
|
122
|
+
if (!isRecord(decodedRequest))
|
|
123
|
+
throw new BrokerProtocolError("FRAME_INVALID", "decoded Frida command must be an object");
|
|
124
|
+
const command = deserializeCommand(decodedRequest);
|
|
125
|
+
const target = isRecord(payload.context) ? deserializeContext(payload.context) : undefined;
|
|
126
|
+
const mutation = isRecord(decodedRequest.mutation) ? decodedRequest.mutation : undefined;
|
|
127
|
+
const launch = isRecord(decodedRequest.launch) ? decodedRequest.launch : undefined;
|
|
128
|
+
const operation = command.operation ?? "";
|
|
129
|
+
const mutating = MUTATING_OPERATIONS.has(operation);
|
|
130
|
+
const spawning = operation === "spawn";
|
|
131
|
+
const controlled = mutating || spawning;
|
|
132
|
+
const contract = spawning ? launch : mutation;
|
|
133
|
+
const resourceId = this.resolveResourceId(operation, decodedRequest, command);
|
|
134
|
+
this.validateTargetBinding(request, command, target);
|
|
135
|
+
this.requireCommandOwnership(command, request, context);
|
|
136
|
+
if (spawning)
|
|
137
|
+
this.validateLaunch(request, launch);
|
|
138
|
+
else if (this.strictIdentity && request.pid !== null && operation === "attach")
|
|
139
|
+
await this.validateProcessTarget(request, target);
|
|
140
|
+
else if (this.strictIdentity && request.pid !== null && this.requiresTargetIdentity(operation))
|
|
141
|
+
await this.revalidateTarget(request, command, target, context);
|
|
142
|
+
if (operation === "script_load")
|
|
143
|
+
this.requireScriptParent(command, request, context);
|
|
144
|
+
if (RELEASE_OPERATIONS[operation])
|
|
145
|
+
this.requireReleaseResource(resourceId, request.clientId, RELEASE_OPERATIONS[operation], context);
|
|
146
|
+
if (operation === "host_call" && !request.buildKey)
|
|
147
|
+
throw new BrokerProtocolError("HOST_CALL_BUILD_KEY_REQUIRED", "host_call requires buildKey");
|
|
148
|
+
if (mutating)
|
|
149
|
+
this.validateMutation(request, operation, mutation);
|
|
150
|
+
if (operation === "host_call")
|
|
151
|
+
this.validateHostCall(request, command, mutation);
|
|
152
|
+
const evidence = [];
|
|
153
|
+
if (controlled && contract) {
|
|
154
|
+
try {
|
|
155
|
+
const before = await this.captureEvidence("before", request, command, target, context, contract.evidencePlan.before);
|
|
156
|
+
evidence.push(before);
|
|
157
|
+
context.transition("before_captured", { evidenceRefs: [before.path], result: { evidence: toJson(evidence) } });
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
throw new BrokerProtocolError("MUTATION_BEFORE_EVIDENCE_FAILED", errorText(error), "Fix the evidence plan before retrying; the mutation was not executed.");
|
|
161
|
+
}
|
|
162
|
+
context.transition("executing");
|
|
163
|
+
}
|
|
164
|
+
let result;
|
|
165
|
+
try {
|
|
166
|
+
result = spawning
|
|
167
|
+
? await this.executeLaunch(command, launch)
|
|
168
|
+
: RELEASE_OPERATIONS[operation]
|
|
169
|
+
? await this.releaseOwnedResource(resourceId, request, RELEASE_OPERATIONS[operation], context)
|
|
170
|
+
: await this.executor.execute(command, target);
|
|
171
|
+
const identity = this.strictIdentity && operation === "attach"
|
|
172
|
+
? await this.validateAttachedTarget(request, command, target, result)
|
|
173
|
+
: undefined;
|
|
174
|
+
if (identity) {
|
|
175
|
+
result.identity = toJson(identity);
|
|
176
|
+
result.processStartTime = identity.processStartTime ?? null;
|
|
177
|
+
result.moduleBase = identity.moduleBase;
|
|
178
|
+
result.moduleIdentity = identity.moduleIdentity;
|
|
179
|
+
result.executable = identity.executable ?? null;
|
|
180
|
+
}
|
|
181
|
+
if (controlled)
|
|
182
|
+
context.transition("executed", { result: toJson(result) });
|
|
183
|
+
if (controlled && contract) {
|
|
184
|
+
const after = await this.captureEvidence("after", request, command, target, context, contract.evidencePlan.after, result);
|
|
185
|
+
evidence.push(after);
|
|
186
|
+
assertMatches(contract.expectedEffect, toJson(result), "expectedEffect");
|
|
187
|
+
context.transition("after_validated", { evidenceRefs: [after.path], result: { evidence: toJson(evidence) } });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (!controlled || !contract)
|
|
192
|
+
throw error;
|
|
193
|
+
if (spawning && error instanceof BrokerProtocolError && error.code === "SPAWN_BUILD_MISMATCH") {
|
|
194
|
+
context.transition("rollback_started", { error: toJson({ rootError: error.message }) });
|
|
195
|
+
context.transition("rollback_executed", { result: { operation: "kill", spawnedProcessOnly: true } });
|
|
196
|
+
context.transition("rollback_validated", { result: { code: "SPAWN_BUILD_MISMATCH", killed: true } });
|
|
197
|
+
context.transition("rolled_back");
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
await this.rollbackMutation(request, command, contract, target, context, error, evidence, result);
|
|
201
|
+
}
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
await this.registerResult(command, result, request, context);
|
|
205
|
+
const encoded = this.codec.encode(result, { clientId: request.clientId, pid: request.pid, buildKey: request.buildKey });
|
|
206
|
+
for (const handle of encoded.handles)
|
|
207
|
+
context.registerResource({ resourceId: handle.resourceId, ownerClientId: request.clientId, pid: request.pid, buildKey: request.buildKey, type: "handle" });
|
|
208
|
+
return evidence.length === 0 ? encoded.value : mergeEvidence(encoded.value, evidence);
|
|
209
|
+
}
|
|
210
|
+
async cleanup(resource, step, reason, cleanupContext) {
|
|
211
|
+
const captureRole = this.captureResourceRoles.get(resource.resourceId);
|
|
212
|
+
if (captureRole) {
|
|
213
|
+
const { capture, role } = captureRole;
|
|
214
|
+
(capture.cleanupSteps ??= []).push(step);
|
|
215
|
+
// The capture scope is prepared exactly once by cleanupCaptureScope.
|
|
216
|
+
// Resource cleanup only commits ownership refs and the final attachment.
|
|
217
|
+
if (!capture.cleanupResult && step !== "ref_release" && step !== "attachment_detach")
|
|
218
|
+
await this.ensureCaptureCleanup(capture, reason, cleanupContext?.effectiveDeadline);
|
|
219
|
+
if (step === "ref_release" && role !== "attachment")
|
|
220
|
+
this.captureResourceRoles.delete(resource.resourceId);
|
|
221
|
+
if (role === "attachment" && step === "attachment_detach") {
|
|
222
|
+
const rawSessionId = this.sessionResources.get(resource.resourceId);
|
|
223
|
+
if (!rawSessionId)
|
|
224
|
+
throw new Error(`session handle missing for ${resource.resourceId}`);
|
|
225
|
+
await this.executor.execute({ operation: "detach", sessionId: rawSessionId, pid: resource.pid ?? undefined, buildKey: resource.buildKey ?? undefined });
|
|
226
|
+
this.sessionResources.delete(resource.resourceId);
|
|
227
|
+
this.attachmentIdentities.delete(rawSessionId);
|
|
228
|
+
this.captureResourceRoles.delete(resource.resourceId);
|
|
229
|
+
for (const member of this.captures.values())
|
|
230
|
+
if (member.attachmentResourceId === resource.resourceId)
|
|
231
|
+
member.released = true;
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (step === "script_unload" && resource.type === "script") {
|
|
236
|
+
const scriptId = this.scriptResources.get(resource.resourceId) ?? idFromResource(resource.resourceId, "script:");
|
|
237
|
+
if (!scriptId)
|
|
238
|
+
throw new Error(`script handle missing for ${resource.resourceId}`);
|
|
239
|
+
await this.executor.execute({ operation: "script_unload", scriptId });
|
|
240
|
+
this.scriptResources.delete(resource.resourceId);
|
|
241
|
+
}
|
|
242
|
+
if (step === "attachment_detach" && resource.type === "attachment") {
|
|
243
|
+
const sessionId = this.sessionResources.get(resource.resourceId) ?? idFromResource(resource.resourceId, "attachment:");
|
|
244
|
+
if (!sessionId)
|
|
245
|
+
throw new Error(`session handle missing for ${resource.resourceId}`);
|
|
246
|
+
await this.executor.execute({ operation: "detach", sessionId });
|
|
247
|
+
this.sessionResources.delete(resource.resourceId);
|
|
248
|
+
this.attachmentIdentities.delete(sessionId);
|
|
249
|
+
this.brokerAttachments.delete(resource.resourceId);
|
|
250
|
+
}
|
|
251
|
+
if (step === "ref_release" && resource.type === "handle")
|
|
252
|
+
this.codec.releaseResource(resource.resourceId);
|
|
253
|
+
void reason;
|
|
254
|
+
}
|
|
255
|
+
async cleanupCaptureScope(scopeId, reason, context) {
|
|
256
|
+
const capture = this.captureForServiceSession(scopeId);
|
|
257
|
+
if (!capture)
|
|
258
|
+
return;
|
|
259
|
+
let literalResult;
|
|
260
|
+
try {
|
|
261
|
+
literalResult = await this.ensureCaptureCleanup(capture, reason, context.effectiveDeadline);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
return {
|
|
265
|
+
sessionId: scopeId,
|
|
266
|
+
status: "failed",
|
|
267
|
+
steps: cleanupStepsFromResult(capture.cleanupResult, error),
|
|
268
|
+
residualResources: capture.managedResources.map(resource => resource.resourceId),
|
|
269
|
+
literalResult: toJson(capture.cleanupResult ?? { error: errorText(error) })
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const steps = cleanupStepsFromResult(literalResult);
|
|
273
|
+
const cleanupStatus = isRecord(literalResult.cleanupStatus) ? literalResult.cleanupStatus : {};
|
|
274
|
+
const residual = Number(cleanupStatus.hooks ?? 0) !== 0 || Number(cleanupStatus.scripts ?? 0) !== 0 || Number(cleanupStatus.interceptors ?? 0) !== 0;
|
|
275
|
+
return {
|
|
276
|
+
sessionId: scopeId,
|
|
277
|
+
status: residual || steps.some(step => !step.ok) ? "partial" : "passed",
|
|
278
|
+
steps,
|
|
279
|
+
evidenceRefs: evidenceRefsFromFocusResult(literalResult),
|
|
280
|
+
residualResources: residual ? capture.managedResources.map(resource => resource.resourceId) : [],
|
|
281
|
+
literalResult: toJson(literalResult)
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
finalizeCaptureScope(scopeId, _reason, result) {
|
|
285
|
+
const capture = this.captureForServiceSession(scopeId);
|
|
286
|
+
if (!capture)
|
|
287
|
+
return;
|
|
288
|
+
capture.cleanupAttempted = true;
|
|
289
|
+
if (result.literalResult && isRecord(result.literalResult))
|
|
290
|
+
capture.cleanupResult = result.literalResult;
|
|
291
|
+
if (result.status !== "passed")
|
|
292
|
+
return;
|
|
293
|
+
capture.cleanupFailureReason = undefined;
|
|
294
|
+
}
|
|
295
|
+
completeCaptureScope(scopeId, _reason) {
|
|
296
|
+
const capture = this.captureForServiceSession(scopeId);
|
|
297
|
+
if (!capture || !capture.cleanupResult)
|
|
298
|
+
return;
|
|
299
|
+
const scopedResourcesReleased = capture.managedResources
|
|
300
|
+
.filter(item => item.type !== "attachment")
|
|
301
|
+
.every(item => item.resource?.cleanupState === "released");
|
|
302
|
+
const attachmentHealthy = capture.managedResources
|
|
303
|
+
.filter(item => item.type === "attachment")
|
|
304
|
+
.every(item => item.resource?.cleanupState !== "failed");
|
|
305
|
+
if (scopedResourcesReleased && attachmentHealthy) {
|
|
306
|
+
for (const item of capture.managedResources) {
|
|
307
|
+
if (item.type === "attachment" && item.resource?.cleanupState === "released"
|
|
308
|
+
&& this.captureResourceRoles.get(item.resourceId)?.role === "attachment") {
|
|
309
|
+
this.captureResourceRoles.delete(item.resourceId);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
this.markCaptureReleased(capture);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
markCaptureReleased(capture) {
|
|
316
|
+
if (capture.fullyReleased)
|
|
317
|
+
return;
|
|
318
|
+
capture.fullyReleased = true;
|
|
319
|
+
capture.released = true;
|
|
320
|
+
this.releasedCaptures.set(capture.controlSessionId, {
|
|
321
|
+
ownerClientId: capture.ownerClientId,
|
|
322
|
+
pid: capture.pid,
|
|
323
|
+
buildKey: capture.buildKey,
|
|
324
|
+
cleanupResult: capture.cleanupResult
|
|
325
|
+
});
|
|
326
|
+
this.captures.delete(capture.controlSessionId);
|
|
327
|
+
for (const item of capture.managedResources) {
|
|
328
|
+
if (item.type !== "attachment" && this.captureResourceRoles.get(item.resourceId)?.capture === capture)
|
|
329
|
+
this.captureResourceRoles.delete(item.resourceId);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async close() {
|
|
333
|
+
await this.focusService?.close();
|
|
334
|
+
await this.executor.close();
|
|
335
|
+
}
|
|
336
|
+
async releaseClient(clientId) { this.codec.releaseClient(clientId); }
|
|
337
|
+
async executeFocus(request, context) {
|
|
338
|
+
if (!this.focusService)
|
|
339
|
+
throw new BrokerProtocolError("BROKER_FATAL", "Broker focus service is not configured");
|
|
340
|
+
if (request.pid === null)
|
|
341
|
+
throw new BrokerProtocolError("PID_REQUIRED", `${request.operation} requires pid`);
|
|
342
|
+
if (!request.buildKey)
|
|
343
|
+
throw new BrokerProtocolError("PID_BUILD_MISMATCH", `${request.operation} requires buildKey`);
|
|
344
|
+
assertFocusEnvelope(request, request.payload);
|
|
345
|
+
const input = { ...request.payload, pid: request.pid, buildKey: request.buildKey };
|
|
346
|
+
if (FOCUS_START_OPERATIONS.has(request.operation)) {
|
|
347
|
+
const result = await this.invokeFocus(request.operation, input);
|
|
348
|
+
return toJson(await this.registerCapture(request, result, context));
|
|
349
|
+
}
|
|
350
|
+
const sessionId = typeof input.sessionId === "string" && input.sessionId ? input.sessionId : undefined;
|
|
351
|
+
if (!sessionId)
|
|
352
|
+
throw new BrokerProtocolError("RESOURCE_NOT_FOUND", `${request.operation} requires sessionId`);
|
|
353
|
+
const capture = this.captures.get(sessionId);
|
|
354
|
+
if (!capture) {
|
|
355
|
+
const released = this.releasedCaptures.get(sessionId);
|
|
356
|
+
if (!released)
|
|
357
|
+
throw new BrokerProtocolError("RESOURCE_NOT_FOUND", `capture session ${sessionId} was not found`);
|
|
358
|
+
if (released.ownerClientId !== request.clientId)
|
|
359
|
+
throw new BrokerProtocolError("RESOURCE_NOT_OWNER", `${request.clientId} does not own capture ${sessionId}`);
|
|
360
|
+
if (released.pid !== request.pid || released.buildKey !== request.buildKey)
|
|
361
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", `capture ${sessionId} target does not match Broker envelope`);
|
|
362
|
+
if (!FOCUS_STOP_OPERATIONS.has(request.operation))
|
|
363
|
+
throw new BrokerProtocolError("RESOURCE_ALREADY_RELEASED", `capture ${sessionId} is already released`);
|
|
364
|
+
return toJson({ ...(released.cleanupResult ?? {}), sessionId, stopped: true, cleanupAttempted: true, fullyReleased: true, alreadyReleased: true });
|
|
365
|
+
}
|
|
366
|
+
if (capture.ownerClientId !== request.clientId)
|
|
367
|
+
throw new BrokerProtocolError("RESOURCE_NOT_OWNER", `${request.clientId} does not own capture ${sessionId}`);
|
|
368
|
+
if (capture.pid !== request.pid || capture.buildKey !== request.buildKey)
|
|
369
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", `capture ${sessionId} target does not match Broker envelope`);
|
|
370
|
+
if (FOCUS_STOP_OPERATIONS.has(request.operation)) {
|
|
371
|
+
if (capture.fullyReleased)
|
|
372
|
+
return toJson({ ...(capture.cleanupResult ?? {}), sessionId, stopped: true, cleanupAttempted: true, fullyReleased: true, alreadyReleased: true });
|
|
373
|
+
await this.releaseCapture(capture, request, context);
|
|
374
|
+
return toJson({ ...(capture.cleanupResult ?? {}), sessionId, stopped: true, cleanupAttempted: true, fullyReleased: true, resourceId: capture.sessionResourceId });
|
|
375
|
+
}
|
|
376
|
+
if (!capture.released)
|
|
377
|
+
context.requireOwnedResource(capture.sessionResourceId, request.clientId, "session");
|
|
378
|
+
return toJson(await this.invokeFocus(request.operation, input));
|
|
379
|
+
}
|
|
380
|
+
async invokeFocus(operation, input) {
|
|
381
|
+
try {
|
|
382
|
+
return await this.focusService.invoke(operation, input);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
if (error instanceof FocusRequestError) {
|
|
386
|
+
throw new BrokerProtocolError(error.code, error.message, "Correct the focused-session selector and retry the same Broker request.");
|
|
387
|
+
}
|
|
388
|
+
if (isAttachmentFatalError(error)) {
|
|
389
|
+
throw new BrokerProtocolError("ATTACHMENT_FATAL", error instanceof Error ? error.message : String(error), "Confirm the target process is running, then start a new focused session for its current PID.");
|
|
390
|
+
}
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
captureForServiceSession(sessionId) {
|
|
395
|
+
return this.captures.get(sessionId)
|
|
396
|
+
?? [...this.captures.values()].find(capture => capture.sessionId === sessionId);
|
|
397
|
+
}
|
|
398
|
+
async registerCapture(request, result, context) {
|
|
399
|
+
const sessionId = typeof result.sessionId === "string" && result.sessionId ? result.sessionId : undefined;
|
|
400
|
+
const fallbackSessionId = typeof request.payload.sessionId === "string" && request.payload.sessionId ? request.payload.sessionId : undefined;
|
|
401
|
+
if (!sessionId || request.pid === null || !request.buildKey) {
|
|
402
|
+
const error = new BrokerProtocolError("CAPTURE_FATAL", "focused start did not return a sessionId");
|
|
403
|
+
await this.rollbackUnregisteredCapture(request, result, fallbackSessionId, error, context);
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
if (this.captures.has(sessionId)) {
|
|
407
|
+
const error = new BrokerProtocolError("RESOURCE_ALREADY_RELEASED", `capture ${sessionId} already exists`);
|
|
408
|
+
await this.rollbackUnregisteredCapture(request, result, sessionId, error, context);
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
const returnedPid = result.pid;
|
|
412
|
+
const returnedBuildKey = result.buildKey;
|
|
413
|
+
if ((returnedPid !== undefined && returnedPid !== request.pid)
|
|
414
|
+
|| (returnedBuildKey !== undefined && returnedBuildKey !== request.buildKey)
|
|
415
|
+
|| (result.ready !== undefined && result.ready !== true)) {
|
|
416
|
+
const error = new BrokerProtocolError("SESSION_TARGET_MISMATCH", "focused start returned a record that failed post-start validation");
|
|
417
|
+
await this.rollbackUnregisteredCapture(request, result, sessionId, error, context);
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
const rawAttachmentId = typeof result.fridaSessionId === "string" && result.fridaSessionId ? result.fridaSessionId : sessionId;
|
|
421
|
+
let attachmentResourceId = `focus-attachment:${rawAttachmentId}`;
|
|
422
|
+
const sessionResourceId = `focus-session:${sessionId}`;
|
|
423
|
+
const rawScriptId = typeof result.scriptId === "string" && result.scriptId ? result.scriptId : undefined;
|
|
424
|
+
const scriptResourceId = rawScriptId ? `focus-script:${sessionId}` : undefined;
|
|
425
|
+
const rawHooks = Array.isArray(result.hookIds) ? result.hookIds.filter((value) => typeof value === "string") : [];
|
|
426
|
+
const identity = {
|
|
427
|
+
...(typeof result.processStartTime === "number" ? { processStartTime: new Date(result.processStartTime).toISOString() } : {}),
|
|
428
|
+
...(typeof result.moduleBase === "string" ? { moduleBase: result.moduleBase } : {}),
|
|
429
|
+
...(typeof result.moduleIdentity === "string" ? { moduleIdentity: result.moduleIdentity } : {})
|
|
430
|
+
};
|
|
431
|
+
const capture = {
|
|
432
|
+
controlSessionId: sessionId,
|
|
433
|
+
sessionId,
|
|
434
|
+
ownerClientId: request.clientId,
|
|
435
|
+
pid: request.pid,
|
|
436
|
+
buildKey: request.buildKey,
|
|
437
|
+
attachmentResourceId,
|
|
438
|
+
sessionResourceId,
|
|
439
|
+
scriptResourceId,
|
|
440
|
+
hookResourceIds: rawHooks.map((_, index) => `focus-hook:${sessionId}:${index}`),
|
|
441
|
+
managedResources: [],
|
|
442
|
+
identity
|
|
443
|
+
};
|
|
444
|
+
const registered = [];
|
|
445
|
+
let acquiredAttachment = false;
|
|
446
|
+
let acquiredAttachmentResource;
|
|
447
|
+
const rollbackErrors = [];
|
|
448
|
+
try {
|
|
449
|
+
const existingAttachment = this.brokerAttachments.get(attachmentResourceId);
|
|
450
|
+
if (existingAttachment) {
|
|
451
|
+
if (existingAttachment.pid !== request.pid || existingAttachment.buildKey !== request.buildKey || !sameCaptureIdentity(existingAttachment.identity, identity)) {
|
|
452
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", `${attachmentResourceId} does not match the requested capture target identity`);
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
acquiredAttachmentResource = context.acquireResource(attachmentResourceId, request.clientId);
|
|
456
|
+
acquiredAttachment = true;
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (!(error instanceof BrokerProtocolError) || error.code !== "RESOURCE_ALREADY_RELEASED")
|
|
460
|
+
throw error;
|
|
461
|
+
attachmentResourceId = `${attachmentResourceId}:${sessionId}`;
|
|
462
|
+
capture.attachmentResourceId = attachmentResourceId;
|
|
463
|
+
const resource = context.registerResource({ resourceId: attachmentResourceId, ownerClientId: request.clientId, pid: request.pid, buildKey: request.buildKey, type: "attachment", identity });
|
|
464
|
+
registered.push({ resourceId: attachmentResourceId, type: "attachment", resource });
|
|
465
|
+
this.sessionResources.set(attachmentResourceId, rawAttachmentId);
|
|
466
|
+
this.brokerAttachments.set(attachmentResourceId, { pid: request.pid, buildKey: request.buildKey, identity });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
const resource = context.registerResource({ resourceId: attachmentResourceId, ownerClientId: BROKER_ATTACHMENT_OWNER, pid: request.pid, buildKey: request.buildKey, type: "attachment", identity });
|
|
471
|
+
acquiredAttachmentResource = context.acquireResource(attachmentResourceId, request.clientId);
|
|
472
|
+
acquiredAttachment = true;
|
|
473
|
+
this.sessionResources.set(attachmentResourceId, rawAttachmentId);
|
|
474
|
+
this.brokerAttachments.set(attachmentResourceId, { pid: request.pid, buildKey: request.buildKey, identity });
|
|
475
|
+
}
|
|
476
|
+
const sessionResource = context.registerResource({ resourceId: sessionResourceId, ownerClientId: request.clientId, pid: request.pid, buildKey: request.buildKey, type: "session", parentResourceId: attachmentResourceId, cleanupScopeId: sessionId, identity });
|
|
477
|
+
registered.push({ resourceId: sessionResourceId, type: "session", resource: sessionResource });
|
|
478
|
+
this.captureResourceRoles.set(sessionResourceId, { capture, role: "session" });
|
|
479
|
+
if (scriptResourceId) {
|
|
480
|
+
const resource = context.registerResource({ resourceId: scriptResourceId, ownerClientId: request.clientId, pid: request.pid, buildKey: request.buildKey, type: "script", parentResourceId: sessionResourceId, cleanupScopeId: sessionId, identity });
|
|
481
|
+
registered.push({ resourceId: scriptResourceId, type: "script", resource });
|
|
482
|
+
this.captureResourceRoles.set(scriptResourceId, { capture, role: "script" });
|
|
483
|
+
}
|
|
484
|
+
for (const resourceId of capture.hookResourceIds) {
|
|
485
|
+
const resource = context.registerResource({ resourceId, ownerClientId: request.clientId, pid: request.pid, buildKey: request.buildKey, type: "hook", parentResourceId: scriptResourceId ?? sessionResourceId, cleanupScopeId: sessionId, identity });
|
|
486
|
+
registered.push({ resourceId, type: "hook", resource });
|
|
487
|
+
this.captureResourceRoles.set(resourceId, { capture, role: "hook" });
|
|
488
|
+
}
|
|
489
|
+
this.captures.set(sessionId, capture);
|
|
490
|
+
capture.managedResources = [
|
|
491
|
+
...[...registered].reverse().map(item => ({ ...item, reasonRole: item.type })),
|
|
492
|
+
...(acquiredAttachment ? [{ resourceId: attachmentResourceId, type: "attachment", reasonRole: "acquired_ref", resource: acquiredAttachmentResource }] : [])
|
|
493
|
+
];
|
|
494
|
+
return { ...result, resourceId: sessionResourceId, attachmentResourceId, scriptResourceId: scriptResourceId ?? null, hookResourceIds: capture.hookResourceIds };
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
let cleanupSucceeded = false;
|
|
498
|
+
try {
|
|
499
|
+
capture.cleanupAttempted = true;
|
|
500
|
+
capture.cleanupPromise = this.focusService.cleanupSession(sessionId, "broker_registration_failed");
|
|
501
|
+
capture.cleanupResult = await capture.cleanupPromise;
|
|
502
|
+
cleanupSucceeded = true;
|
|
503
|
+
}
|
|
504
|
+
catch (cleanupError) {
|
|
505
|
+
capture.cleanupPromise = undefined;
|
|
506
|
+
const message = errorText(cleanupError);
|
|
507
|
+
(capture.cleanupFailures ??= []).push(message);
|
|
508
|
+
rollbackErrors.push(`capture cleanup: ${message}`);
|
|
509
|
+
}
|
|
510
|
+
if (cleanupSucceeded) {
|
|
511
|
+
for (const item of [...registered].reverse()) {
|
|
512
|
+
try {
|
|
513
|
+
await context.releaseResource(item.resourceId, request.clientId, item.type, "focus_registration_rollback");
|
|
514
|
+
}
|
|
515
|
+
catch (releaseError) {
|
|
516
|
+
rollbackErrors.push(`${item.resourceId}: ${errorText(releaseError)}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (acquiredAttachment) {
|
|
520
|
+
try {
|
|
521
|
+
await context.releaseResource(attachmentResourceId, request.clientId, "attachment", "focus_registration_rollback:acquired_ref");
|
|
522
|
+
}
|
|
523
|
+
catch (releaseError) {
|
|
524
|
+
rollbackErrors.push(`${attachmentResourceId}: ${errorText(releaseError)}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
capture.managedResources = [
|
|
529
|
+
...[...registered].reverse().map(item => ({ ...item, reasonRole: item.type })),
|
|
530
|
+
...(acquiredAttachment ? [{ resourceId: attachmentResourceId, type: "attachment", reasonRole: "acquired_ref", resource: acquiredAttachmentResource }] : [])
|
|
531
|
+
];
|
|
532
|
+
capture.fullyReleased = cleanupSucceeded && rollbackErrors.length === 0;
|
|
533
|
+
capture.released = capture.fullyReleased;
|
|
534
|
+
if (capture.fullyReleased) {
|
|
535
|
+
for (const item of registered)
|
|
536
|
+
if (item.type !== "attachment" || this.captureResourceRoles.get(item.resourceId)?.capture === capture)
|
|
537
|
+
this.captureResourceRoles.delete(item.resourceId);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
const controlSessionId = this.reserveResidualSessionId(sessionId);
|
|
541
|
+
capture.controlSessionId = controlSessionId;
|
|
542
|
+
this.captures.set(controlSessionId, capture);
|
|
543
|
+
this.annotateResidualError(error, controlSessionId, rollbackErrors);
|
|
544
|
+
}
|
|
545
|
+
if (rollbackErrors.length && error && typeof error === "object")
|
|
546
|
+
Object.assign(error, { registrationRollback: rollbackErrors });
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async rollbackUnregisteredCapture(request, result, sessionId, error, context) {
|
|
551
|
+
if (!sessionId) {
|
|
552
|
+
Object.assign(error, { registrationRollback: ["capture cleanup unavailable: start returned no sessionId and request supplied no fallback sessionId"] });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
await this.focusService.cleanupSession(sessionId, "broker_registration_failed");
|
|
557
|
+
}
|
|
558
|
+
catch (cleanupError) {
|
|
559
|
+
const controlSessionId = this.reserveResidualSessionId(sessionId);
|
|
560
|
+
const resourceId = `focus-session:${controlSessionId}`;
|
|
561
|
+
const capture = {
|
|
562
|
+
controlSessionId, sessionId, ownerClientId: request.clientId, pid: request.pid, buildKey: request.buildKey,
|
|
563
|
+
attachmentResourceId: "", sessionResourceId: resourceId, hookResourceIds: [], managedResources: [{ resourceId, type: "session", reasonRole: "residual_session" }],
|
|
564
|
+
identity: {}, cleanupAttempted: true, cleanupFailures: [errorText(cleanupError)], released: false, fullyReleased: false
|
|
565
|
+
};
|
|
566
|
+
context.registerResource({ resourceId, ownerClientId: request.clientId, pid: request.pid, buildKey: request.buildKey, type: "session", cleanupScopeId: sessionId });
|
|
567
|
+
this.captureResourceRoles.set(resourceId, { capture, role: "session" });
|
|
568
|
+
this.captures.set(controlSessionId, capture);
|
|
569
|
+
const failures = [`capture cleanup: ${errorText(cleanupError)}`];
|
|
570
|
+
Object.assign(error, { registrationRollback: failures });
|
|
571
|
+
this.annotateResidualError(error, controlSessionId, failures);
|
|
572
|
+
}
|
|
573
|
+
void result;
|
|
574
|
+
}
|
|
575
|
+
async releaseCapture(capture, request, context) {
|
|
576
|
+
try {
|
|
577
|
+
await this.ensureCaptureCleanup(capture, capture.cleanupAttempted ? `${request.operation}:retry_cleanup` : `${request.operation}:session_release`);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
const retained = capture.managedResources.map(item => item.resourceId);
|
|
581
|
+
throw new BrokerProtocolError("CLEANUP_TIMEOUT", `cleanupAttempted=true; fullyReleased=false; residualSessionId=${capture.controlSessionId}; residualResources=${JSON.stringify(retained)}; focus cleanup failed: ${errorText(error)}`, `Retry ${request.operation} with sessionId=${capture.controlSessionId}.`);
|
|
582
|
+
}
|
|
583
|
+
const normalPlan = [
|
|
584
|
+
...capture.hookResourceIds.map(resourceId => ({ resourceId, type: "hook", reasonRole: "hook" })),
|
|
585
|
+
...(capture.scriptResourceId ? [{ resourceId: capture.scriptResourceId, type: "script", reasonRole: "script" }] : []),
|
|
586
|
+
{ resourceId: capture.sessionResourceId, type: "session", reasonRole: "session" },
|
|
587
|
+
{ resourceId: capture.attachmentResourceId, type: "attachment", reasonRole: "attachment" }
|
|
588
|
+
];
|
|
589
|
+
const managed = capture.managedResources.length ? capture.managedResources : normalPlan;
|
|
590
|
+
const plan = managed
|
|
591
|
+
.filter(item => !item.resource || ((item.resource.cleanupState === "active" || item.resource.cleanupState === "failed") && item.resource.refs.has(request.clientId)))
|
|
592
|
+
.map(item => ({ ...item, reason: `${request.operation}:${item.reasonRole}_release` }));
|
|
593
|
+
const failures = [];
|
|
594
|
+
for (const item of plan) {
|
|
595
|
+
try {
|
|
596
|
+
await context.releaseResource(item.resourceId, request.clientId, item.type, item.reason);
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
failures.push(`${item.resourceId}: ${errorText(error)}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
capture.cleanupAttempted = true;
|
|
603
|
+
const residualResources = managed
|
|
604
|
+
.filter(item => item.resource && item.resource.refs.has(request.clientId) && item.resource.cleanupState !== "released")
|
|
605
|
+
.map(item => item.resourceId);
|
|
606
|
+
capture.fullyReleased = failures.length === 0 && residualResources.length === 0;
|
|
607
|
+
capture.released = capture.fullyReleased;
|
|
608
|
+
if (capture.fullyReleased) {
|
|
609
|
+
this.releasedCaptures.set(capture.controlSessionId, {
|
|
610
|
+
ownerClientId: capture.ownerClientId,
|
|
611
|
+
pid: capture.pid,
|
|
612
|
+
buildKey: capture.buildKey,
|
|
613
|
+
cleanupResult: capture.cleanupResult
|
|
614
|
+
});
|
|
615
|
+
this.captures.delete(capture.controlSessionId);
|
|
616
|
+
for (const item of managed) {
|
|
617
|
+
if (this.captureResourceRoles.get(item.resourceId)?.capture === capture)
|
|
618
|
+
this.captureResourceRoles.delete(item.resourceId);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (!capture.fullyReleased) {
|
|
622
|
+
capture.cleanupFailures = [...(capture.cleanupFailures ?? []), ...failures];
|
|
623
|
+
throw new BrokerProtocolError("CLEANUP_TIMEOUT", `cleanupAttempted=true; fullyReleased=false; residualResources=${JSON.stringify(residualResources)}; failures=${failures.join("; ")}`, "Retry stop and inspect focused-session residual cleanup evidence.");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async ensureCaptureCleanup(capture, reason, cleanupDeadline) {
|
|
627
|
+
if (capture.cleanupResult !== undefined)
|
|
628
|
+
return capture.cleanupResult;
|
|
629
|
+
if (capture.cleanupFailureReason !== undefined && capture.cleanupFailureReason !== reason) {
|
|
630
|
+
capture.cleanupPromise = undefined;
|
|
631
|
+
capture.cleanupFailureReason = undefined;
|
|
632
|
+
}
|
|
633
|
+
capture.cleanupAttempted = true;
|
|
634
|
+
capture.cleanupPromise ??= this.focusService.cleanupSession(capture.sessionId, reason, cleanupDeadline);
|
|
635
|
+
try {
|
|
636
|
+
capture.cleanupResult = await capture.cleanupPromise;
|
|
637
|
+
capture.cleanupFailureReason = undefined;
|
|
638
|
+
return capture.cleanupResult;
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
capture.cleanupFailureReason = reason;
|
|
642
|
+
(capture.cleanupFailures ??= []).push(errorText(error));
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
reserveResidualSessionId(sessionId) {
|
|
647
|
+
let candidate;
|
|
648
|
+
do
|
|
649
|
+
candidate = `${sessionId}#rollback-${randomUUID()}`;
|
|
650
|
+
while (this.captures.has(candidate));
|
|
651
|
+
return candidate;
|
|
652
|
+
}
|
|
653
|
+
annotateResidualError(error, controlSessionId, failures) {
|
|
654
|
+
if (!error || typeof error !== "object")
|
|
655
|
+
return;
|
|
656
|
+
const message = `cleanup residual retained; residualSessionId=${controlSessionId}; failures=${failures.join("; ")}`;
|
|
657
|
+
Object.assign(error, { residualSessionId: controlSessionId, nextAction: `Retry wow_focus_stop with sessionId=${controlSessionId}.` });
|
|
658
|
+
if (error instanceof Error)
|
|
659
|
+
error.message = `${error.message}; ${message}`;
|
|
660
|
+
}
|
|
661
|
+
requireScriptParent(command, request, context) {
|
|
662
|
+
if (!command.sessionId)
|
|
663
|
+
throw new BrokerProtocolError("RESOURCE_NOT_FOUND", "script_load requires its Broker attachment sessionId");
|
|
664
|
+
const parent = context.requireOwnedResource(`attachment:${command.sessionId}`, request.clientId, "attachment");
|
|
665
|
+
if (parent.pid !== request.pid || parent.buildKey !== request.buildKey)
|
|
666
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", "script attachment scope does not match request target");
|
|
667
|
+
}
|
|
668
|
+
requireCommandOwnership(command, request, context) {
|
|
669
|
+
if (command.operation === "attach")
|
|
670
|
+
return;
|
|
671
|
+
let attachment;
|
|
672
|
+
let explicitResource;
|
|
673
|
+
if (command.sessionId)
|
|
674
|
+
attachment = context.requireOwnedResource(`attachment:${command.sessionId}`, request.clientId, "attachment");
|
|
675
|
+
if (command.scriptId) {
|
|
676
|
+
const script = context.requireOwnedResource(`script:${command.scriptId}`, request.clientId, "script");
|
|
677
|
+
if (command.sessionId && script.parentResourceId !== `attachment:${command.sessionId}`)
|
|
678
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", "script does not belong to the requested attachment");
|
|
679
|
+
attachment ??= script.parentResourceId ? context.requireOwnedResource(script.parentResourceId, request.clientId, "attachment") : undefined;
|
|
680
|
+
}
|
|
681
|
+
if (command.resourceId) {
|
|
682
|
+
explicitResource = context.requireOwnedResource(command.resourceId, request.clientId);
|
|
683
|
+
const attachmentId = explicitResource.type === "attachment" ? explicitResource.resourceId : explicitResource.parentResourceId;
|
|
684
|
+
if (attachmentId)
|
|
685
|
+
attachment ??= context.requireOwnedResource(attachmentId, request.clientId, "attachment");
|
|
686
|
+
}
|
|
687
|
+
if (command.resourceId && command.sessionId) {
|
|
688
|
+
let explicitAttachmentId;
|
|
689
|
+
let cursor = explicitResource;
|
|
690
|
+
while (cursor) {
|
|
691
|
+
if (cursor.type === "attachment") {
|
|
692
|
+
explicitAttachmentId = cursor.resourceId;
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
explicitAttachmentId = cursor.parentResourceId;
|
|
696
|
+
cursor = cursor.parentResourceId ? context.requireOwnedResource(cursor.parentResourceId, request.clientId) : undefined;
|
|
697
|
+
}
|
|
698
|
+
if (explicitAttachmentId !== `attachment:${command.sessionId}`)
|
|
699
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", "resourceId does not belong to sessionId attachment");
|
|
700
|
+
}
|
|
701
|
+
if (command.resourceId && command.scriptId && command.operation === "script_unload" && command.resourceId !== `script:${command.scriptId}`)
|
|
702
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", "resourceId does not match scriptId");
|
|
703
|
+
if (attachment && (attachment.pid !== request.pid || attachment.buildKey !== request.buildKey))
|
|
704
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", "Broker resource scope does not match request target");
|
|
705
|
+
if (explicitResource && (explicitResource.pid !== request.pid || explicitResource.buildKey !== request.buildKey))
|
|
706
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", "Broker resource scope does not match request target");
|
|
707
|
+
}
|
|
708
|
+
async validateNestedCommand(command, request, target, context) {
|
|
709
|
+
this.validateTargetBinding(request, command, target);
|
|
710
|
+
this.requireCommandOwnership(command, request, context);
|
|
711
|
+
const operation = command.operation ?? "";
|
|
712
|
+
if (operation === "script_load")
|
|
713
|
+
this.requireScriptParent(command, request, context);
|
|
714
|
+
if (RELEASE_OPERATIONS[operation])
|
|
715
|
+
this.requireReleaseResource(this.resolveResourceId(operation, command, command), request.clientId, RELEASE_OPERATIONS[operation], context);
|
|
716
|
+
if (this.strictIdentity && request.pid !== null && operation === "attach")
|
|
717
|
+
await this.validateProcessTarget(request, target);
|
|
718
|
+
else if (this.strictIdentity && request.pid !== null && this.requiresTargetIdentity(operation))
|
|
719
|
+
await this.revalidateTarget(request, command, target, context);
|
|
720
|
+
}
|
|
721
|
+
requireReleaseResource(resourceId, clientId, expectedType, context) {
|
|
722
|
+
if (!resourceId)
|
|
723
|
+
throw new BrokerProtocolError("RESOURCE_NOT_FOUND", `${expectedType} release requires resourceId`, "Use the Broker-issued resourceId; raw Frida IDs cannot release resources.");
|
|
724
|
+
return context.requireOwnedResource(resourceId, clientId, expectedType);
|
|
725
|
+
}
|
|
726
|
+
resolveResourceId(_operation, rawRequest, _command) {
|
|
727
|
+
return typeof rawRequest.resourceId === "string" ? rawRequest.resourceId : undefined;
|
|
728
|
+
}
|
|
729
|
+
async releaseOwnedResource(resourceId, request, expectedType, context) {
|
|
730
|
+
const resource = await context.releaseResource(resourceId, request.clientId, expectedType, `${request.operation}:explicit_release`);
|
|
731
|
+
return { resourceId, released: resource.cleanupState === "released", cleanupState: resource.cleanupState };
|
|
732
|
+
}
|
|
733
|
+
async registerResult(command, result, request, context) {
|
|
734
|
+
const clientId = request.clientId;
|
|
735
|
+
const pid = request.pid ?? command.pid ?? null;
|
|
736
|
+
const buildKey = request.buildKey ?? command.buildKey ?? null;
|
|
737
|
+
if (command.operation === "attach" && typeof result.sessionId === "string") {
|
|
738
|
+
const resourceId = `attachment:${result.sessionId}`;
|
|
739
|
+
if (this.sessionResources.has(resourceId))
|
|
740
|
+
context.acquireResource(resourceId, clientId);
|
|
741
|
+
else {
|
|
742
|
+
this.sessionResources.set(resourceId, result.sessionId);
|
|
743
|
+
const identity = this.attachmentIdentities.get(result.sessionId);
|
|
744
|
+
context.registerResource({
|
|
745
|
+
resourceId,
|
|
746
|
+
ownerClientId: clientId,
|
|
747
|
+
pid,
|
|
748
|
+
buildKey,
|
|
749
|
+
type: "attachment",
|
|
750
|
+
...(identity ? { identity: identityResourceFields(identity) } : {})
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
result.resourceId = resourceId;
|
|
754
|
+
}
|
|
755
|
+
if (command.operation === "script_load" && typeof result.scriptId === "string") {
|
|
756
|
+
const resourceId = `script:${result.scriptId}`;
|
|
757
|
+
if (this.scriptResources.has(resourceId)) {
|
|
758
|
+
try {
|
|
759
|
+
context.requireOwnedResource(resourceId, clientId, "script");
|
|
760
|
+
context.acquireResource(resourceId, clientId);
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
await this.executor.execute({ operation: "script_unload", sessionId: command.sessionId, scriptId: result.scriptId, pid: pid ?? undefined, buildKey: buildKey ?? undefined }).catch(() => undefined);
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
this.scriptResources.set(resourceId, result.scriptId);
|
|
769
|
+
context.registerResource({ resourceId, ownerClientId: clientId, pid, buildKey, type: "script", parentResourceId: `attachment:${command.sessionId}` });
|
|
770
|
+
}
|
|
771
|
+
result.resourceId = resourceId;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
validateTargetBinding(request, command, target) {
|
|
775
|
+
for (const candidate of [command.pid, target?.pid])
|
|
776
|
+
if (candidate !== undefined && candidate !== request.pid)
|
|
777
|
+
throw new BrokerProtocolError("MUTATION_TARGET_MISMATCH", "command/context pid differs from Broker envelope");
|
|
778
|
+
for (const candidate of [command.buildKey, target?.buildKey])
|
|
779
|
+
if (candidate !== undefined && candidate !== request.buildKey)
|
|
780
|
+
throw new BrokerProtocolError("MUTATION_TARGET_MISMATCH", "command/context buildKey differs from Broker envelope");
|
|
781
|
+
}
|
|
782
|
+
requiresTargetIdentity(operation) {
|
|
783
|
+
return !["", "devices", "processes", "applications", "session_status", "broker_status", "broker_processes"].includes(operation);
|
|
784
|
+
}
|
|
785
|
+
async validateProcessTarget(request, target) {
|
|
786
|
+
if (request.pid === null)
|
|
787
|
+
throw new BrokerProtocolError("PID_REQUIRED", "target operation requires pid");
|
|
788
|
+
const actual = await this.resolveProcess(request.pid);
|
|
789
|
+
if (!actual)
|
|
790
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", `PID ${request.pid} is no longer running`, "List current Wow.exe PIDs and start a fresh request.");
|
|
791
|
+
if (actual.name.toLowerCase() !== "wow.exe")
|
|
792
|
+
throw new BrokerProtocolError("PID_NOT_WOW", `PID ${request.pid} is ${actual.name || "an unknown process"}, not Wow.exe`);
|
|
793
|
+
if (!actual.buildKey)
|
|
794
|
+
throw new BrokerProtocolError("PID_BUILD_MISMATCH", `PID ${request.pid} has no verified build identity`);
|
|
795
|
+
if (!actual.executable || !actual.processStartTime)
|
|
796
|
+
throw new BrokerProtocolError("PID_REUSED", `PID ${request.pid} has incomplete executable/start-time identity`);
|
|
797
|
+
if (request.buildKey && actual.buildKey !== request.buildKey)
|
|
798
|
+
throw new BrokerProtocolError("PID_BUILD_MISMATCH", `PID ${request.pid} is ${actual.buildKey}, expected ${request.buildKey}`);
|
|
799
|
+
if (target?.executable && (!actual.executable || canonicalPath(actual.executable) !== canonicalPath(target.executable)))
|
|
800
|
+
throw new BrokerProtocolError("PID_REUSED", `PID ${request.pid} executable identity changed`);
|
|
801
|
+
if (target?.processStartTime && (!actual.processStartTime || normalizeTime(actual.processStartTime) !== normalizeTime(target.processStartTime)))
|
|
802
|
+
throw new BrokerProtocolError("PID_REUSED", `PID ${request.pid} start time changed`);
|
|
803
|
+
return actual;
|
|
804
|
+
}
|
|
805
|
+
async revalidateTarget(request, command, target, context) {
|
|
806
|
+
const process = await this.validateProcessTarget(request, target);
|
|
807
|
+
const sessionId = this.targetSessionId(command, request, context);
|
|
808
|
+
if (!sessionId)
|
|
809
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", "A Broker-owned attachment is required to validate Wow.exe module identity", "Attach to the explicit PID first and reuse the returned sessionId/resourceId.");
|
|
810
|
+
const expected = this.attachmentIdentities.get(sessionId);
|
|
811
|
+
if (!expected)
|
|
812
|
+
throw new BrokerProtocolError("RESOURCE_NOT_FOUND", `attachment identity for ${sessionId} was not found`, "Use the Broker-issued attachment resource.");
|
|
813
|
+
const actual = await this.resolveModuleIdentity(request, sessionId, process);
|
|
814
|
+
assertStableIdentity(expected, actual);
|
|
815
|
+
if (target?.moduleBase && normalizeHex(target.moduleBase) !== normalizeHex(actual.moduleBase))
|
|
816
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", `Wow.exe module base changed for PID ${request.pid}`);
|
|
817
|
+
if (target?.moduleIdentity && target.moduleIdentity !== actual.moduleIdentity)
|
|
818
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", `Wow.exe module identity changed for PID ${request.pid}`);
|
|
819
|
+
return actual;
|
|
820
|
+
}
|
|
821
|
+
async validateAttachedTarget(request, command, target, result) {
|
|
822
|
+
const sessionId = typeof result.sessionId === "string" ? result.sessionId : undefined;
|
|
823
|
+
if (!sessionId)
|
|
824
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", "attach did not return a sessionId");
|
|
825
|
+
try {
|
|
826
|
+
const process = await this.validateProcessTarget(request, target);
|
|
827
|
+
const actual = await this.resolveModuleIdentity(request, sessionId, process);
|
|
828
|
+
const prior = this.attachmentIdentities.get(sessionId);
|
|
829
|
+
if (prior)
|
|
830
|
+
assertStableIdentity(prior, actual);
|
|
831
|
+
if (target?.moduleBase && normalizeHex(target.moduleBase) !== normalizeHex(actual.moduleBase))
|
|
832
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", `Wow.exe module base changed for PID ${request.pid}`);
|
|
833
|
+
if (target?.moduleIdentity && target.moduleIdentity !== actual.moduleIdentity)
|
|
834
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", `Wow.exe module identity changed for PID ${request.pid}`);
|
|
835
|
+
this.attachmentIdentities.set(sessionId, actual);
|
|
836
|
+
return actual;
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
if (result.reused !== true)
|
|
840
|
+
await this.executor.execute({ operation: "detach", sessionId, pid: request.pid ?? undefined, buildKey: request.buildKey ?? undefined }, target).catch(() => undefined);
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
targetSessionId(command, request, context) {
|
|
845
|
+
if (command.sessionId) {
|
|
846
|
+
const resource = context.requireOwnedResource(`attachment:${command.sessionId}`, request.clientId, "attachment");
|
|
847
|
+
if (resource.pid !== request.pid || resource.buildKey !== request.buildKey)
|
|
848
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", "attachment scope does not match request target");
|
|
849
|
+
return command.sessionId;
|
|
850
|
+
}
|
|
851
|
+
const expectedType = RELEASE_OPERATIONS[command.operation ?? ""];
|
|
852
|
+
if (!expectedType || typeof command.resourceId !== "string")
|
|
853
|
+
return undefined;
|
|
854
|
+
const resource = context.requireOwnedResource(command.resourceId, request.clientId, expectedType);
|
|
855
|
+
const attachmentId = resource.type === "attachment" ? resource.resourceId : resource.parentResourceId;
|
|
856
|
+
return attachmentId ? idFromResource(attachmentId, "attachment:") : undefined;
|
|
857
|
+
}
|
|
858
|
+
async resolveProcess(pid) {
|
|
859
|
+
if (this.processResolver)
|
|
860
|
+
return this.processResolver(pid);
|
|
861
|
+
const response = await this.executor.execute({ operation: "processes" });
|
|
862
|
+
const record = arrayOfRecords(response.processes ?? response.value).find(item => Number(item.pid) === pid);
|
|
863
|
+
return record ? processIdentityFromRecord(record) : undefined;
|
|
864
|
+
}
|
|
865
|
+
async resolveModuleIdentity(request, sessionId, process) {
|
|
866
|
+
let response;
|
|
867
|
+
try {
|
|
868
|
+
response = await this.executor.execute({ operation: "modules", sessionId, pid: request.pid ?? undefined, buildKey: request.buildKey ?? undefined }, { pid: request.pid ?? undefined, buildKey: request.buildKey ?? undefined, executable: process.executable, processStartTime: process.processStartTime });
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", `failed to enumerate Wow.exe modules: ${errorText(error)}`);
|
|
872
|
+
}
|
|
873
|
+
const module = arrayOfRecords(response.modules ?? response.value).find(item => String(item.name ?? "").toLowerCase() === "wow.exe");
|
|
874
|
+
if (!module || typeof module.base !== "string" || typeof module.path !== "string")
|
|
875
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", `Wow.exe module identity is incomplete for PID ${request.pid}`);
|
|
876
|
+
const modulePath = module.path;
|
|
877
|
+
if (process.executable && modulePath && canonicalPath(process.executable) !== canonicalPath(modulePath))
|
|
878
|
+
throw new BrokerProtocolError("PID_REUSED", `Wow.exe module path changed for PID ${request.pid}`);
|
|
879
|
+
const moduleSize = typeof module.size === "number" && Number.isFinite(module.size) ? module.size : undefined;
|
|
880
|
+
const moduleBase = normalizeHex(module.base);
|
|
881
|
+
const moduleIdentity = createHash("sha256").update(JSON.stringify({ name: "Wow.exe", path: modulePath ? canonicalPath(modulePath) : null, size: moduleSize ?? null, base: moduleBase })).digest("hex");
|
|
882
|
+
return {
|
|
883
|
+
...process,
|
|
884
|
+
name: "Wow.exe",
|
|
885
|
+
buildKey: process.buildKey,
|
|
886
|
+
moduleBase,
|
|
887
|
+
moduleIdentity,
|
|
888
|
+
...(modulePath ? { modulePath } : {}),
|
|
889
|
+
...(moduleSize !== undefined ? { moduleSize } : {})
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
validateMutation(request, operation, mutation) {
|
|
893
|
+
if (!mutation)
|
|
894
|
+
throw new BrokerProtocolError("MUTATION_PLAN_REQUIRED", `${operation} requires a mutation contract`, "Provide mutationPlanId, confirmation, rollback, evidencePlan and audit.");
|
|
895
|
+
if (!mutation.mutationPlanId)
|
|
896
|
+
throw new BrokerProtocolError("MUTATION_PLAN_REQUIRED", "mutationPlanId is required");
|
|
897
|
+
if (!mutation.confirmation)
|
|
898
|
+
throw new BrokerProtocolError("MUTATION_CONFIRMATION_REQUIRED", "mutation confirmation is required");
|
|
899
|
+
if (mutation.confirmation.clientId !== request.clientId)
|
|
900
|
+
throw new BrokerProtocolError("MUTATION_TARGET_MISMATCH", "confirmation clientId does not match request clientId");
|
|
901
|
+
const confirmedAt = Date.parse(mutation.confirmation.confirmedAt);
|
|
902
|
+
if (!Number.isFinite(confirmedAt) || Math.abs(Date.now() - confirmedAt) > 300_000)
|
|
903
|
+
throw new BrokerProtocolError("MUTATION_CONFIRMATION_EXPIRED", "mutation confirmation is expired");
|
|
904
|
+
if (!mutation.confirmation.nonce)
|
|
905
|
+
throw new BrokerProtocolError("MUTATION_CONFIRMATION_REQUIRED", "confirmation nonce is required");
|
|
906
|
+
if (this.confirmationNonces.has(mutation.confirmation.nonce))
|
|
907
|
+
throw new BrokerProtocolError("MUTATION_CONFIRMATION_REPLAYED", "confirmation nonce was already used");
|
|
908
|
+
if (mutation.expectedEffect === undefined || mutation.expectedEffect === null)
|
|
909
|
+
throw new BrokerProtocolError("MUTATION_EXPECTED_EFFECT_REQUIRED", "expectedEffect is required");
|
|
910
|
+
if (!mutation.rollback)
|
|
911
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_REQUIRED", "rollback is required");
|
|
912
|
+
if (!mutation.evidencePlan)
|
|
913
|
+
throw new BrokerProtocolError("MUTATION_EVIDENCE_PLAN_REQUIRED", "evidencePlan is required");
|
|
914
|
+
if (!mutation.audit)
|
|
915
|
+
throw new BrokerProtocolError("MUTATION_AUDIT_REQUIRED", "audit is required");
|
|
916
|
+
if (mutation.audit.requestId !== request.requestId)
|
|
917
|
+
throw new BrokerProtocolError("MUTATION_TARGET_MISMATCH", "audit requestId does not match requestId");
|
|
918
|
+
const policy = MUTATION_POLICIES[operation];
|
|
919
|
+
if (!policy || !policy.rollbackModes.includes(mutation.rollback.mode))
|
|
920
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_INVALID", `${operation} does not allow ${mutation.rollback.mode} rollback`);
|
|
921
|
+
if (mutation.rollback.mode !== "irreversible" && (!mutation.rollback.plan || !mutation.rollback.deadline || !mutation.rollback.successOracle))
|
|
922
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_INVALID", "reversible and compensating rollback require plan, deadline and successOracle");
|
|
923
|
+
if (mutation.rollback.mode === "irreversible" && (!mutation.rollback.irreversibleReason || mutation.confirmation.irreversibleAcknowledged !== true))
|
|
924
|
+
throw new BrokerProtocolError("MUTATION_IRREVERSIBLE_CONFIRMATION_REQUIRED", "irreversible mutations require explicit acknowledgement and reason");
|
|
925
|
+
if (mutation.rollback.mode !== "irreversible") {
|
|
926
|
+
const rollbackOperation = isRecord(mutation.rollback.plan) ? mutation.rollback.plan.operation : undefined;
|
|
927
|
+
if (typeof rollbackOperation !== "string" || !policy.rollbackOperations.includes(rollbackOperation))
|
|
928
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_INVALID", `${operation} rollback operation is not allowlisted`);
|
|
929
|
+
}
|
|
930
|
+
if (request.pid === null)
|
|
931
|
+
throw new BrokerProtocolError("PID_REQUIRED", "mutating operation requires pid");
|
|
932
|
+
const mutationTarget = mutation.target;
|
|
933
|
+
if (mutationTarget && (mutationTarget.pid !== request.pid || (mutationTarget.buildKey ?? null) !== request.buildKey))
|
|
934
|
+
throw new BrokerProtocolError("MUTATION_TARGET_MISMATCH", "mutation target differs from Broker envelope");
|
|
935
|
+
this.confirmationNonces.add(mutation.confirmation.nonce);
|
|
936
|
+
}
|
|
937
|
+
validateLaunch(request, launch) {
|
|
938
|
+
if (request.pid !== null)
|
|
939
|
+
throw new BrokerProtocolError("SPAWN_PID_MUST_BE_NULL", "spawn must not be bound to an existing pid");
|
|
940
|
+
if (!launch)
|
|
941
|
+
throw new BrokerProtocolError("SPAWN_CONTRACT_REQUIRED", "spawn requires a launch contract");
|
|
942
|
+
if (!launch.mutationPlanId)
|
|
943
|
+
throw new BrokerProtocolError("MUTATION_PLAN_REQUIRED", "launch mutationPlanId is required");
|
|
944
|
+
if (!launch.confirmation)
|
|
945
|
+
throw new BrokerProtocolError("MUTATION_CONFIRMATION_REQUIRED", "launch confirmation is required");
|
|
946
|
+
if (launch.confirmation.clientId !== request.clientId)
|
|
947
|
+
throw new BrokerProtocolError("MUTATION_TARGET_MISMATCH", "launch confirmation clientId does not match request clientId");
|
|
948
|
+
const confirmedAt = Date.parse(launch.confirmation.confirmedAt);
|
|
949
|
+
if (!Number.isFinite(confirmedAt) || Math.abs(Date.now() - confirmedAt) > 300_000)
|
|
950
|
+
throw new BrokerProtocolError("MUTATION_CONFIRMATION_EXPIRED", "launch confirmation is expired");
|
|
951
|
+
if (!launch.confirmation.nonce)
|
|
952
|
+
throw new BrokerProtocolError("MUTATION_CONFIRMATION_REQUIRED", "launch confirmation nonce is required");
|
|
953
|
+
if (this.confirmationNonces.has(launch.confirmation.nonce))
|
|
954
|
+
throw new BrokerProtocolError("MUTATION_CONFIRMATION_REPLAYED", "launch confirmation nonce was already used");
|
|
955
|
+
if (!launch.launchSpec || typeof launch.launchSpec.executable !== "string" || !launch.launchSpec.executable || !Array.isArray(launch.launchSpec.argv) || launch.launchSpec.argv.some(value => typeof value !== "string") || typeof launch.launchSpec.cwd !== "string" || !launch.launchSpec.cwd || !/^[a-f0-9]{64}$/i.test(launch.launchSpec.environmentDigest) || typeof launch.launchSpec.suspended !== "boolean")
|
|
956
|
+
throw new BrokerProtocolError("SPAWN_CONTRACT_REQUIRED", "launchSpec is incomplete or invalid");
|
|
957
|
+
if (!isAbsolute(launch.launchSpec.executable) || !isAbsolute(launch.launchSpec.cwd))
|
|
958
|
+
throw new BrokerProtocolError("SPAWN_CONTRACT_REQUIRED", "launch executable and cwd must be absolute paths");
|
|
959
|
+
if (launch.expectedEffect === undefined || launch.expectedEffect === null)
|
|
960
|
+
throw new BrokerProtocolError("MUTATION_EXPECTED_EFFECT_REQUIRED", "launch expectedEffect is required");
|
|
961
|
+
if (!launch.rollback)
|
|
962
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_REQUIRED", "launch rollback is required");
|
|
963
|
+
if (!launch.evidencePlan)
|
|
964
|
+
throw new BrokerProtocolError("MUTATION_EVIDENCE_PLAN_REQUIRED", "launch evidencePlan is required");
|
|
965
|
+
if (!launch.audit)
|
|
966
|
+
throw new BrokerProtocolError("MUTATION_AUDIT_REQUIRED", "launch audit is required");
|
|
967
|
+
if (launch.audit.requestId !== request.requestId)
|
|
968
|
+
throw new BrokerProtocolError("MUTATION_TARGET_MISMATCH", "launch audit requestId does not match requestId");
|
|
969
|
+
if (launch.rollback.mode !== "compensating" || !isRecord(launch.rollback.plan) || launch.rollback.plan.operation !== "kill" || !launch.rollback.deadline || !launch.rollback.successOracle)
|
|
970
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_INVALID", "spawn requires a compensating kill rollback with deadline and oracle");
|
|
971
|
+
this.confirmationNonces.add(launch.confirmation.nonce);
|
|
972
|
+
}
|
|
973
|
+
async executeLaunch(command, launch) {
|
|
974
|
+
const result = await this.executor.execute({
|
|
975
|
+
...command,
|
|
976
|
+
pid: undefined,
|
|
977
|
+
program: launch.launchSpec.executable,
|
|
978
|
+
argv: [launch.launchSpec.executable, ...launch.launchSpec.argv],
|
|
979
|
+
options: { ...(command.options ?? {}), cwd: launch.launchSpec.cwd, suspended: launch.launchSpec.suspended }
|
|
980
|
+
});
|
|
981
|
+
const pid = typeof result.pid === "number" ? result.pid : undefined;
|
|
982
|
+
if (!pid)
|
|
983
|
+
throw new BrokerProtocolError("MUTATION_EXECUTION_FAILED", "spawn did not return a pid");
|
|
984
|
+
const identity = await this.spawnIdentity(pid, result);
|
|
985
|
+
if (!identity.buildKey) {
|
|
986
|
+
await this.executor.execute({ operation: "kill", pid }).catch(() => undefined);
|
|
987
|
+
throw new BrokerProtocolError("PID_BUILD_MISMATCH", `spawned PID ${pid} has no verified build identity`);
|
|
988
|
+
}
|
|
989
|
+
if (!identity.executable || !identity.startTime || !identity.moduleIdentity) {
|
|
990
|
+
await this.executor.execute({ operation: "kill", pid }).catch(() => undefined);
|
|
991
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", `spawned PID ${pid} has incomplete executable/start-time/module identity`);
|
|
992
|
+
}
|
|
993
|
+
if (canonicalPath(identity.executable) !== canonicalPath(launch.launchSpec.executable)) {
|
|
994
|
+
await this.executor.execute({ operation: "kill", pid }).catch(() => undefined);
|
|
995
|
+
throw new BrokerProtocolError("PID_REUSED", `spawned PID ${pid} executable identity differs from launch contract`);
|
|
996
|
+
}
|
|
997
|
+
if (launch.expectedBuildKey && launch.expectedBuildKey !== identity.buildKey) {
|
|
998
|
+
await this.executor.execute({ operation: "kill", pid }).catch(() => undefined);
|
|
999
|
+
throw new BrokerProtocolError("SPAWN_BUILD_MISMATCH", `spawned process is ${identity.buildKey}, expected ${launch.expectedBuildKey}`);
|
|
1000
|
+
}
|
|
1001
|
+
return { ...result, pid, buildKey: identity.buildKey ?? launch.expectedBuildKey ?? null, startTime: identity.startTime ?? null, executable: identity.executable ?? launch.launchSpec.executable, suspended: launch.launchSpec.suspended };
|
|
1002
|
+
}
|
|
1003
|
+
async spawnIdentity(pid, result) {
|
|
1004
|
+
const direct = isRecord(result.identity) ? result.identity : result;
|
|
1005
|
+
let candidate = direct;
|
|
1006
|
+
try {
|
|
1007
|
+
const processes = await this.executor.execute({ operation: "processes" });
|
|
1008
|
+
candidate = arrayOfRecords(processes.processes ?? processes.value).find(item => item.pid === pid) ?? direct;
|
|
1009
|
+
}
|
|
1010
|
+
catch { /* direct spawn evidence remains available */ }
|
|
1011
|
+
return {
|
|
1012
|
+
buildKey: typeof candidate.buildKey === "string" ? candidate.buildKey : undefined,
|
|
1013
|
+
startTime: typeof candidate.startTime === "string" ? candidate.startTime : undefined,
|
|
1014
|
+
executable: typeof candidate.executable === "string" ? candidate.executable : typeof candidate.path === "string" ? candidate.path : undefined,
|
|
1015
|
+
moduleIdentity: typeof candidate.moduleIdentity === "string" ? candidate.moduleIdentity : undefined
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
validateHostCall(request, command, mutation) {
|
|
1019
|
+
if (!request.buildKey)
|
|
1020
|
+
throw new BrokerProtocolError("HOST_CALL_BUILD_KEY_REQUIRED", "host_call requires buildKey");
|
|
1021
|
+
const method = typeof command.method === "string" ? command.method : "";
|
|
1022
|
+
const entry = this.hostAllowlist.find(item => (item.buildKey === "*" || item.buildKey === request.buildKey) && item.methods.includes(method));
|
|
1023
|
+
if (!entry)
|
|
1024
|
+
throw new BrokerProtocolError("HOST_CALL_NOT_ALLOWLISTED", `host call ${method || "<empty>"} is not allowlisted`, "Use a registered Broker host-call entry.");
|
|
1025
|
+
const args = command.args ?? [];
|
|
1026
|
+
if (entry.argumentTypes && (args.length !== entry.argumentTypes.length || args.some((value, index) => jsonType(value) !== entry.argumentTypes[index])))
|
|
1027
|
+
throw new BrokerProtocolError("HOST_CALL_SCHEMA_MISMATCH", `host call ${method} arguments do not match the registered schema`);
|
|
1028
|
+
if (entry.maxDurationMs !== undefined && (command.timeoutMs ?? entry.maxDurationMs) > entry.maxDurationMs)
|
|
1029
|
+
throw new BrokerProtocolError("HOST_CALL_SCHEMA_MISMATCH", `host call ${method} exceeds its ${entry.maxDurationMs}ms deadline`);
|
|
1030
|
+
if (!(entry.rollbackModes ?? []).includes(mutation.rollback.mode))
|
|
1031
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_INVALID", `host call ${method} does not allow ${mutation.rollback.mode} rollback`);
|
|
1032
|
+
if (entry.sideEffect === "read" && mutation.rollback.mode === "irreversible")
|
|
1033
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_INVALID", `read-only host call ${method} cannot be declared irreversible`);
|
|
1034
|
+
}
|
|
1035
|
+
async captureEvidence(phase, request, command, target, context, plan, result) {
|
|
1036
|
+
const measurement = await this.measure(request, command, target, context, plan, result);
|
|
1037
|
+
validateEvidencePlan(plan, measurement);
|
|
1038
|
+
const record = {
|
|
1039
|
+
schema: "wowdump.broker.mutation-evidence.v1",
|
|
1040
|
+
phase,
|
|
1041
|
+
capturedAt: new Date().toISOString(),
|
|
1042
|
+
requestId: request.requestId,
|
|
1043
|
+
clientId: request.clientId,
|
|
1044
|
+
pid: request.pid,
|
|
1045
|
+
buildKey: request.buildKey,
|
|
1046
|
+
operation: command.operation ?? null,
|
|
1047
|
+
measurement
|
|
1048
|
+
};
|
|
1049
|
+
const body = Buffer.from(JSON.stringify(record), "utf8");
|
|
1050
|
+
const sha256 = createHash("sha256").update(body).digest("hex");
|
|
1051
|
+
await mkdir(this.evidenceDirectory, { recursive: true });
|
|
1052
|
+
const path = join(this.evidenceDirectory, `${sanitize(request.requestId)}-${phase}-${randomUUID()}-${sha256.slice(0, 16)}.json`);
|
|
1053
|
+
const handle = await open(path, "wx");
|
|
1054
|
+
try {
|
|
1055
|
+
await handle.writeFile(body);
|
|
1056
|
+
await handle.sync();
|
|
1057
|
+
}
|
|
1058
|
+
finally {
|
|
1059
|
+
await handle.close();
|
|
1060
|
+
}
|
|
1061
|
+
return { path, size: body.length, sha256, schema: record.schema, phase };
|
|
1062
|
+
}
|
|
1063
|
+
async measure(request, command, target, context, plan, result) {
|
|
1064
|
+
if (isRecord(plan) && isRecord(plan.command)) {
|
|
1065
|
+
const evidenceCommand = deserializeCommand(plan.command);
|
|
1066
|
+
if (!READ_ONLY_EVIDENCE_OPERATIONS.has(evidenceCommand.operation ?? ""))
|
|
1067
|
+
throw new BrokerProtocolError("MUTATION_EVIDENCE_MISMATCH", "evidence command must be read-only");
|
|
1068
|
+
await this.validateNestedCommand(evidenceCommand, request, target, context);
|
|
1069
|
+
return toJson(await this.executor.execute(evidenceCommand, target));
|
|
1070
|
+
}
|
|
1071
|
+
if (command.operation === "write_memory" && command.address !== undefined && command.bytesHex) {
|
|
1072
|
+
const evidenceCommand = { operation: "read_memory", sessionId: command.sessionId, address: command.address, size: Math.ceil(command.bytesHex.length / 2) };
|
|
1073
|
+
await this.validateNestedCommand(evidenceCommand, request, target, context);
|
|
1074
|
+
return toJson(await this.executor.execute(evidenceCommand, target));
|
|
1075
|
+
}
|
|
1076
|
+
return toJson({ resource: this.resourceMeasurement(command), result: result ?? null });
|
|
1077
|
+
}
|
|
1078
|
+
resourceMeasurement(command) {
|
|
1079
|
+
const sourceHash = typeof command.source === "string" ? createHash("sha256").update(command.source).digest("hex") : null;
|
|
1080
|
+
return { sessionId: command.sessionId ?? null, scriptId: command.scriptId ?? null, sourceSha256: sourceHash, exportName: command.exportName ?? null };
|
|
1081
|
+
}
|
|
1082
|
+
async rollbackMutation(request, command, mutation, target, context, rootError, evidence, executionResult) {
|
|
1083
|
+
const rootMessage = errorText(rootError);
|
|
1084
|
+
const residualStateEvidence = evidence.length > 0 ? evidence.map(reference => reference.path) : ["no-residual-evidence-captured"];
|
|
1085
|
+
const manualVerification = "Reopen the recorded evidenceRefs and verify the target state against the expected effect.";
|
|
1086
|
+
const manualRecoveryAction = "Do not replay the request; inspect residual-state evidence and restore the intended state manually.";
|
|
1087
|
+
if (mutation.rollback.mode === "irreversible") {
|
|
1088
|
+
context.transition("irreversible_failed", { error: toJson({ code: "MUTATION_EXECUTION_FAILED", rootError: rootMessage, residualStateEvidence, manualVerification, manualRecoveryAction }) });
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const deadline = Date.parse(mutation.rollback.deadline ?? "");
|
|
1092
|
+
const plan = isRecord(mutation.rollback.plan) ? mutation.rollback.plan : undefined;
|
|
1093
|
+
if (!Number.isFinite(deadline) || deadline < Date.now() || !plan || typeof plan.operation !== "string") {
|
|
1094
|
+
context.transition("rollback_unavailable", { error: toJson({ code: "MUTATION_ROLLBACK_UNAVAILABLE", rootError: rootMessage, failedPrecondition: !plan ? "rollback plan is missing" : "rollback deadline is missing or expired", deadline: mutation.rollback.deadline ?? null, residualStateEvidence, manualVerification, manualRecoveryAction }) });
|
|
1095
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_UNAVAILABLE", "rollback deadline or executable plan is unavailable");
|
|
1096
|
+
}
|
|
1097
|
+
context.transition("rollback_started", { error: toJson({ rootError: errorText(rootError) }) });
|
|
1098
|
+
try {
|
|
1099
|
+
const rollbackCommand = deserializeCommand({ ...plan, ...(command.operation === "spawn" && typeof executionResult?.pid === "number" ? { pid: executionResult.pid } : {}) });
|
|
1100
|
+
await this.validateNestedCommand(rollbackCommand, request, target, context);
|
|
1101
|
+
const result = await this.executor.execute(rollbackCommand, target);
|
|
1102
|
+
context.transition("rollback_executed", { result: toJson(result) });
|
|
1103
|
+
assertMatches(mutation.rollback.successOracle, toJson(result), "rollback success oracle");
|
|
1104
|
+
const reference = await this.captureEvidence("rollback", request, command, target, context, mutation.evidencePlan.rollback ?? {}, result);
|
|
1105
|
+
evidence.push(reference);
|
|
1106
|
+
context.transition("rollback_validated", { evidenceRefs: [reference.path], result: { evidence: toJson(evidence) } });
|
|
1107
|
+
context.transition("rolled_back");
|
|
1108
|
+
}
|
|
1109
|
+
catch (rollbackError) {
|
|
1110
|
+
context.transition("rollback_failed", { error: toJson({ code: "MUTATION_ROLLBACK_FAILED", rootError: rootMessage, rollbackError: errorText(rollbackError), residualStateEvidence, manualVerification, manualRecoveryAction }) });
|
|
1111
|
+
throw new BrokerProtocolError("MUTATION_ROLLBACK_FAILED", "mutation and rollback both failed");
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
function validateEvidencePlan(plan, measurement) {
|
|
1116
|
+
if (!isRecord(plan))
|
|
1117
|
+
return;
|
|
1118
|
+
if (plan.expected !== undefined)
|
|
1119
|
+
assertMatches(plan.expected, measurement, "evidence expected value");
|
|
1120
|
+
if (!isRecord(plan.schema) || !isRecord(measurement))
|
|
1121
|
+
return;
|
|
1122
|
+
const required = Array.isArray(plan.schema.required) ? plan.schema.required : [];
|
|
1123
|
+
for (const key of required)
|
|
1124
|
+
if (typeof key === "string" && !(key in measurement))
|
|
1125
|
+
throw new BrokerProtocolError("MUTATION_EVIDENCE_MISMATCH", `evidence is missing required field ${key}`);
|
|
1126
|
+
const properties = isRecord(plan.schema.properties) ? plan.schema.properties : {};
|
|
1127
|
+
for (const [key, descriptor] of Object.entries(properties))
|
|
1128
|
+
if (isRecord(descriptor) && typeof descriptor.type === "string" && key in measurement && jsonType(measurement[key]) !== descriptor.type)
|
|
1129
|
+
throw new BrokerProtocolError("MUTATION_EVIDENCE_MISMATCH", `evidence field ${key} has the wrong type`);
|
|
1130
|
+
}
|
|
1131
|
+
function assertMatches(expected, actual, label) {
|
|
1132
|
+
if (isRecord(expected)) {
|
|
1133
|
+
if (!isRecord(actual))
|
|
1134
|
+
throw new BrokerProtocolError("MUTATION_EVIDENCE_MISMATCH", `${label} expected an object`);
|
|
1135
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
1136
|
+
if (!(key in actual))
|
|
1137
|
+
throw new BrokerProtocolError("MUTATION_EVIDENCE_MISMATCH", `${label} is missing ${key}`);
|
|
1138
|
+
assertMatches(value, actual[key], `${label}.${key}`);
|
|
1139
|
+
}
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (Array.isArray(expected)) {
|
|
1143
|
+
if (!Array.isArray(actual) || expected.length !== actual.length)
|
|
1144
|
+
throw new BrokerProtocolError("MUTATION_EVIDENCE_MISMATCH", `${label} array mismatch`);
|
|
1145
|
+
expected.forEach((value, index) => assertMatches(value, actual[index], `${label}[${index}]`));
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (expected !== actual)
|
|
1149
|
+
throw new BrokerProtocolError("MUTATION_EVIDENCE_MISMATCH", `${label} mismatch`);
|
|
1150
|
+
}
|
|
1151
|
+
function mergeEvidence(result, evidence) {
|
|
1152
|
+
return isRecord(result) ? toJson({ ...result, brokerEvidence: evidence }) : toJson({ value: result, brokerEvidence: evidence });
|
|
1153
|
+
}
|
|
1154
|
+
function cleanupStepsFromResult(value, failure) {
|
|
1155
|
+
const record = isRecord(value) ? value : {};
|
|
1156
|
+
const raw = arrayOfRecords(record.cleanupSteps);
|
|
1157
|
+
const cleanupStatus = isRecord(record.cleanupStatus) ? record.cleanupStatus : {};
|
|
1158
|
+
const byName = new Map(raw.map(step => [String(step.step ?? ""), step]));
|
|
1159
|
+
const failed = (names) => names.some(name => {
|
|
1160
|
+
const step = byName.get(name);
|
|
1161
|
+
return step && step.status !== "passed" && step.status !== "deferred";
|
|
1162
|
+
});
|
|
1163
|
+
const errorFor = (names) => names.map(name => byName.get(name)).find(step => step && step.status !== "passed" && step.status !== "deferred")?.error;
|
|
1164
|
+
const cleanupResidual = Number(cleanupStatus.hooks ?? 0) !== 0 || Number(cleanupStatus.scripts ?? 0) !== 0 || Number(cleanupStatus.interceptors ?? 0) !== 0;
|
|
1165
|
+
const groups = [
|
|
1166
|
+
{ step: "stop_gate", names: ["stop_accepting_events"] },
|
|
1167
|
+
{ step: "after_snapshot", names: ["after_snapshot", "append_after_snapshot"] },
|
|
1168
|
+
{ step: "trace_dispose", names: ["trace_dispose", "trace_hook_script_cleanup"] },
|
|
1169
|
+
{ step: "hook_cleanup", names: ["hook_cleanup", "trace_hook_script_cleanup"], residual: cleanupResidual },
|
|
1170
|
+
{ step: "script_unload", names: ["script_unload", "trace_hook_script_cleanup"], residual: cleanupResidual },
|
|
1171
|
+
{ step: "artifact_freeze", names: ["drain_append_queue", "stream_prefixes", "write_manifest", "final_stream_prefixes", "freeze_artifacts", "publish_terminal_failure", "quiesce_manifest_publications"] }
|
|
1172
|
+
];
|
|
1173
|
+
return groups.map(group => {
|
|
1174
|
+
const stepFailed = failure !== undefined || group.residual === true || failed(group.names);
|
|
1175
|
+
const message = errorFor(group.names);
|
|
1176
|
+
return {
|
|
1177
|
+
step: group.step,
|
|
1178
|
+
ok: !stepFailed,
|
|
1179
|
+
...(stepFailed ? { error: message ? String(message) : failure ? errorText(failure) : "cleanup residual remained" } : {})
|
|
1180
|
+
};
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
function evidenceRefsFromFocusResult(value) {
|
|
1184
|
+
const refs = new Set();
|
|
1185
|
+
if (typeof value.artifactDirectory === "string")
|
|
1186
|
+
refs.add(value.artifactDirectory);
|
|
1187
|
+
if (isRecord(value.streamFiles))
|
|
1188
|
+
for (const path of Object.values(value.streamFiles))
|
|
1189
|
+
if (typeof path === "string")
|
|
1190
|
+
refs.add(path);
|
|
1191
|
+
return [...refs].sort();
|
|
1192
|
+
}
|
|
1193
|
+
function idFromResource(resourceId, prefix) { return resourceId.startsWith(prefix) ? resourceId.slice(prefix.length) : undefined; }
|
|
1194
|
+
function sanitize(value) { return value.replace(/[^A-Za-z0-9_.-]/g, "_"); }
|
|
1195
|
+
function jsonType(value) { return value === null ? "null" : Array.isArray(value) ? "array" : typeof value; }
|
|
1196
|
+
function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
1197
|
+
function arrayOfRecords(value) { return Array.isArray(value) ? value.filter(isRecord) : []; }
|
|
1198
|
+
function processIdentityFromRecord(record) {
|
|
1199
|
+
const parameters = isRecord(record.parameters) ? record.parameters : {};
|
|
1200
|
+
const name = stringValue(record.name) ?? stringValue(record.processName) ?? stringValue(parameters.name) ?? "";
|
|
1201
|
+
const executable = stringValue(record.executable) ?? stringValue(record.path) ?? stringValue(record.executablePath) ?? stringValue(parameters.executable) ?? stringValue(parameters.path) ?? stringValue(parameters.ExecutablePath);
|
|
1202
|
+
const processStartTime = stringValue(record.processStartTime) ?? stringValue(record.startTime) ?? stringValue(record.creationTime) ?? stringValue(parameters.startTime) ?? stringValue(parameters.creationTime);
|
|
1203
|
+
const buildKey = stringValue(record.buildKey) ?? stringValue(parameters.buildKey);
|
|
1204
|
+
return {
|
|
1205
|
+
pid: Number(record.pid),
|
|
1206
|
+
name,
|
|
1207
|
+
...(executable ? { executable } : {}),
|
|
1208
|
+
...(processStartTime ? { processStartTime } : {}),
|
|
1209
|
+
...(buildKey ? { buildKey } : {})
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
function stringValue(value) { return typeof value === "string" && value.length > 0 ? value : undefined; }
|
|
1213
|
+
function canonicalPath(value) { return value.replace(/[\\/]+/g, "\\").replace(/^\\\?\\/, "").toLowerCase(); }
|
|
1214
|
+
function normalizeTime(value) { return value.trim().replace(/\.\d+(?=Z$)/i, "").toLowerCase(); }
|
|
1215
|
+
function normalizeHex(value) {
|
|
1216
|
+
const text = value.trim();
|
|
1217
|
+
if (!/^0x[0-9a-f]+$/i.test(text))
|
|
1218
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", `invalid module base ${value}`);
|
|
1219
|
+
return `0x${text.slice(2).toLowerCase()}`;
|
|
1220
|
+
}
|
|
1221
|
+
function identityResourceFields(identity) {
|
|
1222
|
+
return {
|
|
1223
|
+
...(identity.executable ? { executable: identity.executable } : {}),
|
|
1224
|
+
...(identity.processStartTime ? { processStartTime: identity.processStartTime } : {}),
|
|
1225
|
+
moduleBase: identity.moduleBase,
|
|
1226
|
+
moduleIdentity: identity.moduleIdentity
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
function sameCaptureIdentity(left, right) {
|
|
1230
|
+
try {
|
|
1231
|
+
const sameOptional = (a, b, normalize) => a === undefined && b === undefined || a !== undefined && b !== undefined && normalize(a) === normalize(b);
|
|
1232
|
+
return sameOptional(left.executable, right.executable, canonicalPath)
|
|
1233
|
+
&& sameOptional(left.processStartTime, right.processStartTime, normalizeTime)
|
|
1234
|
+
&& sameOptional(left.moduleBase, right.moduleBase, normalizeHex)
|
|
1235
|
+
&& sameOptional(left.moduleIdentity, right.moduleIdentity, value => value);
|
|
1236
|
+
}
|
|
1237
|
+
catch {
|
|
1238
|
+
return false;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
function assertStableIdentity(expected, actual) {
|
|
1242
|
+
if (expected.pid !== actual.pid)
|
|
1243
|
+
throw new BrokerProtocolError("PID_REUSED", `PID changed from ${expected.pid} to ${actual.pid}`);
|
|
1244
|
+
if (expected.buildKey !== actual.buildKey)
|
|
1245
|
+
throw new BrokerProtocolError("PID_BUILD_MISMATCH", `build changed from ${expected.buildKey} to ${actual.buildKey}`);
|
|
1246
|
+
if (expected.executable && (!actual.executable || canonicalPath(expected.executable) !== canonicalPath(actual.executable)))
|
|
1247
|
+
throw new BrokerProtocolError("PID_REUSED", "executable identity changed");
|
|
1248
|
+
if (expected.processStartTime && (!actual.processStartTime || normalizeTime(expected.processStartTime) !== normalizeTime(actual.processStartTime)))
|
|
1249
|
+
throw new BrokerProtocolError("PID_REUSED", "process start time changed");
|
|
1250
|
+
if (normalizeHex(expected.moduleBase) !== normalizeHex(actual.moduleBase) || expected.moduleIdentity !== actual.moduleIdentity)
|
|
1251
|
+
throw new BrokerProtocolError("MODULE_NOT_FOUND", "Wow.exe module identity changed");
|
|
1252
|
+
}
|
|
1253
|
+
function deserializeCommand(value) { return { ...value }; }
|
|
1254
|
+
function deserializeContext(value) {
|
|
1255
|
+
const context = {};
|
|
1256
|
+
if (typeof value.pid === "number")
|
|
1257
|
+
context.pid = value.pid;
|
|
1258
|
+
if (typeof value.buildKey === "string")
|
|
1259
|
+
context.buildKey = value.buildKey;
|
|
1260
|
+
if (typeof value.flavor === "string")
|
|
1261
|
+
context.flavor = value.flavor;
|
|
1262
|
+
if (typeof value.executable === "string")
|
|
1263
|
+
context.executable = value.executable;
|
|
1264
|
+
if (typeof value.processStartTime === "string")
|
|
1265
|
+
context.processStartTime = value.processStartTime;
|
|
1266
|
+
if (typeof value.moduleBase === "string")
|
|
1267
|
+
context.moduleBase = value.moduleBase;
|
|
1268
|
+
if (typeof value.moduleIdentity === "string")
|
|
1269
|
+
context.moduleIdentity = value.moduleIdentity;
|
|
1270
|
+
if (context.buildKey)
|
|
1271
|
+
context.adapter = buildAdapterRegistry.lookup(context.buildKey);
|
|
1272
|
+
return context;
|
|
1273
|
+
}
|
|
1274
|
+
function toJson(value) {
|
|
1275
|
+
return JSON.parse(JSON.stringify(value, (_key, child) => typeof child === "bigint" ? child.toString() : child === undefined ? null : child));
|
|
1276
|
+
}
|
|
1277
|
+
function errorText(error) { return error instanceof Error ? error.message : String(error); }
|
|
1278
|
+
function assertFocusEnvelope(request, input) {
|
|
1279
|
+
if (input.pid !== undefined && input.pid !== request.pid)
|
|
1280
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", "focus payload pid differs from Broker envelope");
|
|
1281
|
+
if (input.buildKey !== undefined && input.buildKey !== request.buildKey)
|
|
1282
|
+
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", "focus payload buildKey differs from Broker envelope");
|
|
1283
|
+
}
|