wowdump 0.2.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +17 -111
  3. package/dist/adapters/reader.js +33 -0
  4. package/dist/analysis/disassemble.js +77 -0
  5. package/dist/{frida-runtime.js → analysis/frida-runtime.js} +3 -25
  6. package/dist/analysis/runtime-script.js +36 -0
  7. package/dist/cli.js +563 -0
  8. package/dist/core/profile-engine.js +238 -0
  9. package/dist/frida-worker.js +99 -0
  10. package/dist/reader/broker.js +475 -0
  11. package/dist/reader/client.js +1 -0
  12. package/dist/reader/launcher.js +219 -0
  13. package/dist/reader/main.js +100 -0
  14. package/dist/reader/protocol.js +1 -0
  15. package/dist/reader/windows.js +242 -0
  16. package/dist/reader-main.js +2 -0
  17. package/dist/toolchain.js +123 -0
  18. package/package.json +19 -37
  19. package/skills/wowdump/SKILL.md +22 -0
  20. package/skills/wowdump/references/commands.md +63 -0
  21. package/skills/wowdump/references/disassemble.md +18 -0
  22. package/skills/wowdump/references/dynamic.md +54 -0
  23. package/skills/wowdump/references/evidence-workflow.md +41 -0
  24. package/skills/wowdump/references/profiles.md +34 -0
  25. package/skills/wowdump/references/request-schema.md +28 -0
  26. package/skills/wowdump/references/workflow.md +44 -0
  27. package/skills/wowdump/scripts/dynamic-session.js +133 -0
  28. package/dist/agent.js +0 -1335
  29. package/dist/analysis-path.js +0 -38
  30. package/dist/analysis-process-log.js +0 -146
  31. package/dist/broker-client.js +0 -411
  32. package/dist/broker-codec.js +0 -148
  33. package/dist/broker-core.js +0 -1045
  34. package/dist/broker-gateway.js +0 -447
  35. package/dist/broker-ledger.js +0 -196
  36. package/dist/broker-main.js +0 -291
  37. package/dist/broker-protocol.js +0 -119
  38. package/dist/broker-runtime.js +0 -1283
  39. package/dist/broker-server.js +0 -466
  40. package/dist/build-bundle-loader.js +0 -183
  41. package/dist/build-bundle.js +0 -11
  42. package/dist/discovery.js +0 -59
  43. package/dist/dry-run.js +0 -38
  44. package/dist/error-log.js +0 -71
  45. package/dist/focus-errors.js +0 -63
  46. package/dist/focus-service.js +0 -1855
  47. package/dist/focused-session.js +0 -1357
  48. package/dist/mcp-main.js +0 -51
  49. package/dist/mcp.js +0 -924
  50. package/dist/observability.js +0 -41
  51. package/dist/process-log-lock.js +0 -195
  52. package/dist/processes.js +0 -47
  53. package/dist/runtime-config.js +0 -399
  54. package/dist/session.js +0 -145
  55. package/dist/storage.js +0 -12
  56. package/dist/wow-analysis.js +0 -1430
  57. package/resources/builds/retail/12.0.7.68974/build-profile.json +0 -290
  58. package/resources/builds/retail/12.0.7.68974/data-sources.json +0 -1633
  59. package/resources/builds/retail/12.0.7.68974/lua-targets.jsonl +0 -5130
  60. package/resources/builds/retail/12.0.7.68974/manifest.json +0 -63
  61. package/resources/builds/retail/12.0.7.68974/signatures.json +0 -260
  62. /package/dist/{adapters.js → core/build-adapters.js} +0 -0
  63. /package/dist/{types.js → core/types.js} +0 -0
@@ -1,1855 +0,0 @@
1
- import { randomUUID, createHash } from "node:crypto";
2
- import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
- import { dirname, join, resolve } from "node:path";
4
- import { FocusedSession, TargetKindSchema, TriggerPlanSchema, validateSelector } from "./focused-session.js";
5
- import { brokerManagedCommand } from "./frida-runtime.js";
6
- import { FocusRequestError, asSelectorRequestError, isAttachmentFatalError } from "./focus-errors.js";
7
- import { BuildBundleStore } from "./build-bundle-loader.js";
8
- const DEFAULT_MODULE = "Wow.exe";
9
- const DEFAULT_IMAGE_BASE = "0x140000000";
10
- const MAX_EVENTS = 100_000;
11
- const MAX_BYTES = 64 * 1024 * 1024;
12
- export class FocusAnalysisService {
13
- sessions = new Map();
14
- executor;
15
- runtimeRoot;
16
- profileRoot;
17
- packagedProfiles;
18
- buildBundles;
19
- moduleName;
20
- imageBase;
21
- selectorResolver;
22
- targetResolver;
23
- hookTargetVerifier;
24
- deferAttachmentDetach;
25
- lifecycle;
26
- lifecycleTimeoutMs;
27
- beforeFreezeArtifacts;
28
- beforeManifestPublish;
29
- beforeArtifactFragmentWrite;
30
- onFatal;
31
- onDurationExpired;
32
- foundationCache = new Map();
33
- constructor(options) {
34
- if (!options?.executor || typeof options.executor.execute !== "function")
35
- throw new TypeError("executor is required");
36
- this.executor = options.executor;
37
- const compatibilityRoot = options.artifactDir;
38
- this.runtimeRoot = resolve(requiredString(options.runtimeRoot ?? compatibilityRoot, "runtimeRoot"));
39
- this.profileRoot = resolve(requiredString(options.profileRoot ?? compatibilityRoot, "profileRoot"));
40
- this.packagedProfiles = options.profileRoot !== undefined;
41
- this.buildBundles = this.packagedProfiles ? new BuildBundleStore(this.profileRoot) : undefined;
42
- this.moduleName = options.moduleName ?? DEFAULT_MODULE;
43
- this.imageBase = options.imageBase ?? DEFAULT_IMAGE_BASE;
44
- this.selectorResolver = options.selectorResolver;
45
- this.targetResolver = options.targetResolver ?? (input => this.resolveVerifiedTargetsFromProfile(input));
46
- this.hookTargetVerifier = options.hookTargetVerifier;
47
- this.deferAttachmentDetach = options.deferAttachmentDetach === true;
48
- this.lifecycle = options.lifecycle;
49
- this.lifecycleTimeoutMs = bounded(options.lifecycleTimeoutMs, 2_000, 0, 60_000);
50
- this.beforeFreezeArtifacts = options.beforeFreezeArtifacts;
51
- this.beforeManifestPublish = options.beforeManifestPublish;
52
- this.beforeArtifactFragmentWrite = options.beforeArtifactFragmentWrite;
53
- this.onFatal = options.onFatal;
54
- this.onDurationExpired = options.onDurationExpired;
55
- }
56
- setFatalHandler(handler) { this.onFatal = handler; }
57
- setDurationExpiredHandler(handler) { this.onDurationExpired = handler; }
58
- async handleFatal(event) {
59
- const record = this.sessions.get(event.sessionId);
60
- if (!record || record.session.status === "stopping" || record.session.status === "stopped")
61
- return;
62
- if (event.failureClass === "capture_fatal") {
63
- await this.cleanupSession(event.sessionId, "capture_fatal").catch(() => undefined);
64
- }
65
- }
66
- statusSnapshot() {
67
- return [...this.sessions.values()].map(record => {
68
- const manifest = record.session.manifest;
69
- const cleanup = manifest.cleanupStatus;
70
- return {
71
- sessionId: record.sessionId,
72
- kind: record.kind,
73
- pid: record.pid,
74
- buildKey: record.buildKey,
75
- selector: manifest.selector,
76
- state: record.session.status,
77
- active: record.session.status === "running" || record.session.status === "paused",
78
- eventCount: manifest.eventCount,
79
- eventBytes: manifest.eventBytes,
80
- droppedEventCount: manifest.droppedEventCount,
81
- droppedByteCount: manifest.droppedByteCount,
82
- droppedByReason: manifest.droppedByReason,
83
- hookIds: manifest.hookIds,
84
- scriptIds: record.scriptId ? [record.scriptId] : [],
85
- interceptorIds: manifest.hookIds,
86
- cleanupState: record.cleanupAttempted ? cleanup : { hooks: manifest.hookIds.length, scripts: record.scriptId ? 1 : 0, interceptors: manifest.hookIds.length },
87
- };
88
- });
89
- }
90
- async invoke(operation, input = {}) {
91
- switch (operation) {
92
- case "wow_focus_start": return this.start("lua_focus", input);
93
- case "wow_watch_start": return this.start("watch", input);
94
- case "wow_focus_read": return this.read(input);
95
- case "wow_watch_read": return this.read(input);
96
- case "wow_focus_status": return this.status(input);
97
- case "wow_watch_status": return this.status(input);
98
- case "wow_focus_pause": return this.pause(input);
99
- case "wow_focus_resume": return this.resume(input);
100
- case "wow_focus_stop": return this.stop(input);
101
- case "wow_watch_stop": return this.stop(input);
102
- case "wow_session_checkpoint": return this.checkpoint(input);
103
- default: throw new Error(`unsupported focus operation ${operation}`);
104
- }
105
- }
106
- async close() {
107
- for (const record of [...this.sessions.values()])
108
- await this.cleanupSession(record.sessionId, "service_close").catch(() => undefined);
109
- }
110
- async cleanupSession(sessionId, reason, cleanupDeadline) {
111
- const record = this.sessions.get(requiredSessionId(sessionId));
112
- if (!record)
113
- return { sessionId, stopped: true, alreadyReleased: true };
114
- if (record.durationTimer)
115
- clearTimeout(record.durationTimer);
116
- record.durationTimer = undefined;
117
- let retryReceipt;
118
- if (record.artifactPublicationRetry && reason !== "service_close") {
119
- retryReceipt = await record.artifactPublicationRetry?.();
120
- }
121
- else {
122
- record.cleanupAttempted = true;
123
- if (reason === "target_exit" || reason === "attachment_fatal")
124
- await record.session.targetExited(cleanupDeadline);
125
- else
126
- await record.session.stop(cleanupDeadline);
127
- }
128
- const result = { ...record.session.manifest, ...(retryReceipt ? { terminalStatus: retryReceipt.terminalStatus, frozen: retryReceipt.terminalStatus === "frozen", state: "stopped" } : {}), terminal: true, sessionId: record.sessionId, stopped: true, artifactDirectory: join(this.runtimeRoot, "focused-sessions", record.sessionId), lifecycleFailures: record.lifecycleFailures };
129
- this.dispatchLifecycle({ action: "focus_session_cleanup", operation: record.kind === "watch" ? "wow_watch_stop" : "wow_focus_stop", sessionId: record.sessionId, pid: record.pid, buildKey: record.buildKey, status: record.session.status === "failed" ? "partial" : "passed", reason, result: { cleanupStatus: record.session.manifest.cleanupStatus } }, record);
130
- return result;
131
- }
132
- async start(kind, input) {
133
- const pid = positiveInt(input.pid, "pid");
134
- const buildKey = requiredString(input.buildKey, "buildKey");
135
- const sessionId = requiredSessionId(input.sessionId ?? randomUUID());
136
- if (this.sessions.has(sessionId))
137
- throw new Error(`session ${sessionId} already exists`);
138
- const targetKind = TargetKindSchema.parse(input.targetKind ?? (kind === "watch" ? "data_source" : "lua_wrapper"));
139
- if (kind === "watch" && !["data_source", "object", "address_range"].includes(targetKind))
140
- throw new FocusRequestError("SELECTOR_INVALID", "watch targetKind is incompatible");
141
- if (!this.selectorResolver && !isRecord(input.selector))
142
- throw new FocusRequestError("SELECTOR_EMPTY", "selector is required");
143
- const { selector, selectorVerbatim } = await this.resolveSelector(input, targetKind);
144
- const effectiveMaxBytes = bounded(input.maxBytes, 8 * 1024 * 1024, 1, MAX_BYTES);
145
- const verifiedResolution = targetKind === "data_source" || targetKind === "object"
146
- ? validateVerifiedResolution(await this.targetResolver({
147
- buildKey,
148
- targetKind,
149
- ids: targetKind === "data_source" ? selector.dataSourceIds ?? [] : selector.objectIds ?? [],
150
- maxBytes: effectiveMaxBytes
151
- }), effectiveMaxBytes)
152
- : undefined;
153
- if (verifiedResolution && (verifiedResolution.targets.length === 0 || verifiedResolution.ranges.length === 0))
154
- throw new Error("VERIFIED_RANGE_REQUIRED: target has no machine-validated bounded range");
155
- const verifiedBytes = verifiedResolution?.ranges.reduce((sum, range) => sum + range.size, 0) ?? 0;
156
- if (verifiedBytes > effectiveMaxBytes)
157
- throw new Error(`RANGE_LIMIT_EXCEEDED: ${verifiedBytes} bytes exceed maxBytes=${effectiveMaxBytes}`);
158
- if (targetKind === "address_range") {
159
- const totalRangeBytes = (selector.ranges ?? []).reduce((sum, range) => sum + range.size, 0);
160
- if (totalRangeBytes > effectiveMaxBytes)
161
- throw new Error(`RANGE_LIMIT_EXCEEDED: ${totalRangeBytes} bytes exceed maxBytes=${effectiveMaxBytes}`);
162
- }
163
- const environment = requiredRecord(input.environmentDescriptor ?? input.environment, "environmentDescriptor");
164
- if (typeof environment.descriptorVersion !== "string" || typeof environment.capturedAt !== "string")
165
- throw new Error("ENVIRONMENT_INVALID: descriptorVersion and capturedAt are required");
166
- const rawTriggerPlan = requiredRecord(input.triggerPlan, "triggerPlan");
167
- const triggerPlan = { ...rawTriggerPlan };
168
- if (typeof triggerPlan.planVersion !== "string")
169
- triggerPlan.planVersion = "trigger-plan.v1";
170
- if (typeof triggerPlan.workloadId !== "string" || (!Array.isArray(triggerPlan.phases) && !Array.isArray(triggerPlan.before) && !Array.isArray(triggerPlan.during) && !Array.isArray(triggerPlan.after)))
171
- throw new Error("TRIGGER_PLAN_INVALID: workloadId and ordered phases/actions are required");
172
- const normalizedTriggerPlan = TriggerPlanSchema.parse(triggerPlan);
173
- validateTriggerPlanOrder(normalizedTriggerPlan);
174
- const limits = {
175
- snapshotIntervalMs: bounded(input.snapshotIntervalMs, 1000, 0, 60_000),
176
- maxEvents: bounded(input.maxEvents, 10_000, 1, MAX_EVENTS),
177
- maxBytes: bounded(input.maxBytes, 8 * 1024 * 1024, 1, MAX_BYTES),
178
- durationMs: bounded(input.durationMs, 0, 0, 86_400_000),
179
- sampling: boundedNumber(input.sampling, 1, Number.EPSILON, 1)
180
- };
181
- const directory = join(this.runtimeRoot, "focused-sessions", sessionId);
182
- if (resolve(directory) !== directory || !directory.startsWith(join(this.runtimeRoot, "focused-sessions") + "\\"))
183
- throw new Error("ARTIFACT_PATH_INVALID");
184
- const identity = await this.discoverIdentity(pid, buildKey);
185
- let verifiedHookTargets;
186
- try {
187
- verifiedHookTargets = targetKind === "lua_wrapper" || targetKind === "cpp_function"
188
- ? await this.verifyHookTargets({
189
- buildKey,
190
- targetKind,
191
- rvas: selector.rvas ?? [],
192
- moduleBase: identity.moduleBase,
193
- moduleSize: identity.moduleSize,
194
- fridaSessionId: identity.fridaSessionId,
195
- pid,
196
- })
197
- : undefined;
198
- }
199
- catch (error) {
200
- await this.bestEffortDiscoveryDetach(undefined, identity.fridaSessionId, pid, buildKey, error);
201
- throw error;
202
- }
203
- await mkdir(directory, { recursive: true });
204
- const streamFiles = {
205
- lua: join(directory, `lua-focus-${sessionId}.jsonl`),
206
- cpp: join(directory, `cpp-focus-${sessionId}.jsonl`),
207
- data: join(directory, `data-watch-${sessionId}.jsonl`),
208
- manifest: join(directory, `session-manifest-${sessionId}.json`)
209
- };
210
- await Promise.all([streamFiles.lua, streamFiles.cpp, streamFiles.data].map(file => writeFile(file, "", { flag: "wx" })));
211
- const events = [];
212
- const context = { pid, buildKey };
213
- let scriptId;
214
- let fridaSessionId = identity.fridaSessionId;
215
- const transport = {
216
- revalidateIdentity: async (expected) => this.revalidate(expected, fridaSessionId),
217
- installHooks: async (target, capture) => {
218
- const scriptTarget = { ...target };
219
- delete scriptTarget.rva;
220
- stored.fridaSessionId = fridaSessionId;
221
- const loaded = await this.executor.execute(brokerManagedCommand({ operation: "script_load", sessionId: fridaSessionId, source: FOCUS_SCRIPT }, "focus.script_load"), context);
222
- scriptId = requiredString(loaded.scriptId, "script_load.scriptId");
223
- stored.scriptId = scriptId;
224
- const started = await this.executor.execute(brokerManagedCommand({ operation: "script_call", sessionId: fridaSessionId, scriptId, exportName: "focusStart", args: [{ ...scriptTarget, ...capture, ...(selector.rvas?.length ? { rvas: selector.rvas } : {}), ...(verifiedResolution ? { ranges: verifiedResolution.ranges, verifiedTargets: verifiedResolution.targets } : {}), ...(verifiedHookTargets ? { verifiedHookTargets } : {}), moduleBase: identity.moduleBase }] }, "focus.start"), context);
225
- const hooks = arrayOfStrings(started.value && started.value.hookIds);
226
- return hooks.length ? hooks : ["focus-script:" + scriptId];
227
- },
228
- snapshot: async (phase) => {
229
- if (!scriptId)
230
- return undefined;
231
- try {
232
- const result = await this.executor.execute(brokerManagedCommand({ operation: "script_call", sessionId: fridaSessionId, scriptId, exportName: "focusSnapshot", args: [phase] }, `focus.snapshot.${phase}`), context);
233
- return result.value;
234
- }
235
- catch (error) {
236
- if (!isAttachmentFatalError(error))
237
- throw error;
238
- return { unavailable: true, reason: "attachment_fatal", phase, error: errorText(error) };
239
- }
240
- },
241
- pauseProducer: async () => {
242
- if (!scriptId)
243
- return;
244
- await this.executor.execute(brokerManagedCommand({ operation: "script_call", sessionId: fridaSessionId, scriptId, exportName: "focusPause", args: [] }, "focus.pause"), context);
245
- await this.ingestScriptEvents(stored);
246
- },
247
- resumeProducer: async () => {
248
- if (!scriptId)
249
- return;
250
- await this.executor.execute(brokerManagedCommand({ operation: "script_call", sessionId: fridaSessionId, scriptId, exportName: "focusResume", args: [] }, "focus.resume"), context);
251
- },
252
- cleanup: async (hooks) => {
253
- const steps = [];
254
- const residualErrors = [];
255
- let targetUnavailable = false;
256
- const recordAttachmentLoss = (step, error) => {
257
- if (!isAttachmentFatalError(error))
258
- return false;
259
- targetUnavailable = true;
260
- steps.push({ step, ok: true, targetUnavailable: true, disposition: "released_with_attachment" });
261
- return true;
262
- };
263
- if (scriptId) {
264
- try {
265
- const stopped = await this.executor.execute(brokerManagedCommand({ operation: "script_call", sessionId: fridaSessionId, scriptId, exportName: "focusStop", args: ["session_stop"] }, "focus.stop"), context);
266
- const stats = isRecord(stopped?.value) ? stopped.value : undefined;
267
- if (stats)
268
- await stored.session.mergeExternalDrops(stats, "frida-script");
269
- steps.push({ step: "stop", ok: true });
270
- }
271
- catch (error) {
272
- if (!recordAttachmentLoss("stop", error)) {
273
- residualErrors.push({ step: "stop", error: errorText(error) });
274
- steps.push({ step: "stop", ok: false });
275
- }
276
- }
277
- if (!targetUnavailable) {
278
- try {
279
- await this.ingestScriptEvents(stored);
280
- steps.push({ step: "drain", ok: true, scriptCursor: stored.scriptCursor });
281
- }
282
- catch (error) {
283
- if (!recordAttachmentLoss("drain", error)) {
284
- residualErrors.push({ step: "drain", error: errorText(error) });
285
- steps.push({ step: "drain", ok: false });
286
- }
287
- }
288
- }
289
- if (!targetUnavailable) {
290
- try {
291
- await this.executor.execute(brokerManagedCommand({ operation: "script_unload", sessionId: fridaSessionId, scriptId }, "focus.script_unload"), context);
292
- steps.push({ step: "script_unload", ok: true });
293
- }
294
- catch (error) {
295
- if (!recordAttachmentLoss("script_unload", error)) {
296
- residualErrors.push({ step: "script_unload", error: errorText(error) });
297
- steps.push({ step: "script_unload", ok: false });
298
- }
299
- }
300
- }
301
- if (targetUnavailable || residualErrors.length === 0) {
302
- scriptId = undefined;
303
- stored.scriptId = undefined;
304
- }
305
- }
306
- const clean = residualErrors.length === 0;
307
- return { hooks: clean ? 0 : hooks.length, scripts: scriptId ? 1 : 0, interceptors: clean ? 0 : hooks.length, targetUnavailable, remainingResourceRefs: clean ? 0 : undefined, residualResourceIds: clean ? [] : hooks, steps, residualErrors };
308
- },
309
- detach: async () => {
310
- if (this.deferAttachmentDetach)
311
- return;
312
- if (fridaSessionId)
313
- await this.executor.execute({ operation: "detach", sessionId: fridaSessionId, pid, buildKey }, context);
314
- fridaSessionId = undefined;
315
- stored.fridaSessionId = undefined;
316
- }
317
- };
318
- const stored = {
319
- session: undefined,
320
- kind,
321
- pid,
322
- buildKey,
323
- moduleBase: identity.moduleBase,
324
- sessionId,
325
- events,
326
- streamFiles,
327
- scriptCursor: 0,
328
- checkpointCursor: 0,
329
- lifecycleFailures: [],
330
- cleanupAttempted: false
331
- };
332
- const session = new FocusedSession({
333
- sessionId,
334
- pid,
335
- buildKey,
336
- moduleBase: identity.moduleBase,
337
- imageBase: this.imageBase,
338
- processStartTime: identity.processStartTime,
339
- moduleIdentity: identity.moduleIdentity,
340
- target: selector,
341
- selectorVerbatim,
342
- targetKind,
343
- environment,
344
- triggerPlan: normalizedTriggerPlan,
345
- captureArgs: bool(input.captureArgs),
346
- captureReturns: bool(input.captureReturns),
347
- captureCallStack: bool(input.captureCallStack),
348
- captureMemoryWrites: bool(input.captureMemoryWrites),
349
- captureObjectDiff: bool(input.captureObjectDiff),
350
- detachOnCleanup: !this.deferAttachmentDetach,
351
- ...limits,
352
- transport,
353
- onFatal: event => this.onFatal?.(event),
354
- eventSink: {
355
- append: async (event, signal) => {
356
- await appendJsonlDurable(streamForEvent(event, streamFiles), JSON.stringify(event), signal);
357
- events.push(event);
358
- if (["overflow", "exception", "cleanup"].includes(event.event.kind))
359
- this.dispatchLifecycle({ action: `focus_event_${event.event.kind}`, operation: kind === "watch" ? "wow_watch_internal" : "wow_focus_internal", sessionId, pid, buildKey, status: event.status, result: { seq: event.seq, eventKind: event.event.kind, diff: event.diff ?? null } }, stored);
360
- },
361
- prefixes: async () => Promise.all(["lua", "cpp", "data"].map(async (stream) => {
362
- const content = await readRecoveredFile(streamFiles[stream]);
363
- return { stream, size: content.byteLength, sha256: createHash("sha256").update(content).digest("hex") };
364
- }))
365
- },
366
- manifestSink: (() => {
367
- let terminalStatus;
368
- let terminalFragmentFile;
369
- let terminalFailureClaimed = false;
370
- let publicationGeneration = 0;
371
- let failurePublicationAttempt = 0;
372
- let frozenPublicationAttempt = 0;
373
- let publicationFence = new AbortController();
374
- const activePublications = new Set();
375
- const activeWrites = new Set();
376
- let manifestTail = Promise.resolve();
377
- const enqueueManifest = (operation) => {
378
- const result = manifestTail.then(operation);
379
- manifestTail = result.then(() => undefined, () => undefined);
380
- return result;
381
- };
382
- const committedStatus = () => terminalStatus;
383
- const publishGuarded = (value, ownsPublication, beforePublish) => {
384
- const publication = writeJsonAtomicGuarded(streamFiles.manifest, value, ownsPublication, beforePublish, publicationFence.signal);
385
- activePublications.add(publication);
386
- void publication.then(() => activePublications.delete(publication), () => activePublications.delete(publication));
387
- return publication;
388
- };
389
- const publishTracked = (file, value, ownsPublication, beforePublish, signal) => {
390
- const publication = writeJsonAtomicGuarded(file, value, ownsPublication, beforePublish, signal);
391
- activePublications.add(publication);
392
- void publication.then(() => activePublications.delete(publication), () => activePublications.delete(publication));
393
- return publication;
394
- };
395
- const trackWrite = (operation) => {
396
- activeWrites.add(operation);
397
- void operation.then(() => activeWrites.delete(operation), () => activeWrites.delete(operation));
398
- return operation;
399
- };
400
- const quiesce = async () => {
401
- for (;;) {
402
- const pending = [...activePublications, ...activeWrites];
403
- if (pending.length === 0)
404
- return;
405
- await Promise.allSettled(pending);
406
- }
407
- };
408
- const materialize = (fragment, extra) => ({ ...fragment, streamFiles, ...(verifiedResolution ? { verifiedTargetResolution: verifiedResolution } : {}), ...extra });
409
- return {
410
- write: fragment => enqueueManifest(async () => {
411
- if (terminalStatus || terminalFailureClaimed)
412
- return;
413
- const generation = publicationGeneration;
414
- await publishGuarded(materialize(fragment, { frozen: false }), () => generation === publicationGeneration && !terminalStatus && !terminalFailureClaimed, () => this.beforeManifestPublish?.({ kind: "write", file: streamFiles.manifest }));
415
- }),
416
- freeze: (fragment, signal) => enqueueManifest(async () => {
417
- signal?.throwIfAborted();
418
- if (committedStatus() === "frozen")
419
- return { committed: true, terminalStatus: "frozen" };
420
- if (committedStatus() === "failed") {
421
- publicationGeneration += 1;
422
- publicationFence = new AbortController();
423
- terminalFailureClaimed = false;
424
- }
425
- const generation = publicationGeneration;
426
- await this.beforeFreezeArtifacts?.();
427
- signal?.throwIfAborted();
428
- if (committedStatus() === "frozen")
429
- return { committed: true, terminalStatus: "frozen" };
430
- const streamArtifacts = await this.streamArtifacts(streamFiles, { pid, buildKey, sessionId, targetKind, environment, triggerPlan: normalizedTriggerPlan });
431
- signal?.throwIfAborted();
432
- if (committedStatus() === "frozen")
433
- return { committed: true, terminalStatus: "frozen" };
434
- const frozenManifest = materialize(fragment, { streamArtifacts, frozen: true, terminal: true, terminalStatus: "frozen" });
435
- const previousFragmentFile = terminalFragmentFile;
436
- const supersedes = previousFragmentFile
437
- ? await this.fragmentSupersession(previousFragmentFile)
438
- : undefined;
439
- const frozenAttempt = ++frozenPublicationAttempt;
440
- const manifestArtifactPath = join(dirname(streamFiles.manifest), `session-manifest-${sessionId}-frozen-${publicationGeneration}-${frozenAttempt}.json`);
441
- await trackWrite(writeFile(manifestArtifactPath, JSON.stringify(frozenManifest, null, 2) + "\n", { flag: "wx" }));
442
- const fragmentFile = await trackWrite(this.writeArtifactFragment(sessionId, buildKey, [
443
- ...streamArtifacts,
444
- this.manifestArtifactForValue(manifestArtifactPath, frozenManifest, { pid, buildKey, sessionId })
445
- ], previousFragmentFile ? `frozen-retry-${frozenAttempt}` : `frozen-${frozenAttempt}`, supersedes, signal));
446
- try {
447
- const published = await publishGuarded(frozenManifest, () => generation === publicationGeneration && terminalStatus !== "frozen", () => this.beforeManifestPublish?.({ kind: "freeze", file: streamFiles.manifest }));
448
- if (!published)
449
- throw new Error("TERMINAL_FREEZE_PUBLICATION_ABORTED");
450
- terminalStatus = "frozen";
451
- terminalFailureClaimed = false;
452
- terminalFragmentFile = fragmentFile;
453
- stored.artifactPublicationRetry = undefined;
454
- return { committed: true, terminalStatus };
455
- }
456
- catch (error) {
457
- await unlink(fragmentFile).catch(() => undefined);
458
- throw error;
459
- }
460
- }),
461
- terminalFailure: async (fragment, signal) => {
462
- if (terminalStatus === "frozen")
463
- return { committed: true, terminalStatus };
464
- if (stored.artifactPublicationRetry)
465
- return stored.artifactPublicationRetry();
466
- // This fence is intentionally independent of manifestTail. A timed-out
467
- // freeze may never settle, but it must immediately lose publication
468
- // rights so the bounded terminal failure can become canonical.
469
- terminalFailureClaimed = true;
470
- publicationGeneration += 1;
471
- publicationFence.abort();
472
- // Guarded publications race their hook against publicationFence, so
473
- // abort makes this drain independent of a non-cooperative test hook.
474
- // Wait for their finally blocks to remove durable temp files before
475
- // publishing or reporting the terminal failure.
476
- const drained = await drainAbortedPublications(activePublications, signal);
477
- if (!drained)
478
- await drainAbortedPublications(activePublications);
479
- const failedManifest = materialize(fragment, { frozen: false, terminal: true, terminalStatus: "failed" });
480
- const publishFailure = async (publicationSignal) => {
481
- if (terminalStatus === "frozen") {
482
- stored.artifactPublicationRetry = undefined;
483
- return { committed: true, terminalStatus };
484
- }
485
- const streamArtifacts = await this.streamArtifacts(streamFiles, { pid, buildKey, sessionId, targetKind, environment, triggerPlan: normalizedTriggerPlan });
486
- const previousFragmentFile = terminalFragmentFile;
487
- const supersedes = previousFragmentFile ? await this.fragmentSupersession(previousFragmentFile) : undefined;
488
- const failureAttempt = ++failurePublicationAttempt;
489
- const manifestArtifactPath = join(dirname(streamFiles.manifest), `session-manifest-${sessionId}-failed-${publicationGeneration}-${failureAttempt}.json`);
490
- await trackWrite(writeFile(manifestArtifactPath, JSON.stringify(failedManifest, null, 2) + "\n", { flag: "wx" }));
491
- const fragmentFile = await trackWrite(this.writeArtifactFragment(sessionId, buildKey, [
492
- ...streamArtifacts,
493
- this.manifestArtifactForValue(manifestArtifactPath, failedManifest, { pid, buildKey, sessionId })
494
- ], failureAttempt === 1 ? `failed-${failureAttempt}` : `failure-retry-${failureAttempt}`, supersedes, publicationSignal));
495
- terminalFragmentFile = fragmentFile;
496
- try {
497
- const published = await publishTracked(streamFiles.manifest, failedManifest, () => !publicationSignal?.aborted, () => this.beforeManifestPublish?.({ kind: "failure", file: streamFiles.manifest }), publicationSignal);
498
- if (!published)
499
- throw new Error("TERMINAL_FAILURE_PUBLICATION_ABORTED");
500
- terminalStatus = "failed";
501
- terminalFragmentFile = fragmentFile;
502
- stored.artifactPublicationRetry = undefined;
503
- return { committed: true, terminalStatus };
504
- }
505
- catch (error) {
506
- // Keep failed fragments append-only for coordinator audit and retry lineage.
507
- throw error;
508
- }
509
- };
510
- stored.artifactPublicationRetry = () => trackWrite(publishFailure());
511
- return trackWrite(publishFailure(signal));
512
- },
513
- quiesce
514
- };
515
- })()
516
- });
517
- stored.session = session;
518
- this.sessions.set(sessionId, stored);
519
- try {
520
- await session.start();
521
- const durationMs = bounded(input.durationMs, 0, 0, 86_400_000);
522
- if (limits.durationMs > 0) {
523
- stored.durationTimer = setTimeout(() => {
524
- const expiration = { sessionId, pid, buildKey };
525
- const operation = this.onDurationExpired
526
- ? Promise.resolve(this.onDurationExpired(expiration))
527
- : this.cleanupSession(sessionId, "duration_expired").then(() => undefined);
528
- void operation.catch(() => undefined);
529
- }, limits.durationMs);
530
- stored.durationTimer.unref?.();
531
- }
532
- return { ...session.manifest, ...(verifiedResolution ? { verifiedTargetResolution: verifiedResolution } : {}), ready: true, sessionId, targetKind, fridaSessionId, scriptId, artifactDirectory: directory };
533
- }
534
- catch (error) {
535
- stored.cleanupAttempted = true;
536
- await session.stop().catch(() => undefined);
537
- this.dispatchLifecycle({
538
- action: "focus_start_failed",
539
- operation: kind === "watch" ? "wow_watch_start" : "wow_focus_start",
540
- sessionId,
541
- pid,
542
- buildKey,
543
- status: "partial",
544
- error: { message: errorText(error) },
545
- result: {
546
- terminalStatus: session.manifest.terminalStatus ?? null,
547
- frozen: session.manifest.frozen,
548
- cleanupStatus: session.manifest.cleanupStatus,
549
- remainingResourceRefs: 0,
550
- residualResourceIds: [],
551
- },
552
- nextAction: "Start a new session only for a current explicit PID after the target is available."
553
- }, stored);
554
- throw error;
555
- }
556
- }
557
- async read(input) {
558
- const record = this.requireSession(input);
559
- await this.routed(record, () => this.assertLive(record), "attachment_fatal");
560
- const afterSeq = bounded(input.afterSeq ?? input.cursor, 0, 0, Number.MAX_SAFE_INTEGER);
561
- const limit = bounded(input.limit, 1000, 1, 10_000);
562
- if (record.scriptId) {
563
- await this.routed(record, () => this.ingestScriptEvents(record), "capture_fatal");
564
- }
565
- const events = record.events.filter(event => event.seq > afterSeq).slice(0, limit);
566
- return { active: record.session.status === "running" || record.session.status === "paused", sessionId: record.sessionId, events, oldestSeq: record.events[0]?.seq ?? null, newestSeq: record.events.at(-1)?.seq ?? null, nextAfterSeq: events.at(-1)?.seq ?? afterSeq, droppedEvents: record.session.manifest.droppedEventCount, eof: record.session.status === "stopped" };
567
- }
568
- async status(input) {
569
- const record = this.requireSession(input);
570
- await this.routed(record, () => this.assertLive(record), "attachment_fatal");
571
- if (record.scriptId) {
572
- const result = await this.routed(record, () => this.executor.execute(brokerManagedCommand({ operation: "script_call", sessionId: record.fridaSessionId, scriptId: record.scriptId, exportName: "focusStatus", args: [] }, "focus.status"), { pid: record.pid, buildKey: record.buildKey }), "capture_fatal").catch(() => undefined);
573
- if (isRecord(result?.value))
574
- await record.session.mergeExternalDrops(result.value, "frida-script");
575
- }
576
- return { ...record.session.manifest, active: record.session.status === "running" || record.session.status === "paused", eventCount: record.events.length, nextAfterSeq: record.events.at(-1)?.seq ?? 0, scriptId: record.scriptId ?? null, fridaSessionId: record.fridaSessionId ?? null, lifecycleFailures: record.lifecycleFailures };
577
- }
578
- async pause(input) {
579
- const record = this.requireSession(input);
580
- await this.routed(record, () => record.session.pause(), "capture_fatal");
581
- return this.status(input);
582
- }
583
- async resume(input) {
584
- const record = this.requireSession(input);
585
- await this.routed(record, () => record.session.resume(), "capture_fatal");
586
- return this.status(input);
587
- }
588
- async checkpoint(input) {
589
- const record = this.requireSession(input);
590
- await this.routed(record, () => this.assertLive(record), "attachment_fatal");
591
- if (record.session.status !== "running")
592
- throw new Error("CHECKPOINT_SESSION_NOT_RUNNING");
593
- const phases = TriggerPlanSchema.parse(record.session.options.triggerPlan).phases ?? [];
594
- const expected = phases[record.checkpointCursor];
595
- if (!expected)
596
- throw new Error("CHECKPOINT_PLAN_COMPLETE");
597
- const phase = String(input.triggerPhase ?? "");
598
- const phaseIndex = bounded(input.phaseIndex, -1, 0, Number.MAX_SAFE_INTEGER);
599
- const userAction = requiredString(input.userAction, "userAction");
600
- const expectedActions = [...expected.actions.map(action => typeof action === "string" ? action : action.userAction ?? action.description), "userAction" in expected ? expected.userAction : undefined].filter(Boolean);
601
- if (phase !== expected.phase || phaseIndex !== expected.phaseIndex || !expectedActions.includes(userAction))
602
- throw new Error("CHECKPOINT_PHASE_INVALID");
603
- const checkpoint = await record.session.checkpoint(userAction, { phase, phaseIndex, userAction }, input.snapshotNow === true);
604
- record.checkpointCursor += 1;
605
- return { ...await this.status(input), triggerPhase: phase, phaseIndex, userAction, streamPrefixes: checkpoint.streamPrefixes };
606
- }
607
- async routed(record, operation, fallback) {
608
- try {
609
- return await operation();
610
- }
611
- catch (error) {
612
- const text = errorText(error);
613
- const failureClass = text.includes("BROKER_FATAL") ? "broker_fatal"
614
- : text.includes("ATTACHMENT_FATAL") || text.includes("TARGET_") || text.includes("SESSION_NOT_LIVE") ? "attachment_fatal"
615
- : fallback;
616
- const errorCode = failureClass === "broker_fatal" ? "BROKER_FATAL" : failureClass === "attachment_fatal" ? "ATTACHMENT_FATAL" : "CAPTURE_FATAL";
617
- void Promise.resolve(this.onFatal?.({ failureClass, errorCode, sessionId: record.sessionId, pid: record.pid, buildKey: record.buildKey, error })).catch(() => undefined);
618
- throw error;
619
- }
620
- }
621
- async stop(input) {
622
- const record = this.requireSession(input);
623
- if (record.session.status !== "stopped")
624
- await this.assertLive(record).catch(async (error) => { await record.session.targetExited().catch(() => undefined); throw error; });
625
- return this.cleanupSession(record.sessionId, String(input.reason ?? "requested"));
626
- }
627
- requireSession(input) {
628
- const sessionId = requiredString(input.sessionId, "sessionId");
629
- const record = this.sessions.get(sessionId);
630
- if (!record)
631
- throw new Error("SESSION_NOT_FOUND");
632
- const pid = positiveInt(input.pid, "pid");
633
- const buildKey = requiredString(input.buildKey, "buildKey");
634
- if (pid !== record.pid || buildKey !== record.buildKey)
635
- throw new Error("SESSION_TARGET_MISMATCH");
636
- return record;
637
- }
638
- async assertLive(record) {
639
- try {
640
- await record.session.validateIdentity();
641
- }
642
- catch (error) {
643
- await record.session.targetExited().catch(() => undefined);
644
- throw error;
645
- }
646
- }
647
- async ingestScriptEvents(record) {
648
- if (!record.scriptId)
649
- return;
650
- for (let page = 0; page < 16; page += 1) {
651
- const result = await this.executor.execute(brokerManagedCommand({ operation: "script_call", sessionId: record.fridaSessionId, scriptId: record.scriptId, exportName: "focusRead", args: [record.scriptCursor, 10_000] }, "focus.read"), { pid: record.pid, buildKey: record.buildKey });
652
- const scriptResult = isRecord(result.value) ? result.value : {};
653
- await record.session.mergeExternalDrops(scriptResult, "frida-script");
654
- const scriptEvents = arrayOfRecords(scriptResult.events);
655
- for (const event of scriptEvents) {
656
- const scriptSeq = numberValue(event.seq);
657
- if (scriptSeq !== undefined)
658
- record.scriptCursor = Math.max(record.scriptCursor, scriptSeq);
659
- const fallbackTarget = selectorTarget(record.session.resolvedSelector, record.moduleBase);
660
- const rawTarget = isRecord(event.target) ? event.target : fallbackTarget;
661
- const eventTarget = { ...rawTarget, kind: isTargetKind(rawTarget.kind) ? rawTarget.kind : fallbackTarget.kind };
662
- const eventMeta = isRecord(event.event) ? event.event : {};
663
- const threadId = numberValue(eventMeta.threadId) ?? 0;
664
- const kind = typeof eventMeta.kind === "string" ? eventMeta.kind : "enter";
665
- const { seq: _scriptSeq, ingressOrdinal: _scriptIngress, event: _event, ...rest } = event;
666
- const invocationId = stringValue(event.invocationId) ?? stringValue(eventMeta.invocationId) ?? null;
667
- const parentInvocationId = stringValue(event.parentInvocationId) ?? stringValue(eventMeta.parentInvocationId) ?? null;
668
- const recursive = event.recursive === true || eventMeta.recursive === true;
669
- const capturedTimestamp = typeof eventMeta.timestamp === "string" ? eventMeta.timestamp : undefined;
670
- const capturedMonotonicTimestamp = typeof eventMeta.monotonicTimestamp === "number" && Number.isFinite(eventMeta.monotonicTimestamp)
671
- ? eventMeta.monotonicTimestamp
672
- : undefined;
673
- const evidence = Array.isArray(event.evidence) ? [...event.evidence] : [];
674
- if (capturedTimestamp !== undefined || capturedMonotonicTimestamp !== undefined)
675
- evidence.push({ source: "frida-gumjs", capturedTimestamp: capturedTimestamp ?? null, capturedMonotonicTimestamp: capturedMonotonicTimestamp ?? null });
676
- const payload = {
677
- ...rest,
678
- arguments: event.arguments ?? rest.arguments ?? null,
679
- returnValue: event.returnValue ?? rest.returnValue ?? null,
680
- callStack: Array.isArray(event.callStack) ? event.callStack : (Array.isArray(rest.callStack) ? rest.callStack : []),
681
- evidence,
682
- target: eventTarget,
683
- trigger: event.trigger ?? { phase: "during" },
684
- status: event.status ?? "dynamic"
685
- };
686
- await record.session.emitTransport({
687
- ...payload,
688
- event: {
689
- kind: normalizeTimelineKind(kind),
690
- threadId,
691
- depth: numberValue(eventMeta.depth) ?? 0,
692
- invocationId,
693
- parentInvocationId,
694
- recursive,
695
- monotonicTimestamp: capturedMonotonicTimestamp
696
- }
697
- });
698
- }
699
- const newestSeq = numberValue(scriptResult.newestSeq) ?? record.scriptCursor;
700
- if (scriptEvents.length === 0 || record.scriptCursor >= newestSeq)
701
- return;
702
- }
703
- throw new Error("SCRIPT_DRAIN_LIMIT_EXCEEDED");
704
- }
705
- async streamArtifacts(files, inputs) {
706
- return Promise.all(["lua", "cpp", "data"].map(async (stream) => {
707
- const file = resolve(files[stream]);
708
- const bytes = await readRecoveredFile(file);
709
- const text = bytes.toString("utf8");
710
- const lineCount = text.length ? text.split("\n").filter(Boolean).length : 0;
711
- const verificationCode = "const fs=require('fs'),c=require('crypto'),p=process.argv[1],b=fs.readFileSync(p),n=b.length?b.toString('utf8').split('\\n').filter(Boolean).length:0;console.log(JSON.stringify({size:b.length,lineCount:n,sha256:c.createHash('sha256').update(b).digest('hex')}))";
712
- return { path: file, role: stream === "lua" ? "lua-focus" : stream === "cpp" ? "cpp-focus" : "data-watch", applicability: lineCount === 0 ? "not_applicable" : "applicable", lineCount, eventCount: lineCount, size: bytes.byteLength, sha256: createHash("sha256").update(bytes).digest("hex"), generationInputs: inputs, verificationCommand: `node -e ${JSON.stringify(verificationCode)} ${JSON.stringify(file)}` };
713
- }));
714
- }
715
- async discoverIdentity(pid, buildKey, existingSessionId) {
716
- const processes = await this.executor.execute({ operation: "processes" });
717
- const list = arrayOfRecords(processes.processes ?? processes.value);
718
- const process = list.find(item => numberValue(item.pid) === pid && (stringValue(item.name) ?? "").toLowerCase() === this.moduleName.toLowerCase());
719
- if (!process)
720
- throw new Error("PID_NOT_WOW");
721
- const context = { pid, buildKey };
722
- const attached = existingSessionId ? undefined : await this.executor.execute({ operation: "attach", pid, buildKey }, context);
723
- const sessionId = existingSessionId ?? requiredString(attached?.sessionId, "attach.sessionId");
724
- let modules;
725
- try {
726
- modules = await this.executor.execute({ operation: "modules", sessionId }, context);
727
- }
728
- catch (error) {
729
- await this.bestEffortDiscoveryDetach(existingSessionId, sessionId, pid, buildKey, error);
730
- throw error;
731
- }
732
- const module = arrayOfRecords(modules.modules ?? modules.value).find(item => (stringValue(item.name) ?? "").toLowerCase() === this.moduleName.toLowerCase());
733
- if (!module) {
734
- const error = new Error("MODULE_NOT_FOUND");
735
- await this.bestEffortDiscoveryDetach(existingSessionId, sessionId, pid, buildKey, error);
736
- throw error;
737
- }
738
- let moduleBase;
739
- try {
740
- moduleBase = requiredString(module.base, "module.base");
741
- }
742
- catch (error) {
743
- await this.bestEffortDiscoveryDetach(existingSessionId, sessionId, pid, buildKey, error);
744
- throw error;
745
- }
746
- const parsedStartTime = typeof process.startTime === "string" ? Date.parse(process.startTime) : numberValue(process.startTime) ?? numberValue(process.startTimeMs) ?? numberValue(process.creationTime);
747
- const processStartTime = typeof parsedStartTime === "number" && Number.isFinite(parsedStartTime) ? parsedStartTime : undefined;
748
- const moduleSize = numberValue(module.size);
749
- if (moduleSize === undefined || moduleSize <= 0) {
750
- const error = new Error("MODULE_SIZE_INVALID");
751
- await this.bestEffortDiscoveryDetach(existingSessionId, sessionId, pid, buildKey, error);
752
- throw error;
753
- }
754
- const moduleIdentity = createHash("sha256").update(JSON.stringify({ name: module.name, path: module.path, size: module.size, base: module.base })).digest("hex");
755
- return { pid, buildKey, moduleBase, imageBase: this.imageBase, processStartTime, moduleIdentity, fridaSessionId: sessionId, moduleSize };
756
- }
757
- async bestEffortDiscoveryDetach(existingSessionId, sessionId, pid, buildKey, error) {
758
- if (existingSessionId || this.deferAttachmentDetach)
759
- return;
760
- const audit = { attempted: true, sessionId, ok: false };
761
- try {
762
- await this.executor.execute({ operation: "detach", sessionId, pid, buildKey }, { pid, buildKey });
763
- audit.ok = true;
764
- }
765
- catch (detachError) {
766
- audit.error = errorText(detachError);
767
- }
768
- if (error && typeof error === "object")
769
- error.attachmentCleanup = audit;
770
- }
771
- manifestArtifactForValue(file, value, inputs) {
772
- const bytes = Buffer.from(JSON.stringify(value, null, 2) + "\n", "utf8");
773
- return { path: resolve(file), role: "Focused session manifest", buildKey: inputs.buildKey, inputs: [inputs], outputs: [resolve(file)], size: bytes.byteLength, sha256: createHash("sha256").update(bytes).digest("hex"), generationCommand: "wow_focus_start", verificationCommand: `node -e ${JSON.stringify("JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); console.log('ok')")} ${JSON.stringify(resolve(file))}`, allPassed: true };
774
- }
775
- async fragmentSupersession(file) {
776
- const bytes = await readFile(file);
777
- return { path: resolve(file), sha256: createHash("sha256").update(bytes).digest("hex") };
778
- }
779
- async writeArtifactFragment(sessionId, buildKey, entries, publicationLabel, supersedes, signal) {
780
- const manifestPath = join(this.runtimeRoot, "analysis-artifact-manifest-68974.json");
781
- let baseGeneration = null;
782
- try {
783
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
784
- baseGeneration = Number.isInteger(manifest.generation) ? Number(manifest.generation) : null;
785
- }
786
- catch (error) {
787
- if (error?.code !== "ENOENT")
788
- throw error;
789
- }
790
- const generation = (baseGeneration ?? 0) + 1;
791
- const directory = join(this.runtimeRoot, "manifest-staging", String(generation));
792
- await mkdir(directory, { recursive: true });
793
- const fragment = { schema: "wow.artifact-manifest-fragment.v1", producerId: `focus:${sessionId}`, generation, baseGeneration, ...(supersedes ? { supersedes } : {}), entries };
794
- const file = join(directory, `focus-${sessionId}${publicationLabel ? `-${publicationLabel}` : ""}.json`);
795
- if (!await waitForPublication(() => this.beforeArtifactFragmentWrite?.({ sessionId, file }), signal))
796
- throw new Error("ARTIFACT_FRAGMENT_WRITE_ABORTED");
797
- if (signal?.aborted)
798
- throw new Error("ARTIFACT_FRAGMENT_WRITE_ABORTED");
799
- await writeFile(file, JSON.stringify(fragment, null, 2) + "\n", { flag: "wx" });
800
- return file;
801
- }
802
- async revalidate(expected, sessionId) {
803
- const actual = await this.discoverIdentity(expected.pid, expected.buildKey, sessionId);
804
- if (actual.moduleBase.toLowerCase() !== expected.moduleBase.toLowerCase() || (expected.processStartTime !== undefined && actual.processStartTime !== expected.processStartTime) || (expected.moduleIdentity !== undefined && actual.moduleIdentity !== expected.moduleIdentity))
805
- throw new Error("PID_REUSED");
806
- return actual;
807
- }
808
- async resolveSelector(input, targetKind) {
809
- if (this.selectorResolver) {
810
- const selector = await this.selectorResolver(input);
811
- return { selector, selectorVerbatim: isRecord(input.selector) ? { ...input.selector } : stripSelectorFields(selector) };
812
- }
813
- const selector = isRecord(input.selector) ? { ...input.selector } : {
814
- ...(Array.isArray(input.APIs) ? { apis: input.APIs } : {}),
815
- ...(Array.isArray(input.apis) ? { apis: input.apis } : {}),
816
- ...(typeof input.namespace === "string" ? { namespace: input.namespace } : {}),
817
- ...(typeof input.glob === "string" ? { glob: input.glob } : {}),
818
- ...(input.rva !== undefined ? { rva: Array.isArray(input.rva) ? String(input.rva[0]) : String(input.rva) } : {}),
819
- ...(typeof input.address === "string" ? { address: input.address } : {}),
820
- ...(typeof input.start === "string" ? { start: input.start } : {}),
821
- ...(typeof input.end === "string" ? { end: input.end } : {})
822
- };
823
- const kind = TargetKindSchema.parse(targetKind);
824
- let normalized;
825
- try {
826
- normalized = validateSelector(selector, kind);
827
- }
828
- catch (error) {
829
- throw asSelectorRequestError(error);
830
- }
831
- const selectorVerbatim = stripSelectorFields(normalized);
832
- if (kind !== "lua_wrapper")
833
- return { selector: normalized, selectorVerbatim };
834
- const resolvedRvas = await this.resolveLuaRvas(normalized, requiredString(input.buildKey, "buildKey"));
835
- if (resolvedRvas.length === 0)
836
- throw new FocusRequestError("SELECTOR_EMPTY", "selector did not resolve to a current-build Lua wrapper");
837
- try {
838
- return {
839
- selector: validateSelector({ combine: normalized.combine, rvas: resolvedRvas, maxTargets: normalized.maxTargets }, kind),
840
- selectorVerbatim,
841
- };
842
- }
843
- catch (error) {
844
- throw asSelectorRequestError(error);
845
- }
846
- }
847
- async resolveLuaRvas(selector, buildKey) {
848
- const records = await this.loadFoundation(buildKey);
849
- const fields = [];
850
- if (selector.apis?.length)
851
- fields.push(new Set(records.filter(record => selector.apis.includes(String(record.api ?? record.name ?? ""))).map(record => String(record.wrapperRva))));
852
- if (selector.namespaces?.length)
853
- fields.push(new Set(records.filter(record => selector.namespaces.includes(String(record.namespace ?? ""))).map(record => String(record.wrapperRva))));
854
- if (selector.globs?.length)
855
- fields.push(new Set(records.filter(record => selector.globs.some(glob => globMatch(glob, `${String(record.namespace ?? "")}.${String(record.name ?? "")}`))).map(record => String(record.wrapperRva))));
856
- if (selector.rvas?.length)
857
- fields.push(new Set(selector.rvas));
858
- if (fields.length === 0)
859
- return [];
860
- const result = selector.combine === "all"
861
- ? [...fields[0]].filter(value => fields.every(field => field.has(value)))
862
- : [...new Set(fields.flatMap(field => [...field]))];
863
- result.sort((left, right) => BigInt(left) < BigInt(right) ? -1 : BigInt(left) > BigInt(right) ? 1 : 0);
864
- if (result.length > selector.maxTargets)
865
- throw new FocusRequestError("TARGETS_OVER_LIMIT", `selector resolves to ${result.length} targets, exceeding maxTargets=${selector.maxTargets}`, { resolvedTargets: result.length, maxTargets: selector.maxTargets });
866
- return result;
867
- }
868
- loadFoundation(buildKey) {
869
- const cached = this.foundationCache.get(buildKey);
870
- if (cached)
871
- return cached;
872
- const suffix = buildKey.split(".").at(-1) ?? "";
873
- const promise = this.packagedProfiles
874
- ? this.buildBundles.readJsonl(buildKey, "lua-targets.jsonl")
875
- : readFile(join(this.profileRoot, `lua-foundation-${suffix}.jsonl`), "utf8")
876
- .then(text => text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)).filter(record => record.buildKey === buildKey));
877
- this.foundationCache.set(buildKey, promise);
878
- void promise.catch(() => {
879
- if (this.foundationCache.get(buildKey) === promise)
880
- this.foundationCache.delete(buildKey);
881
- });
882
- return promise;
883
- }
884
- async verifyHookTargets(request) {
885
- if (request.rvas.length === 0)
886
- throw new FocusRequestError("SELECTOR_EMPTY", "hook selector resolved to no RVAs");
887
- if (this.hookTargetVerifier)
888
- return validateVerifiedHookTargets(await this.hookTargetVerifier(request), request);
889
- if (request.targetKind === "cpp_function")
890
- return this.verifyProfileHookTargets(request);
891
- const records = await this.loadFoundation(request.buildKey);
892
- const byRva = new Map();
893
- for (const record of records) {
894
- const rva = canonicalRva(requiredString(record.wrapperRva, "foundation.wrapperRva"));
895
- const values = byRva.get(rva) ?? [];
896
- values.push(record);
897
- byRva.set(rva, values);
898
- }
899
- let registrationRecords = records;
900
- if (!this.packagedProfiles) {
901
- const suffix = request.buildKey.split(".").at(-1) ?? "";
902
- const registration = JSON.parse(await readFile(join(this.profileRoot, `wow-lua-api-all-rva-${suffix}.json`), "utf8"));
903
- registrationRecords = arrayOfRecords(registration.records);
904
- }
905
- const registrationByRva = new Map(registrationRecords.map(record => [canonicalRva(requiredString(record.rva ?? record.wrapperRva, "registration.rva")), record]));
906
- const verified = [];
907
- for (const rawRva of request.rvas) {
908
- const rva = canonicalRva(rawRva);
909
- const candidates = byRva.get(rva) ?? [];
910
- const record = candidates.find(value => isRecord(value.abi) && value.abi.status === "confirmed" && isConfirmedFunctionBoundary(value.functionBoundary) && isSha256(value.entryHash));
911
- if (!record)
912
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: ${rva} lacks confirmed ABI, function boundary, or entry hash`);
913
- const numericRva = BigInt(rva);
914
- const registration = registrationByRva.get(rva);
915
- const expectedHex = registration && typeof registration.entryBytesHex === "string" ? registration.entryBytesHex.toLowerCase() : "";
916
- if (!/^[0-9a-f]{64}$/.test(expectedHex))
917
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: ${rva} lacks a 32-byte build entry baseline`);
918
- const expected = Buffer.from(expectedHex, "hex");
919
- const entryLength = expected.length;
920
- if (numericRva + BigInt(entryLength) > BigInt(request.moduleSize))
921
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: ${rva} is outside the runtime module`);
922
- const expectedHash = createHash("sha256").update(expected).digest("hex");
923
- if (expectedHash !== String(record.entryHash).toLowerCase())
924
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: ${rva} static entry hash mismatch`);
925
- const runtimeAddress = addHex(request.moduleBase, rva);
926
- const read = await this.executor.execute(brokerManagedCommand({ operation: "read_memory", sessionId: request.fridaSessionId, address: runtimeAddress, size: entryLength }, "focus.verify_hook_entry"), { pid: request.pid, buildKey: request.buildKey });
927
- const actualHex = requiredString(read.bytesHex ?? read.hex, "read_memory.bytesHex").toLowerCase();
928
- const actualHash = createHash("sha256").update(Buffer.from(actualHex, "hex")).digest("hex");
929
- if (actualHex !== expected.toString("hex") || actualHash !== expectedHash)
930
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: ${rva} runtime entry bytes do not match build baseline`, { rva, runtimeAddress, expectedHash, actualHash });
931
- verified.push({ rva, runtimeAddress, entryBytesHex: actualHex, entryBytesSha256: actualHash, entryBytesLength: entryLength, functionBoundary: record.functionBoundary, abiStatus: "confirmed" });
932
- }
933
- return validateVerifiedHookTargets(verified, request);
934
- }
935
- async verifyProfileHookTargets(request) {
936
- let signatures;
937
- try {
938
- const suffix = request.buildKey.split(".").at(-1) ?? "";
939
- const payload = this.packagedProfiles
940
- ? await this.buildBundles.readJson(request.buildKey, "signatures.json")
941
- : JSON.parse(await readFile(join(this.profileRoot, `build-profile-${suffix}.json`), "utf8"));
942
- if (payload.buildKey !== request.buildKey)
943
- throw new Error("signatures buildKey mismatch");
944
- signatures = arrayOfRecords(payload.signatures);
945
- }
946
- catch (error) {
947
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: build profile is unavailable: ${errorText(error)}`);
948
- }
949
- const byRva = new Map(signatures.map(signature => [canonicalRva(requiredString(signature.rva, "signature.rva")), signature]));
950
- const verified = [];
951
- for (const rawRva of request.rvas) {
952
- const rva = canonicalRva(rawRva);
953
- const signature = byRva.get(rva);
954
- const expectedHex = signature && typeof signature.entryBytesHex === "string" ? signature.entryBytesHex.toLowerCase() : "";
955
- const expectedHash = signature && typeof signature.entryBytesSha256 === "string" ? signature.entryBytesSha256.toLowerCase() : "";
956
- if (!/^[0-9a-f]{32,128}$/.test(expectedHex) || expectedHex.length % 2 !== 0 || !isSha256(expectedHash))
957
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: ${rva} is not a signed build-profile function`);
958
- const entryBytes = Buffer.from(expectedHex, "hex");
959
- const recomputedHash = createHash("sha256").update(entryBytes).digest("hex");
960
- if (recomputedHash !== expectedHash)
961
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: ${rva} build-profile entry hash mismatch`);
962
- if (BigInt(rva) + BigInt(entryBytes.length) > BigInt(request.moduleSize))
963
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: ${rva} is outside the runtime module`);
964
- const runtimeAddress = addHex(request.moduleBase, rva);
965
- const read = await this.executor.execute(brokerManagedCommand({ operation: "read_memory", sessionId: request.fridaSessionId, address: runtimeAddress, size: entryBytes.length }, "focus.verify_cpp_entry"), { pid: request.pid, buildKey: request.buildKey });
966
- const actualHex = requiredString(read.bytesHex ?? read.hex, "read_memory.bytesHex").toLowerCase();
967
- const actualHash = createHash("sha256").update(Buffer.from(actualHex, "hex")).digest("hex");
968
- if (actualHex !== expectedHex || actualHash !== expectedHash)
969
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: ${rva} runtime entry bytes do not match build profile`, { rva, runtimeAddress, expectedHash, actualHash });
970
- verified.push({ rva, runtimeAddress, entryBytesHex: actualHex, entryBytesSha256: actualHash, entryBytesLength: entryBytes.length, functionBoundary: { status: "confirmed", startVa: addHex(this.imageBase, rva), size: `0x${entryBytes.length.toString(16)}`, evidenceKind: "build_profile_signature" }, abiStatus: "confirmed" });
971
- }
972
- return validateVerifiedHookTargets(verified, request);
973
- }
974
- async resolveVerifiedTargetsFromProfile(request) {
975
- const suffix = request.buildKey.split(".").at(-1) ?? "";
976
- let profile;
977
- try {
978
- profile = this.packagedProfiles
979
- ? await this.buildBundles.readJson(request.buildKey, "build-profile.json")
980
- : JSON.parse(await readFile(join(this.profileRoot, `build-profile-${suffix}.json`), "utf8"));
981
- }
982
- catch (error) {
983
- if (error?.code === "ENOENT")
984
- throw new Error("VERIFIED_RANGE_REQUIRED: build profile is unavailable");
985
- throw error;
986
- }
987
- if (profile.buildKey !== request.buildKey)
988
- throw new Error("VERIFIED_RANGE_REQUIRED: build profile does not match the requested build");
989
- const roots = arrayOfRecords(profile.dataRoots);
990
- const readers = arrayOfRecords(profile.readers);
991
- const objects = [...arrayOfRecords(profile.objects), ...arrayOfRecords(profile.focusTargets)];
992
- const targets = [];
993
- const ranges = [];
994
- const evidence = [];
995
- for (const id of request.ids) {
996
- const root = request.targetKind === "data_source" ? roots.find(item => item.dataSourceId === id) : undefined;
997
- const reader = request.targetKind === "data_source"
998
- ? readers.find(item => item.dataSourceId === id)
999
- : objects.find(item => item.objectId === id || item.id === id);
1000
- const status = stringValue(reader?.status) ?? stringValue(root?.status);
1001
- const machineValidated = reader?.machineValidated === true || root?.machineValidated === true;
1002
- const invariants = dedupeInvariants([
1003
- ...arrayOfRecords(root?.invariants).map(normalizeInvariant),
1004
- ...arrayOfRecords(reader?.invariants).map(normalizeInvariant)
1005
- ]);
1006
- const rawRanges = [...arrayOfRecords(root?.watchRanges), ...arrayOfRecords(reader?.watchRanges)];
1007
- if (status !== "reader_ready" || !machineValidated || invariants.length === 0 || rawRanges.length === 0)
1008
- throw new Error(`VERIFIED_RANGE_REQUIRED: ${id} is not reader_ready with machine-validated ranges and invariants`);
1009
- const semanticDefault = request.targetKind === "object"
1010
- ? "object"
1011
- : isRecord(root?.containerDefinition) || reader?.kind === "container" ? "container" : "memory";
1012
- const targetRanges = rawRanges.map(range => {
1013
- const rva = canonicalRva(requiredString(range.rva, `${id}.watchRanges.rva`));
1014
- const size = positiveInt(range.size, `${id}.watchRanges.size`);
1015
- const semantic = stringValue(range.semanticKind);
1016
- const semanticKind = semantic === "object" || semantic === "container" || semantic === "memory" ? semantic : semanticDefault;
1017
- return { rva, size, semanticKind, targetId: id, invariants };
1018
- });
1019
- targets.push({ id, status: "reader_ready", machineValidated: true, invariants });
1020
- ranges.push(...targetRanges);
1021
- evidence.push({
1022
- buildProfile: this.packagedProfiles
1023
- ? `${this.profileRoot}:${request.buildKey}:build-profile.json`
1024
- : resolve(this.profileRoot, `build-profile-${suffix}.json`),
1025
- targetId: id,
1026
- status,
1027
- machineValidated,
1028
- invariants,
1029
- ranges: targetRanges
1030
- });
1031
- }
1032
- const unique = new Map(ranges.map(range => [`${range.rva}:${range.size}:${range.semanticKind}:${range.targetId}`, range]));
1033
- const normalized = [...unique.values()].sort((left, right) => BigInt(left.rva) < BigInt(right.rva) ? -1 : BigInt(left.rva) > BigInt(right.rva) ? 1 : left.size - right.size);
1034
- if (normalized.reduce((sum, range) => sum + range.size, 0) > request.maxBytes)
1035
- throw new Error("RANGE_LIMIT_EXCEEDED: verified target ranges exceed maxBytes");
1036
- return { targets, ranges: normalized, evidence };
1037
- }
1038
- dispatchLifecycle(event, record) {
1039
- if (!this.lifecycle)
1040
- return;
1041
- let callback;
1042
- try {
1043
- callback = this.lifecycle(event);
1044
- }
1045
- catch (error) {
1046
- record.lifecycleFailures.push({ errorCode: "LIFECYCLE_CALLBACK_FAILED", error: errorText(error), event });
1047
- return;
1048
- }
1049
- const operation = Promise.resolve(callback);
1050
- let timer;
1051
- const boundedOperation = this.lifecycleTimeoutMs > 0
1052
- ? Promise.race([
1053
- operation,
1054
- new Promise((_, reject) => {
1055
- timer = setTimeout(() => {
1056
- const timeout = new Error("LIFECYCLE_CALLBACK_TIMEOUT");
1057
- timeout.name = "LifecycleTimeoutError";
1058
- reject(timeout);
1059
- }, this.lifecycleTimeoutMs);
1060
- timer.unref?.();
1061
- }),
1062
- ])
1063
- : operation;
1064
- void boundedOperation
1065
- .catch(error => {
1066
- record.lifecycleFailures.push({
1067
- errorCode: error?.name === "LifecycleTimeoutError" ? "LIFECYCLE_CALLBACK_TIMEOUT" : "LIFECYCLE_CALLBACK_FAILED",
1068
- error: errorText(error),
1069
- event,
1070
- });
1071
- })
1072
- .finally(() => { if (timer)
1073
- clearTimeout(timer); })
1074
- .catch(() => undefined);
1075
- }
1076
- }
1077
- function validateVerifiedResolution(value, maxBytes) {
1078
- if (!value || !Array.isArray(value.targets) || !Array.isArray(value.ranges) || !Array.isArray(value.evidence))
1079
- throw new Error("VERIFIED_RANGE_REQUIRED: resolver returned an invalid resolution");
1080
- const targets = value.targets.map(target => {
1081
- if (!target || target.status !== "reader_ready" || target.machineValidated !== true || typeof target.id !== "string")
1082
- throw new Error("VERIFIED_RANGE_REQUIRED: target is not machine validated");
1083
- const invariants = dedupeInvariants(arrayOfRecords(target.invariants).map(normalizeInvariant));
1084
- if (!invariants.length)
1085
- throw new Error(`VERIFIED_RANGE_REQUIRED: ${target.id} has no executable invariants`);
1086
- return { id: target.id, status: "reader_ready", machineValidated: true, invariants };
1087
- });
1088
- const targetIds = new Set(targets.map(target => target.id));
1089
- const ranges = value.ranges.map(range => {
1090
- if (!range || !targetIds.has(range.targetId))
1091
- throw new Error("VERIFIED_RANGE_REQUIRED: range target is not registered");
1092
- const size = positiveInt(range.size, `${range.targetId}.range.size`);
1093
- const invariants = dedupeInvariants(arrayOfRecords(range.invariants).map(normalizeInvariant));
1094
- if (!invariants.length)
1095
- throw new Error(`VERIFIED_RANGE_REQUIRED: ${range.targetId} range has no executable invariants`);
1096
- for (const invariant of invariants)
1097
- assertInvariantBounds(invariant, size, range.targetId);
1098
- const semanticKind = range.semanticKind === "object" || range.semanticKind === "container" || range.semanticKind === "memory" ? range.semanticKind : "memory";
1099
- return { rva: canonicalRva(range.rva), size, semanticKind, targetId: range.targetId, invariants };
1100
- });
1101
- if (ranges.reduce((sum, range) => sum + range.size, 0) > maxBytes)
1102
- throw new Error("RANGE_LIMIT_EXCEEDED: verified target ranges exceed maxBytes");
1103
- return { targets, ranges, evidence: value.evidence.map(item => ({ ...item })) };
1104
- }
1105
- function normalizeInvariant(value) {
1106
- const kind = requiredString(value.kind, "invariant.kind");
1107
- if (kind === "readable_range")
1108
- return { kind };
1109
- if (kind === "protection") {
1110
- const require = requiredString(value.require, "invariant.require");
1111
- if (!/^[rwx-]{3}$/.test(require))
1112
- throw new Error("VERIFIED_RANGE_REQUIRED: protection.require must be a three-character protection mask");
1113
- return { kind, require };
1114
- }
1115
- if (kind === "bytes_equal") {
1116
- const offset = nonNegativeInt(value.offset, "invariant.offset");
1117
- const bytesHex = requiredString(value.bytesHex, "invariant.bytesHex").toLowerCase();
1118
- if (!/^(?:[0-9a-f]{2})+$/.test(bytesHex))
1119
- throw new Error("VERIFIED_RANGE_REQUIRED: bytes_equal requires even-length hex bytes");
1120
- return { kind, offset, bytesHex };
1121
- }
1122
- if (kind === "pointer_in_module" || kind === "vtable_in_module")
1123
- return { kind, offset: nonNegativeInt(value.offset, "invariant.offset"), ...(value.allowNull === true ? { allowNull: true } : {}) };
1124
- if (kind === "count_capacity")
1125
- return { kind, countOffset: nonNegativeInt(value.countOffset, "invariant.countOffset"), capacityOffset: nonNegativeInt(value.capacityOffset, "invariant.capacityOffset"), width: value.width === 4 ? 4 : failInvariant("count_capacity.width must be 4"), maxCapacity: positiveInt(value.maxCapacity, "invariant.maxCapacity") };
1126
- if (kind === "string_length")
1127
- return { kind, lengthOffset: nonNegativeInt(value.lengthOffset, "invariant.lengthOffset"), width: value.width === 4 ? 4 : failInvariant("string_length.width must be 4"), maxLength: positiveInt(value.maxLength, "invariant.maxLength") };
1128
- if (kind === "traversal_limit")
1129
- return { kind, countOffset: nonNegativeInt(value.countOffset, "invariant.countOffset"), width: value.width === 4 ? 4 : failInvariant("traversal_limit.width must be 4"), maxItems: positiveInt(value.maxItems, "invariant.maxItems") };
1130
- throw new Error(`VERIFIED_RANGE_REQUIRED: unsupported invariant kind ${kind}`);
1131
- }
1132
- function assertInvariantBounds(invariant, size, targetId) {
1133
- const check = (offset, width) => { if (offset + width > size)
1134
- throw new Error(`VERIFIED_RANGE_REQUIRED: ${targetId} invariant exceeds bounded range`); };
1135
- if (invariant.kind === "bytes_equal")
1136
- check(invariant.offset, invariant.bytesHex.length / 2);
1137
- if (invariant.kind === "pointer_in_module" || invariant.kind === "vtable_in_module")
1138
- check(invariant.offset, 8);
1139
- if (invariant.kind === "count_capacity") {
1140
- check(invariant.countOffset, invariant.width);
1141
- check(invariant.capacityOffset, invariant.width);
1142
- }
1143
- if (invariant.kind === "string_length")
1144
- check(invariant.lengthOffset, invariant.width);
1145
- if (invariant.kind === "traversal_limit")
1146
- check(invariant.countOffset, invariant.width);
1147
- }
1148
- function dedupeInvariants(values) {
1149
- return [...new Map(values.map(value => [JSON.stringify(value), value])).values()];
1150
- }
1151
- function nonNegativeInt(value, name) {
1152
- if (!Number.isSafeInteger(value) || Number(value) < 0)
1153
- throw new Error(`${name} must be a non-negative integer`);
1154
- return Number(value);
1155
- }
1156
- function failInvariant(message) { throw new Error(`VERIFIED_RANGE_REQUIRED: ${message}`); }
1157
- const FOCUS_SCRIPT = String.raw `
1158
- let listeners = [];
1159
- let events = [];
1160
- let active = false;
1161
- let admitting = false;
1162
- let snapshotTimer = null;
1163
- let seq = 0;
1164
- let ingressOrdinal = 0;
1165
- let eventBytes = 0;
1166
- let droppedEventCount = 0;
1167
- let droppedByteCount = 0;
1168
- let droppedByReason = {};
1169
- let dropWindows = {};
1170
- let stacks = {};
1171
- let invocationCounter = 0;
1172
- let ranges = [];
1173
- let lastSnapshots = [];
1174
- let wowModule = null;
1175
- let limits = { maxEvents: 10000, maxBytes: 8388608, sampling: 1, captureArgs: false, captureReturns: false, captureCallStack: false, captureMemoryWrites: false, captureObjectDiff: false, snapshotIntervalMs: 1000 };
1176
-
1177
- function jsonBytes(value) {
1178
- try { return JSON.stringify(value).length; } catch (_) { return 0; }
1179
- }
1180
-
1181
- const DIFF_PREVIEW_BYTES = 64;
1182
- const SHA256_K = [
1183
- 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
1184
- 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
1185
- 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
1186
- 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
1187
- 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
1188
- 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
1189
- 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
1190
- 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
1191
- ];
1192
- function sha256Hex(hex) {
1193
- const bytes = [];
1194
- for (let i = 0; i + 1 < hex.length; i += 2) bytes.push(parseInt(hex.slice(i, i + 2), 16));
1195
- const bitLength = bytes.length * 8;
1196
- bytes.push(0x80);
1197
- while ((bytes.length % 64) !== 56) bytes.push(0);
1198
- for (let shift = 7; shift >= 0; shift--) bytes.push((bitLength / Math.pow(2, shift * 8)) & 0xff);
1199
- let h0=0x6a09e667,h1=0xbb67ae85,h2=0x3c6ef372,h3=0xa54ff53a,h4=0x510e527f,h5=0x9b05688c,h6=0x1f83d9ab,h7=0x5be0cd19;
1200
- for (let offset = 0; offset < bytes.length; offset += 64) {
1201
- const w = new Array(64).fill(0);
1202
- for (let i=0;i<16;i++) w[i]=((bytes[offset+i*4]<<24)|(bytes[offset+i*4+1]<<16)|(bytes[offset+i*4+2]<<8)|bytes[offset+i*4+3])>>>0;
1203
- for (let i=16;i<64;i++) { const a=w[i-15], b=w[i-2]; const s0=((a>>>7)|(a<<25))^((a>>>18)|(a<<14))^(a>>>3); const s1=((b>>>17)|(b<<15))^((b>>>19)|(b<<13))^(b>>>10); w[i]=(w[i-16]+s0+w[i-7]+s1)>>>0; }
1204
- let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,hh=h7;
1205
- for (let i=0;i<64;i++) { const s1=((e>>>6)|(e<<26))^((e>>>11)|(e<<21))^((e>>>25)|(e<<7)); const ch=(e&f)^(~e&g); const t1=(hh+s1+ch+SHA256_K[i]+w[i])>>>0; const s0=((a>>>2)|(a<<30))^((a>>>13)|(a<<19))^((a>>>22)|(a<<10)); const maj=(a&b)^(a&c)^(b&c); const t2=(s0+maj)>>>0; hh=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0; }
1206
- h0=(h0+a)>>>0;h1=(h1+b)>>>0;h2=(h2+c)>>>0;h3=(h3+d)>>>0;h4=(h4+e)>>>0;h5=(h5+f)>>>0;h6=(h6+g)>>>0;h7=(h7+hh)>>>0;
1207
- }
1208
- return [h0,h1,h2,h3,h4,h5,h6,h7].map(v=>('00000000'+v.toString(16)).slice(-8)).join('');
1209
- }
1210
- function boundedBytesEvidence(hex) {
1211
- const normalized = typeof hex === 'string' ? hex : '';
1212
- const byteLength = Math.floor(normalized.length / 2);
1213
- const previewHex = normalized.slice(0, DIFF_PREVIEW_BYTES * 2);
1214
- return { byteLength, sha256: sha256Hex(normalized), previewHex, previewTruncated: byteLength > DIFF_PREVIEW_BYTES };
1215
- }
1216
-
1217
- function u32le(bytes, offset) {
1218
- return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0;
1219
- }
1220
-
1221
- function validateInvariant(invariant, address, bytes, protection) {
1222
- if (invariant.kind === 'readable_range') return { kind: invariant.kind, valid: bytes.length > 0 && typeof protection === 'string' && protection.indexOf('r') >= 0, protection };
1223
- if (invariant.kind === 'protection') {
1224
- const required = String(invariant.require || '---');
1225
- const valid = required.length === 3 && typeof protection === 'string' && required.split('').every((flag, index) => flag === '-' || protection[index] === flag);
1226
- return { kind: invariant.kind, valid, required, actual: protection || null };
1227
- }
1228
- if (invariant.kind === 'bytes_equal') {
1229
- let actual = '';
1230
- const count = invariant.bytesHex.length / 2;
1231
- for (let i = 0; i < count; i++) actual += ('0' + bytes[invariant.offset + i].toString(16)).slice(-2);
1232
- return { kind: invariant.kind, valid: actual === invariant.bytesHex, offset: invariant.offset, expected: invariant.bytesHex, actual };
1233
- }
1234
- if (invariant.kind === 'pointer_in_module' || invariant.kind === 'vtable_in_module') {
1235
- const pointer = address.add(invariant.offset).readPointer();
1236
- const valid = (invariant.allowNull === true && pointer.isNull()) || (!pointer.isNull() && wowModule !== null && pointer.compare(wowModule.base) >= 0 && pointer.compare(wowModule.base.add(wowModule.size)) < 0);
1237
- return { kind: invariant.kind, valid, offset: invariant.offset, pointer: pointer.toString(), moduleBase: wowModule ? wowModule.base.toString() : null };
1238
- }
1239
- if (invariant.kind === 'count_capacity') {
1240
- const count = u32le(bytes, invariant.countOffset); const capacity = u32le(bytes, invariant.capacityOffset);
1241
- return { kind: invariant.kind, valid: count <= capacity && capacity <= invariant.maxCapacity, count, capacity, maxCapacity: invariant.maxCapacity };
1242
- }
1243
- if (invariant.kind === 'string_length') {
1244
- const length = u32le(bytes, invariant.lengthOffset);
1245
- return { kind: invariant.kind, valid: length <= invariant.maxLength, length, maxLength: invariant.maxLength };
1246
- }
1247
- if (invariant.kind === 'traversal_limit') {
1248
- const count = u32le(bytes, invariant.countOffset);
1249
- return { kind: invariant.kind, valid: count <= invariant.maxItems, count, maxItems: invariant.maxItems, traversalAllowed: Math.min(count, invariant.maxItems) };
1250
- }
1251
- return { kind: String(invariant.kind), valid: false, error: 'unsupported invariant' };
1252
- }
1253
-
1254
- function drop(reason, size, ingress, threadId) {
1255
- const now = new Date().toISOString();
1256
- droppedEventCount += 1;
1257
- droppedByteCount += size;
1258
- droppedByReason[reason] = (droppedByReason[reason] || 0) + 1;
1259
- const current = dropWindows[reason] || { firstLostIngressOrdinal: ingress, firstTimestamp: now, producer: 'gumjs', threadIds: [], chainCompletenessAffected: true };
1260
- current.lastLostIngressOrdinal = ingress; current.lastTimestamp = now;
1261
- if (threadId && current.threadIds.indexOf(threadId) < 0) current.threadIds.push(threadId);
1262
- dropWindows[reason] = current;
1263
- }
1264
-
1265
- function append(kind, target, payload, threadId, depth) {
1266
- const ingress = ++ingressOrdinal;
1267
- if (!admitting) { drop('producer_paused', 0, ingress, threadId); return false; }
1268
- const event = { ...payload, seq: 0, ingressOrdinal: ingress, target, event: { kind, timestamp: new Date().toISOString(), threadId: threadId || 0, depth: depth || 0 }, status: payload.status === 'partial' || payload.status === 'unresolved' ? payload.status : 'dynamic' };
1269
- const size = jsonBytes(event);
1270
- if (limits.sampling < 1 && Math.random() > limits.sampling) { drop('sampling', size, ingress, threadId); return false; }
1271
- if (events.length >= limits.maxEvents) { drop('max_events', size, ingress, threadId); return false; }
1272
- if (eventBytes + size > limits.maxBytes) { drop('max_bytes', size, ingress, threadId); return false; }
1273
- event.seq = ++seq;
1274
- events.push(event);
1275
- eventBytes += size;
1276
- return true;
1277
- }
1278
-
1279
- function readRange(range) {
1280
- const address = ptr(range.runtimeAddress);
1281
- const queried = Process.findRangeByAddress(address);
1282
- if (!queried || queried.protection.indexOf('r') < 0 || address.add(range.size).compare(queried.base.add(queried.size)) > 0) throw new Error('RANGE_VALIDATION_FAILED:' + range.rva);
1283
- const raw = address.readByteArray(range.size);
1284
- const bytes = raw ? new Uint8Array(raw) : new Uint8Array(0);
1285
- const invariantResults = (range.invariants || []).map(invariant => validateInvariant(invariant, address, bytes, queried.protection));
1286
- if (invariantResults.some(result => result.valid !== true)) throw new Error('INVARIANT_VALIDATION_FAILED:' + range.rva + ':' + JSON.stringify(invariantResults));
1287
- return { rva: range.rva, runtimeAddress: range.runtimeAddress, size: range.size, protection: queried.protection, semanticKind: range.semanticKind || 'memory', targetId: range.targetId || null, invariants: range.invariants || [], invariantResults, bytesHex: Array.prototype.map.call(bytes, b => ('0' + b.toString(16)).slice(-2)).join('') };
1288
- }
1289
-
1290
- function snapshot(phase) {
1291
- const values = [];
1292
- for (const range of ranges) {
1293
- try { values.push({ ...readRange(range), valid: true }); }
1294
- catch (error) { values.push({ rva: range.rva, runtimeAddress: range.runtimeAddress, size: range.size, protection: range.protection, semanticKind: range.semanticKind || 'memory', targetId: range.targetId || null, valid: false, status: 'unresolved', error: { code: String(error).indexOf('INVARIANT_VALIDATION_FAILED') >= 0 ? 'INVARIANT_VALIDATION_FAILED' : 'RANGE_VALIDATION_FAILED', message: String(error) } }); }
1295
- }
1296
- return { phase, capturedAt: new Date().toISOString(), ranges: values };
1297
- }
1298
-
1299
- function pollRanges() {
1300
- if (!active || !admitting || !ranges.length) return;
1301
- const current = snapshot('during').ranges;
1302
- for (let index = 0; index < current.length; index++) {
1303
- const before = lastSnapshots[index]; const after = current[index];
1304
- if (after && after.valid !== true) {
1305
- append('exception', { kind: after.semanticKind || 'address_range', name: after.targetId || undefined, rva: after.rva, runtimeAddress: after.runtimeAddress }, { before: before || {}, after, diff: {}, error: { code: after.error && after.error.code ? after.error.code : 'INVARIANT_VALIDATION_FAILED', message: after.error && after.error.message ? after.error.message : 'runtime invariant failed' }, status: 'partial' }, Process.getCurrentThreadId(), 0);
1306
- } else if (before && before.valid === true && after && after.valid === true && before.bytesHex !== after.bytesHex) {
1307
- const semantic = after.semanticKind === 'object' || after.semanticKind === 'container' ? after.semanticKind : 'memory';
1308
- const eventKind = limits.captureObjectDiff && semantic !== 'memory' ? semantic + '_change' : 'memory_change';
1309
- append(eventKind, { kind: semantic === 'memory' ? 'address_range' : semantic, name: after.targetId || undefined, rva: after.rva, runtimeAddress: after.runtimeAddress }, { before, after, diff: { changed: true, captureMode: 'bounded_range_polling', semanticKind: semantic, targetId: after.targetId || null, invariants: after.invariants || [], old: boundedBytesEvidence(before.bytesHex), new: boundedBytesEvidence(after.bytesHex) } }, Process.getCurrentThreadId(), 0);
1310
- }
1311
- }
1312
- lastSnapshots = current;
1313
- }
1314
-
1315
- function callStack(context) {
1316
- if (!limits.captureCallStack) return [];
1317
- try { return Thread.backtrace(context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).map(symbol => symbol.toString()); } catch (_) { return []; }
1318
- }
1319
-
1320
- function focusStart(config) {
1321
- if (active) throw new Error('focus session already active');
1322
- const module = Process.getModuleByName('Wow.exe');
1323
- wowModule = module;
1324
- limits = {
1325
- maxEvents: Number.isSafeInteger(config.maxEvents) && config.maxEvents > 0 ? config.maxEvents : 10000,
1326
- maxBytes: Number.isSafeInteger(config.maxBytes) && config.maxBytes > 0 ? config.maxBytes : 8388608,
1327
- sampling: typeof config.sampling === 'number' && config.sampling > 0 && config.sampling <= 1 ? config.sampling : 1,
1328
- captureArgs: config.captureArgs === true,
1329
- captureReturns: config.captureReturns === true,
1330
- captureCallStack: config.captureCallStack === true,
1331
- captureMemoryWrites: config.captureMemoryWrites === true,
1332
- captureObjectDiff: config.captureObjectDiff === true,
1333
- snapshotIntervalMs: Number.isSafeInteger(config.snapshotIntervalMs) && config.snapshotIntervalMs >= 0 ? config.snapshotIntervalMs : 1000
1334
- };
1335
- events = []; seq = 0; ingressOrdinal = 0; eventBytes = 0; droppedEventCount = 0; droppedByteCount = 0; droppedByReason = {}; dropWindows = {}; stacks = {}; invocationCounter = 0; ranges = [];
1336
- const ids = [];
1337
- const verifiedHooks = Array.isArray(config.verifiedHookTargets) ? config.verifiedHookTargets : [];
1338
- const rvas = [];
1339
- if (config.rva) rvas.push(config.rva);
1340
- if (Array.isArray(config.rvas)) rvas.push(...config.rvas);
1341
- if (rvas.length && verifiedHooks.length !== rvas.length) throw new Error('HOOK_TARGET_UNVERIFIED: verified target count mismatch (rvas=' + rvas.length + ', verified=' + verifiedHooks.length + ')');
1342
- const pendingHooks = [];
1343
- for (const raw of rvas) {
1344
- const expected = verifiedHooks.find(value => String(value.rva).toLowerCase() === String(raw).toLowerCase());
1345
- if (!expected || expected.abiStatus !== 'confirmed' || !expected.functionBoundary || expected.functionBoundary.status !== 'confirmed') throw new Error('HOOK_TARGET_UNVERIFIED:' + raw);
1346
- const address = module.base.add(ptr(raw));
1347
- if (address.toString().toLowerCase() !== String(expected.runtimeAddress).toLowerCase()) throw new Error('HOOK_TARGET_ADDRESS_MISMATCH:' + raw);
1348
- if (address.compare(module.base) < 0 || address.add(expected.entryBytesLength).compare(module.base.add(module.size)) > 0) throw new Error('HOOK_TARGET_RANGE_INVALID:' + raw);
1349
- const entryRange = Process.findRangeByAddress(address);
1350
- if (!entryRange || entryRange.protection.indexOf('r') < 0 || entryRange.protection.indexOf('x') < 0 || address.add(expected.entryBytesLength).compare(entryRange.base.add(entryRange.size)) > 0) throw new Error('HOOK_TARGET_NOT_EXECUTABLE:' + raw);
1351
- const actual = address.readByteArray(expected.entryBytesLength);
1352
- const actualHex = actual ? Array.prototype.map.call(new Uint8Array(actual), b => ('0' + b.toString(16)).slice(-2)).join('') : '';
1353
- if (actualHex.toLowerCase() !== String(expected.entryBytesHex).toLowerCase()) throw new Error('HOOK_TARGET_BYTES_MISMATCH:' + raw);
1354
- pendingHooks.push({ raw, address, entryRange });
1355
- }
1356
- for (const item of pendingHooks) {
1357
- const raw = item.raw; const address = item.address; const entryRange = item.entryRange;
1358
- const target = { kind: config.kind, rva: String(raw), runtimeAddress: address.toString() };
1359
- const listener = Interceptor.attach(address, {
1360
- onEnter(args) {
1361
- this.focusTarget = target;
1362
- const key = String(this.threadId); const stack = stacks[key] || []; const invocationId = 'gumjs:' + (++invocationCounter); const parentInvocationId = stack.length ? stack[stack.length - 1] : null; stack.push(invocationId); stacks[key] = stack; this.focusInvocationId = invocationId; this.focusParentInvocationId = parentInvocationId; this.focusDepth = stack.length;
1363
- append('enter', target, { arguments: limits.captureArgs ? [args[0] ? args[0].toString() : null] : [], callStack: callStack(this.context), invocationId, parentInvocationId, recursive: stack.length > 1 }, this.threadId, this.focusDepth);
1364
- },
1365
- onLeave(retval) {
1366
- append('leave', this.focusTarget || target, { returnValue: limits.captureReturns ? retval.toString() : undefined, invocationId: this.focusInvocationId, parentInvocationId: this.focusParentInvocationId, recursive: (this.focusDepth || 1) > 1 }, this.threadId, this.focusDepth || 1);
1367
- const key = String(this.threadId); const stack = stacks[key] || []; stack.pop(); if (stack.length) stacks[key] = stack; else delete stacks[key];
1368
- }
1369
- });
1370
- listeners.push(listener); ids.push('interceptor-' + ids.length);
1371
- if (entryRange && address.add(16).compare(entryRange.base.add(entryRange.size)) <= 0) ranges.push({ rva: String(raw), runtimeAddress: address.toString(), size: 16, protection: entryRange.protection, watch: false });
1372
- }
1373
- if (Array.isArray(config.ranges)) for (const range of config.ranges) {
1374
- const address = module.base.add(ptr(range.rva)); const queried = Process.findRangeByAddress(address);
1375
- if (!queried || queried.protection.indexOf('r') < 0 || range.size <= 0 || address.compare(module.base) < 0 || address.add(range.size).compare(module.base.add(module.size)) > 0 || address.add(range.size).compare(queried.base.add(queried.size)) > 0) throw new Error('RANGE_VALIDATION_FAILED:' + range.rva);
1376
- ranges.push({ rva: String(range.rva), runtimeAddress: address.toString(), size: range.size, protection: queried.protection, watch: true, semanticKind: range.semanticKind || 'memory', targetId: range.targetId || null, invariants: Array.isArray(range.invariants) ? range.invariants : [] });
1377
- }
1378
- lastSnapshots = snapshot('before').ranges;
1379
- const invalidBefore = lastSnapshots.find(value => value.valid !== true);
1380
- if (invalidBefore) {
1381
- for (const listener of listeners.splice(0)) { try { listener.detach(); } catch (_) {} }
1382
- ranges = []; lastSnapshots = [];
1383
- throw new Error('INVARIANT_VALIDATION_FAILED:' + JSON.stringify({ code: 'INVARIANT_VALIDATION_FAILED', status: 'unresolved', range: invalidBefore }));
1384
- }
1385
- active = true; admitting = false;
1386
- if (ranges.length && limits.snapshotIntervalMs > 0) snapshotTimer = setInterval(pollRanges, limits.snapshotIntervalMs);
1387
- return { active, hookIds: ids, moduleBase: module.base.toString() };
1388
- }
1389
- function focusSnapshot(phase) { return snapshot(phase || 'checkpoint'); }
1390
- function focusPause() { admitting = false; return focusStatus(); }
1391
- function focusResume() { if (!active) throw new Error('focus session is not active'); lastSnapshots = snapshot('resume').ranges; admitting = true; return focusStatus(); }
1392
- function focusStatus() { return { active, admitting, captureMode: 'bounded_range_polling', newestSeq: seq, eventBytes, ingressOrdinal, droppedEventCount, droppedByteCount, droppedByReason, dropWindows }; }
1393
- function focusRead(afterSeq, limit) { return { ...focusStatus(), events: events.filter(e => e.seq > afterSeq).slice(0, limit) }; }
1394
- function focusStop() { admitting = false; if (snapshotTimer !== null) { clearInterval(snapshotTimer); snapshotTimer = null; } for (const listener of listeners.splice(0)) { try { listener.detach(); } catch (_) {} } active = false; return { ...focusStatus(), hooks: 0, scripts: 0, interceptors: 0 }; }
1395
- rpc.exports = { focusStart, focusSnapshot, focusPause, focusResume, focusRead, focusStatus, focusStop, dispose: focusStop };
1396
- `;
1397
- function streamForEvent(event, files) {
1398
- const kind = event.event.kind;
1399
- if (["memory_change", "object_change", "container_change", "snapshot", "checkpoint", "pause", "resume", "cleanup", "overflow"].includes(kind))
1400
- return files.data;
1401
- if (event.target.kind === "lua_wrapper")
1402
- return files.lua;
1403
- return files.cpp;
1404
- }
1405
- async function writeJsonAtomic(file, value) {
1406
- await writeJsonAtomicGuarded(file, value, () => true);
1407
- }
1408
- async function writeJsonAtomicGuarded(file, value, ownsPublication, beforePublish, publicationSignal) {
1409
- const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
1410
- const payload = JSON.stringify(value, null, 2) + "\n";
1411
- let handle;
1412
- try {
1413
- handle = await open(temporary, "wx", 0o600);
1414
- await handle.writeFile(payload, "utf8");
1415
- await handle.sync();
1416
- await handle.close();
1417
- handle = undefined;
1418
- if (!await waitForPublication(beforePublish, publicationSignal)) {
1419
- await unlink(temporary).catch(() => undefined);
1420
- return false;
1421
- }
1422
- if (publicationSignal?.aborted || !ownsPublication()) {
1423
- await unlink(temporary).catch(() => undefined);
1424
- return false;
1425
- }
1426
- await rename(temporary, file);
1427
- await syncParentDirectory(file);
1428
- return true;
1429
- }
1430
- catch (error) {
1431
- await handle?.close().catch(() => undefined);
1432
- await unlink(temporary).catch(() => undefined);
1433
- throw error;
1434
- }
1435
- }
1436
- async function waitForPublication(beforePublish, signal) {
1437
- if (signal?.aborted)
1438
- return false;
1439
- if (!beforePublish)
1440
- return true;
1441
- if (!signal) {
1442
- await beforePublish();
1443
- return true;
1444
- }
1445
- let abortListener;
1446
- const aborted = new Promise(resolve => {
1447
- abortListener = () => resolve(false);
1448
- signal.addEventListener("abort", abortListener, { once: true });
1449
- });
1450
- try {
1451
- // Promise.race keeps a rejection handler attached to a late hook promise,
1452
- // so cancellation cannot turn a later hook rejection into an unhandled one.
1453
- return await Promise.race([
1454
- Promise.resolve().then(beforePublish).then(() => !signal.aborted),
1455
- aborted
1456
- ]);
1457
- }
1458
- finally {
1459
- if (abortListener)
1460
- signal.removeEventListener("abort", abortListener);
1461
- }
1462
- }
1463
- async function drainAbortedPublications(publications, signal) {
1464
- const pending = [...publications];
1465
- if (pending.length === 0)
1466
- return true;
1467
- const drained = Promise.allSettled(pending).then(() => true);
1468
- if (!signal)
1469
- return drained;
1470
- // Let abort-aware guarded writers enter their unlink/finally path before
1471
- // observing a concurrently expiring aggregate cleanup signal.
1472
- await Promise.resolve();
1473
- if (signal.aborted)
1474
- return false;
1475
- let abortListener;
1476
- const deadline = new Promise(resolve => {
1477
- abortListener = () => resolve(false);
1478
- signal.addEventListener("abort", abortListener, { once: true });
1479
- });
1480
- try {
1481
- return await Promise.race([drained, deadline]);
1482
- }
1483
- finally {
1484
- if (abortListener)
1485
- signal.removeEventListener("abort", abortListener);
1486
- }
1487
- }
1488
- async function syncParentDirectory(file) {
1489
- let handle;
1490
- try {
1491
- handle = await open(dirname(file), "r");
1492
- await handle.sync();
1493
- }
1494
- catch (error) {
1495
- const code = error?.code;
1496
- if (!code || !["EACCES", "EBADF", "EISDIR", "EINVAL", "ENOSYS", "ENOTSUP", "EPERM"].includes(code))
1497
- throw error;
1498
- }
1499
- finally {
1500
- await handle?.close().catch(() => undefined);
1501
- }
1502
- }
1503
- export async function appendJsonlDurable(file, line, signal, hooks = {}) {
1504
- signal?.throwIfAborted();
1505
- await recoverJsonlTail(file);
1506
- signal?.throwIfAborted();
1507
- const handle = await open(file, "a+", 0o600);
1508
- let closed = false;
1509
- const close = async () => {
1510
- if (closed)
1511
- return;
1512
- closed = true;
1513
- await handle.close();
1514
- };
1515
- const originalLength = Number((await handle.stat()).size);
1516
- try {
1517
- let prefix = "";
1518
- if (originalLength > 0) {
1519
- const lastByte = Buffer.alloc(1);
1520
- await handle.read(lastByte, 0, 1, originalLength - 1);
1521
- if (lastByte[0] !== 0x0a)
1522
- prefix = "\n";
1523
- }
1524
- const payload = Buffer.from(`${prefix}${line}\n`, "utf8");
1525
- signal?.throwIfAborted();
1526
- if (hooks.write)
1527
- await hooks.write(handle, payload, signal);
1528
- else
1529
- await handle.writeFile(payload, { signal });
1530
- // Abort is checked before the durability barrier only. Once sync starts,
1531
- // the append is committed and its sequence/byte accounting must stand.
1532
- throwIfAborted(signal);
1533
- if (hooks.sync)
1534
- await hooks.sync(handle);
1535
- else
1536
- await handle.sync();
1537
- }
1538
- catch (error) {
1539
- await close().catch(() => undefined);
1540
- try {
1541
- await rollbackJsonlAppend(file, originalLength, error);
1542
- }
1543
- catch (rollbackError) {
1544
- if (error && typeof error === "object")
1545
- Object.assign(error, { rollbackError: errorText(rollbackError) });
1546
- else
1547
- throw new AggregateError([error, rollbackError], "STREAM_APPEND_ROLLBACK_FAILED");
1548
- }
1549
- throw error;
1550
- }
1551
- finally {
1552
- await close();
1553
- }
1554
- }
1555
- function throwIfAborted(signal) {
1556
- if (!signal?.aborted)
1557
- return;
1558
- const reason = signal.reason;
1559
- if (reason && typeof reason === "object" && "name" in reason)
1560
- throw reason;
1561
- const error = new Error("The append was aborted");
1562
- error.name = "AbortError";
1563
- throw error;
1564
- }
1565
- async function rollbackJsonlAppend(file, originalLength, cause) {
1566
- let content;
1567
- try {
1568
- content = await readFile(file);
1569
- }
1570
- catch (error) {
1571
- if (error.code === "ENOENT" && originalLength === 0)
1572
- return;
1573
- throw error;
1574
- }
1575
- if (content.length < originalLength)
1576
- throw new Error(`STREAM_APPEND_BASE_CHANGED:${file}`);
1577
- const appended = content.subarray(originalLength);
1578
- let restoreError;
1579
- try {
1580
- const handle = await open(file, "r+");
1581
- try {
1582
- await handle.truncate(originalLength);
1583
- await handle.sync();
1584
- }
1585
- finally {
1586
- await handle.close();
1587
- }
1588
- }
1589
- catch (error) {
1590
- restoreError = error;
1591
- }
1592
- let evidenceError;
1593
- if (appended.length > 0) {
1594
- try {
1595
- await writeAppendRollbackEvidence(file, originalLength, appended, cause);
1596
- }
1597
- catch (error) {
1598
- evidenceError = error;
1599
- }
1600
- }
1601
- if (restoreError && evidenceError)
1602
- throw new AggregateError([restoreError, evidenceError], "STREAM_APPEND_ROLLBACK_AND_EVIDENCE_FAILED");
1603
- if (restoreError)
1604
- throw restoreError;
1605
- if (evidenceError)
1606
- throw evidenceError;
1607
- }
1608
- async function writeAppendRollbackEvidence(file, offset, bytes, cause) {
1609
- const sha256 = createHash("sha256").update(bytes).digest("hex");
1610
- const causeText = errorText(cause);
1611
- const evidenceKey = createHash("sha256").update(bytes).update(causeText).digest("hex");
1612
- const preview = bytes.subarray(0, 1024 * 1024);
1613
- const quarantine = `${file}.append-${evidenceKey}.quarantine.json`;
1614
- const evidence = JSON.stringify({
1615
- schemaVersion: 1,
1616
- kind: "focused-jsonl-append-rollback",
1617
- sourcePath: resolve(file),
1618
- byteOffset: offset,
1619
- bytes: bytes.length,
1620
- sha256,
1621
- encoding: "base64",
1622
- data: preview.toString("base64"),
1623
- dataBytes: preview.length,
1624
- dataTruncated: preview.length !== bytes.length,
1625
- cause: causeText,
1626
- }) + "\n";
1627
- try {
1628
- const handle = await open(quarantine, "wx", 0o600);
1629
- let complete = false;
1630
- try {
1631
- await handle.writeFile(evidence, "utf8");
1632
- await handle.sync();
1633
- complete = true;
1634
- }
1635
- finally {
1636
- await handle.close().catch(() => undefined);
1637
- if (!complete)
1638
- await unlink(quarantine).catch(() => undefined);
1639
- }
1640
- }
1641
- catch (error) {
1642
- if (error.code !== "EEXIST")
1643
- throw error;
1644
- const prior = await readFile(quarantine, "utf8");
1645
- if (prior !== evidence)
1646
- throw new Error(`STREAM_QUARANTINE_CONFLICT:${quarantine}`);
1647
- }
1648
- }
1649
- async function readRecoveredFile(file) {
1650
- await recoverJsonlTail(file);
1651
- return readFile(file).catch(error => {
1652
- if (error.code === "ENOENT")
1653
- return Buffer.alloc(0);
1654
- throw error;
1655
- });
1656
- }
1657
- async function recoverJsonlTail(file) {
1658
- let content;
1659
- try {
1660
- content = await readFile(file);
1661
- }
1662
- catch (error) {
1663
- if (error.code === "ENOENT")
1664
- return;
1665
- throw error;
1666
- }
1667
- if (content.length === 0 || content.at(-1) === 0x0a)
1668
- return;
1669
- const offset = content.lastIndexOf(0x0a) + 1;
1670
- const tail = content.subarray(offset);
1671
- try {
1672
- JSON.parse(tail.toString("utf8"));
1673
- return;
1674
- }
1675
- catch (error) {
1676
- if (!isIncompleteJsonTail(tail))
1677
- throw new Error(`STREAM_TAIL_CORRUPT:${file}:${error instanceof Error ? error.message : String(error)}`);
1678
- }
1679
- const sha256 = createHash("sha256").update(tail).digest("hex");
1680
- const quarantine = `${file}.tail-${sha256}.quarantine.json`;
1681
- const evidence = JSON.stringify({ schemaVersion: 1, kind: "focused-jsonl-incomplete-tail", sourcePath: resolve(file), byteOffset: offset, bytes: tail.length, sha256, encoding: "base64", data: tail.toString("base64") }) + "\n";
1682
- try {
1683
- const quarantineHandle = await open(quarantine, "wx");
1684
- let complete = false;
1685
- try {
1686
- await quarantineHandle.writeFile(evidence, "utf8");
1687
- await quarantineHandle.sync();
1688
- complete = true;
1689
- }
1690
- finally {
1691
- await quarantineHandle.close().catch(() => undefined);
1692
- if (!complete)
1693
- await unlink(quarantine).catch(() => undefined);
1694
- }
1695
- }
1696
- catch (error) {
1697
- if (error.code !== "EEXIST")
1698
- throw error;
1699
- const prior = await readFile(quarantine, "utf8");
1700
- if (prior !== evidence)
1701
- throw new Error(`STREAM_QUARANTINE_CONFLICT:${quarantine}`);
1702
- }
1703
- const handle = await open(file, "r+");
1704
- try {
1705
- await handle.truncate(offset);
1706
- await handle.sync();
1707
- }
1708
- finally {
1709
- await handle.close();
1710
- }
1711
- }
1712
- function isIncompleteJsonTail(tail) {
1713
- const text = tail.toString("utf8").trimStart();
1714
- if (!text.startsWith("{"))
1715
- return false;
1716
- const stack = [];
1717
- let inString = false;
1718
- let escaped = false;
1719
- for (let index = 0; index < text.length; index += 1) {
1720
- const character = text[index];
1721
- if (inString) {
1722
- if (escaped)
1723
- escaped = false;
1724
- else if (character === "\\")
1725
- escaped = true;
1726
- else if (character === '"')
1727
- inString = false;
1728
- continue;
1729
- }
1730
- if (character === '"')
1731
- inString = true;
1732
- else if (character === "{" || character === "[")
1733
- stack.push(character);
1734
- else if (character === "}" || character === "]") {
1735
- const expected = character === "}" ? "{" : "[";
1736
- if (stack.pop() !== expected)
1737
- return false;
1738
- if (stack.length === 0 && text.slice(index + 1).trim())
1739
- return false;
1740
- }
1741
- }
1742
- return inString || escaped || stack.length > 0;
1743
- }
1744
- function normalizeTimelineKind(kind) {
1745
- return ["snapshot", "enter", "leave", "memory_change", "object_change", "container_change", "exception", "checkpoint", "pause", "resume", "overflow", "cleanup"].includes(kind)
1746
- ? kind
1747
- : "exception";
1748
- }
1749
- function isTargetKind(value) { return ["lua_wrapper", "cpp_function", "data_source", "object", "address_range"].includes(String(value)); }
1750
- function selectorTarget(selector, moduleBase) { return { kind: selector.kind, rva: selector.rva, runtimeAddress: selector.rva ? addHex(moduleBase, selector.rva) : undefined }; }
1751
- function addHex(base, rva) { try {
1752
- return `0x${(BigInt(base) + BigInt(rva)).toString(16)}`;
1753
- }
1754
- catch {
1755
- return base;
1756
- } }
1757
- function canonicalRva(value) { try {
1758
- return `0x${BigInt(value).toString(16).toUpperCase()}`;
1759
- }
1760
- catch {
1761
- throw new Error(`invalid RVA ${value}`);
1762
- } }
1763
- function isSha256(value) { return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value); }
1764
- function isConfirmedFunctionBoundary(value) {
1765
- return isRecord(value) && value.status === "confirmed" && typeof value.startVa === "string" && typeof value.size === "string";
1766
- }
1767
- function validateVerifiedHookTargets(values, request) {
1768
- if (!Array.isArray(values) || values.length !== request.rvas.length)
1769
- throw new FocusRequestError("SELECTOR_INVALID", "HOOK_TARGET_UNVERIFIED: verified target count mismatch");
1770
- const requested = new Set(request.rvas.map(canonicalRva));
1771
- const seen = new Set();
1772
- for (const value of values) {
1773
- const rva = canonicalRva(value.rva);
1774
- if (!requested.has(rva) || seen.has(rva))
1775
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: unexpected or duplicate target ${rva}`);
1776
- seen.add(rva);
1777
- if (value.abiStatus !== "confirmed" || !isConfirmedFunctionBoundary(value.functionBoundary) || !isSha256(value.entryBytesSha256) || !/^[0-9a-f]+$/i.test(value.entryBytesHex) || value.entryBytesHex.length !== value.entryBytesLength * 2 || value.entryBytesLength < 16 || value.entryBytesLength > 64)
1778
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: incomplete evidence for ${rva}`);
1779
- const recomputedHash = createHash("sha256").update(Buffer.from(value.entryBytesHex, "hex")).digest("hex");
1780
- if (recomputedHash !== value.entryBytesSha256.toLowerCase())
1781
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: entry hash does not match entry bytes for ${rva}`);
1782
- if (value.runtimeAddress.toLowerCase() !== addHex(request.moduleBase, rva).toLowerCase())
1783
- throw new FocusRequestError("SELECTOR_INVALID", `HOOK_TARGET_UNVERIFIED: runtime address mismatch for ${rva}`);
1784
- }
1785
- return values.map(value => ({ ...value, rva: canonicalRva(value.rva), entryBytesHex: value.entryBytesHex.toLowerCase(), entryBytesSha256: value.entryBytesSha256.toLowerCase() }));
1786
- }
1787
- function requiredString(value, field, fallback) { const v = typeof value === "string" && value.trim() ? value : fallback; if (!v)
1788
- throw new Error(`${field} is required`); return v; }
1789
- function requiredSessionId(value) { const id = requiredString(value, "sessionId"); if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id))
1790
- throw new Error("SESSION_ID_INVALID"); return id; }
1791
- function positiveInt(value, field) { if (!Number.isSafeInteger(value) || Number(value) <= 0)
1792
- throw new Error(`${field} must be a positive integer`); return Number(value); }
1793
- function bounded(value, fallback, min, max) { if (value === undefined)
1794
- return fallback; if (!Number.isSafeInteger(value) || Number(value) < min || Number(value) > max)
1795
- throw new Error(`value must be an integer in [${min},${max}]`); return Number(value); }
1796
- function boundedNumber(value, fallback, min, max) { if (value === undefined)
1797
- return fallback; if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max)
1798
- throw new Error(`value must be in [${min},${max}]`); return value; }
1799
- function bool(value) { return value === true; }
1800
- function requiredRecord(value, field) { if (!isRecord(value))
1801
- throw new Error(`${field} is required`); return value; }
1802
- function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
1803
- function stringValue(value) { return typeof value === "string" && value.trim() ? value : undefined; }
1804
- function numberValue(value) { return typeof value === "number" && Number.isSafeInteger(value) ? value : undefined; }
1805
- function errorText(error) { return error instanceof Error ? error.message : String(error); }
1806
- function validateTriggerPlanOrder(plan) {
1807
- let lastRank = -1;
1808
- const ranks = { before: 0, during: 1, after: 2 };
1809
- for (const phase of plan.phases ?? []) {
1810
- const rank = ranks[phase.phase];
1811
- if (rank < lastRank)
1812
- throw new Error("TRIGGER_PLAN_INVALID: phases must be ordered before, during, after");
1813
- lastRank = rank;
1814
- }
1815
- }
1816
- function arrayOfRecords(value) { return Array.isArray(value) ? value.filter(isRecord) : []; }
1817
- function arrayOfStrings(value) { return Array.isArray(value) ? value.filter(item => typeof item === "string") : []; }
1818
- function stripSelectorFields(selector) {
1819
- return {
1820
- combine: selector.combine,
1821
- ...(selector.apis ? { apis: selector.apis } : {}),
1822
- ...(selector.namespaces ? { namespaces: selector.namespaces } : {}),
1823
- ...(selector.globs ? { globs: selector.globs } : {}),
1824
- ...(selector.rvas ? { rvas: selector.rvas } : {}),
1825
- ...(selector.dataSourceIds ? { dataSourceIds: selector.dataSourceIds } : {}),
1826
- ...(selector.objectIds ? { objectIds: selector.objectIds } : {}),
1827
- ...(selector.ranges ? { ranges: selector.ranges } : {}),
1828
- maxTargets: selector.maxTargets
1829
- };
1830
- }
1831
- function globMatch(pattern, value) {
1832
- let source = "^";
1833
- let escaped = false;
1834
- for (const character of pattern) {
1835
- if (escaped) {
1836
- source += escapeRegex(character);
1837
- escaped = false;
1838
- continue;
1839
- }
1840
- if (character === "\\") {
1841
- escaped = true;
1842
- continue;
1843
- }
1844
- if (character === "*")
1845
- source += ".*";
1846
- else if (character === "?")
1847
- source += ".";
1848
- else
1849
- source += escapeRegex(character);
1850
- }
1851
- if (escaped)
1852
- return false;
1853
- return new RegExp(`${source}$`, "u").test(value);
1854
- }
1855
- function escapeRegex(value) { return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); }