@openclaw/plugin-inspector 0.3.24 → 0.3.25

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.
@@ -1,27 +1,22 @@
1
1
  import { rmSync } from "node:fs";
2
2
  import { mkdtemp } from "node:fs/promises";
3
- import { register } from "node:module";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
- import { pathToFileURL } from "node:url";
7
5
  import { captureEntrypoint } from "./inspector.js";
8
- import { createMockSdkPackage } from "./sdk-mock.js";
6
+ import { createMockSdkPackage, installMockSdkLoader } from "./sdk-mock.js";
9
7
  import { runCapturedSyntheticProbes } from "./synthetic-probes.js";
10
8
 
11
9
  export async function runEntrypointSyntheticProbes(entrypoint, options = {}) {
12
- const capture = await captureEntrypointForSyntheticProbes(entrypoint, {
10
+ const captureOptions = {
13
11
  ...options,
14
12
  apiOptions: {
15
13
  ...(options.apiOptions ?? {}),
16
14
  retainHandlers: true,
17
15
  },
18
- });
19
- return runCapturedSyntheticProbes(capture, options);
20
- }
21
-
22
- async function captureEntrypointForSyntheticProbes(entrypoint, options) {
16
+ };
23
17
  if (options.mockSdk !== true) {
24
- return captureEntrypoint(entrypoint, options);
18
+ const capture = await captureEntrypoint(entrypoint, captureOptions);
19
+ return runCapturedSyntheticProbes(capture, options);
25
20
  }
26
21
 
27
22
  const cwd = options.cwd ?? process.cwd();
@@ -29,15 +24,20 @@ async function captureEntrypointForSyntheticProbes(entrypoint, options) {
29
24
  const pluginRoot = path.resolve(cwd, options.pluginRoot ?? path.dirname(resolvedEntrypoint));
30
25
  const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-synthetic-mock-sdk-"));
31
26
  cleanupTempDirOnExit(workspace);
32
- const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
33
- register(pathToFileURL(loaderPath));
34
-
35
- return captureEntrypoint(entrypoint, {
36
- ...options,
37
- cwd,
38
- mockSdk: false,
39
- pluginRoot,
40
- });
27
+ const mockPackage = await createMockSdkPackage(workspace, { pluginRoot });
28
+ const stopLoader = await installMockSdkLoader(mockPackage);
29
+ try {
30
+ const capture = await captureEntrypoint(entrypoint, {
31
+ ...captureOptions,
32
+ cwd,
33
+ mockSdk: false,
34
+ pluginRoot,
35
+ });
36
+ // Retained handlers can require SDK modules lazily during invocation.
37
+ return await runCapturedSyntheticProbes(capture, options);
38
+ } finally {
39
+ stopLoader();
40
+ }
41
41
  }
42
42
 
43
43
  function cleanupTempDirOnExit(dir) {
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { runEntrypointSyntheticProbes, writeArtifacts } from "./advanced.js";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { readBoundedJsonArtifact, writeArtifacts } from "./artifacts.js";
7
+ import { resolveProcessLimits, startOwnedProcess } from "./process-profile.js";
3
8
 
4
9
  const args = process.argv.slice(2);
5
10
 
@@ -26,7 +31,7 @@ async function run(commandArgs) {
26
31
  throw new Error("synthetic probes import plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 in an isolated workspace");
27
32
  }
28
33
 
29
- const results = await runEntrypointSyntheticProbes(entrypoint, {
34
+ const results = await runInChild(entrypoint, {
30
35
  mockSdk,
31
36
  pluginRoot,
32
37
  apiOptions: { retainHandlers: true },
@@ -43,6 +48,78 @@ async function run(commandArgs) {
43
48
  }
44
49
  }
45
50
 
51
+ async function runInChild(entrypoint, options) {
52
+ const limits = resolveProcessLimits({}, "PROBE");
53
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-synthetic-cli-"));
54
+ const outputPath = path.join(workspace, "result.json");
55
+ const controller = new AbortController();
56
+ const cancel = () => controller.abort(new Error("Synthetic probes cancelled"));
57
+ process.once("SIGINT", cancel);
58
+ process.once("SIGTERM", cancel);
59
+ try {
60
+ const runnerPath = fileURLToPath(new URL("./mock-sdk-capture-runner.js", import.meta.url));
61
+ const { result } = startOwnedProcess({
62
+ command: process.execPath,
63
+ args: [
64
+ "--no-warnings",
65
+ ...(options.mockSdk ? ["--preserve-symlinks"] : []),
66
+ runnerPath,
67
+ JSON.stringify({
68
+ ...options, ...limits, entrypoint, outputPath,
69
+ cwd: process.cwd(), syntheticProbes: true,
70
+ }),
71
+ ],
72
+ ...limits,
73
+ signal: controller.signal,
74
+ }, "PROBE");
75
+ const outcome = await result;
76
+ if (outcome.exitCode !== 0 || outcome.outputTruncated) {
77
+ const message = outcome.cancelled ? "Synthetic probes cancelled"
78
+ : outcome.timedOut ? `Synthetic probes timed out after ${limits.timeoutMs}ms`
79
+ : outcome.outputTruncated ? "Synthetic probe child output exceeded its byte limit"
80
+ : outcome.stderr.trim() || outcome.error?.message || "Synthetic probe child failed";
81
+ throw new Error(message);
82
+ }
83
+ controller.signal.throwIfAborted();
84
+ // The report is separate from plugin stdout, including direct fd writes.
85
+ // Only accept a fresh complete artifact after successful child cleanup.
86
+ const results = await readBoundedJsonArtifact(outputPath, limits.maxOutputBytes);
87
+ validateSyntheticReport(results);
88
+ controller.signal.throwIfAborted();
89
+ return results;
90
+ } finally {
91
+ process.removeListener("SIGINT", cancel);
92
+ process.removeListener("SIGTERM", cancel);
93
+ await rm(workspace, { recursive: true, force: true });
94
+ }
95
+ }
96
+
97
+ function validateSyntheticReport(report) {
98
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
99
+ if (!isObject(report) || typeof report.entrypoint !== "string" ||
100
+ !["captured", "no-register-export"].includes(report.status) ||
101
+ !isObject(report.summary) || !Array.isArray(report.results)) {
102
+ throw new Error("Invalid synthetic probe report: expected entrypoint, status, summary, and results");
103
+ }
104
+ const counts = { probeCount: report.results.length, passCount: 0, failCount: 0, blockedCount: 0 };
105
+ for (const row of report.results) {
106
+ if (!isObject(row) || !Number.isSafeInteger(row.captureIndex) || row.captureIndex < 0 ||
107
+ !["kind", "seam", "label"].every((key) => typeof row[key] === "string") ||
108
+ !["pass", "fail", "blocked"].includes(row.status) ||
109
+ (row.status === "fail" && typeof row.error !== "string") ||
110
+ (row.status === "blocked" && typeof row.reason !== "string")) {
111
+ throw new Error("Invalid synthetic probe report: malformed result row");
112
+ }
113
+ counts[`${row.status}Count`] += 1;
114
+ }
115
+ for (const [key, expected] of Object.entries(counts)) {
116
+ if (!Number.isSafeInteger(report.summary[key]) || report.summary[key] < 0 ||
117
+ report.summary[key] !== expected) {
118
+ throw new Error(`Invalid synthetic probe report: invalid or inconsistent ${key}`);
119
+ }
120
+ }
121
+ }
122
+
46
123
  function readFlag(commandArgs, name) {
47
124
  const index = commandArgs.indexOf(name);
48
125
  if (index === -1) {
@@ -1,4 +1,5 @@
1
1
  import { renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
2
+ import { resolveProcessLimits } from "./process-profile.js";
2
3
 
3
4
  export const syntheticRegistrationExecutionProfiles = {
4
5
  createChatChannelPlugin: {
@@ -46,6 +47,11 @@ export const syntheticRegistrationExecutionProfiles = {
46
47
  callableProperties: [],
47
48
  reason: "auto-enable probes are captured as registration metadata before runtime activation checks",
48
49
  },
50
+ registerBoardWidgetContentKind: {
51
+ mode: "metadata-only",
52
+ callableProperties: [],
53
+ reason: "board widget content kinds are captured as registration metadata before source validation or document composition",
54
+ },
49
55
  registerCli: {
50
56
  mode: "direct",
51
57
  callableProperties: ["handler", "run", "execute"],
@@ -126,6 +132,16 @@ export const syntheticRegistrationExecutionProfiles = {
126
132
  callableProperties: [],
127
133
  reason: "hosted media resolvers are captured as registration metadata before media URL resolution",
128
134
  },
135
+ registerMcpServerConnectionResolver: {
136
+ mode: "metadata-only",
137
+ callableProperties: [],
138
+ reason: "MCP server connection resolvers are captured as registration metadata before requester-bound transport resolution",
139
+ },
140
+ registerMemoryPromptPreparation: {
141
+ mode: "metadata-only",
142
+ callableProperties: [],
143
+ reason: "memory prompt preparation callbacks are captured as registration metadata before prompt-runtime execution",
144
+ },
129
145
  registerMemoryPromptSection: {
130
146
  mode: "metadata-only",
131
147
  callableProperties: [],
@@ -275,6 +291,11 @@ export const syntheticRegistrationExecutionProfiles = {
275
291
  callableProperties: [],
276
292
  reason: "text transforms are captured as registration metadata before content mutation execution",
277
293
  },
294
+ registerTranscriptSourceProvider: {
295
+ mode: "metadata-only",
296
+ callableProperties: [],
297
+ reason: "transcript source providers are captured as registration metadata before live capture or transcript import",
298
+ },
278
299
  registerVideoGenerationProvider: {
279
300
  mode: "metadata-only",
280
301
  callableProperties: [],
@@ -300,6 +321,11 @@ export const syntheticRegistrationExecutionProfiles = {
300
321
  callableProperties: [],
301
322
  reason: "widget presenters are captured as registration metadata before presentation runtime execution",
302
323
  },
324
+ registerWorkerProvider: {
325
+ mode: "metadata-only",
326
+ callableProperties: [],
327
+ reason: "worker providers are captured as registration metadata before cloud-worker lifecycle execution",
328
+ },
303
329
  };
304
330
 
305
331
  export const defaultSyntheticHookEvents = {
@@ -483,6 +509,7 @@ export const defaultSyntheticRegistrationProbeInputs = {
483
509
  registerGatewayMethod: {
484
510
  execute: gatewayProbeArgs,
485
511
  handler: gatewayProbeArgs,
512
+ invoke: gatewayProbeArgs,
486
513
  run: gatewayProbeArgs,
487
514
  },
488
515
  registerHttpRoute: {
@@ -597,8 +624,27 @@ export async function writeSyntheticProbePlan(plan, options = {}) {
597
624
  }
598
625
 
599
626
  export async function runCapturedSyntheticProbes(capture, options = {}) {
627
+ options.signal?.throwIfAborted();
600
628
  const hookEvents = options.hookEvents ?? defaultSyntheticHookEvents;
601
629
  const hookContexts = options.hookContexts ?? defaultSyntheticHookContexts;
630
+ const { timeoutMs } = resolveProcessLimits(options, "PROBE");
631
+ const controller = new AbortController();
632
+ const onCancel = () => controller.abort(new Error("Synthetic probes cancelled"));
633
+ options.signal?.addEventListener("abort", onCancel, { once: true });
634
+ if (options.signal?.aborted) onCancel();
635
+ try {
636
+ const result = await runCapturedProbes(capture, {
637
+ ...options, hookEvents, hookContexts, timeoutMs, controller, signal: controller.signal,
638
+ });
639
+ if (options.signal?.aborted) throw controller.signal.reason;
640
+ return result;
641
+ } finally {
642
+ options.signal?.removeEventListener("abort", onCancel);
643
+ }
644
+ }
645
+
646
+ async function runCapturedProbes(capture, options) {
647
+ const { hookEvents, hookContexts, timeoutMs, controller, signal } = options;
602
648
  const captured = capture.captured ?? [];
603
649
  const retained = new Map((capture.retained ?? []).map((item) => [item.captureIndex, item]));
604
650
  const resultsByCaptureIndex = new Map();
@@ -611,6 +657,10 @@ export async function runCapturedSyntheticProbes(capture, options = {}) {
611
657
  );
612
658
 
613
659
  for (const { entry, captureIndex } of executionEntries) {
660
+ if (signal.aborted) {
661
+ resultsByCaptureIndex.set(captureIndex, [blockedResult(entry, captureIndex, signal.reason.message)]);
662
+ continue;
663
+ }
614
664
  const retainedEntry = retained.get(captureIndex);
615
665
  if (!retainedEntry) {
616
666
  resultsByCaptureIndex.set(captureIndex, [blockedResult(entry, captureIndex, "handler retention was not enabled")]);
@@ -618,7 +668,7 @@ export async function runCapturedSyntheticProbes(capture, options = {}) {
618
668
  }
619
669
  if (entry.kind === "hook") {
620
670
  resultsByCaptureIndex.set(captureIndex, [
621
- await runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, hookContexts }),
671
+ await runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, hookContexts, timeoutMs, controller }),
622
672
  ]);
623
673
  continue;
624
674
  }
@@ -719,7 +769,7 @@ function probeBlocker({ hasSyntheticArguments, execution }) {
719
769
  return null;
720
770
  }
721
771
 
722
- async function runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, hookContexts }) {
772
+ async function runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, hookContexts, timeoutMs, controller }) {
723
773
  if (typeof retainedEntry.handler !== "function") {
724
774
  return blockedResult(entry, captureIndex, "captured hook has no callable handler");
725
775
  }
@@ -728,6 +778,8 @@ async function runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, ho
728
778
  kind: "hook",
729
779
  seam: entry.name,
730
780
  label: entry.name,
781
+ timeoutMs,
782
+ controller,
731
783
  invoke: () =>
732
784
  retainedEntry.handler(
733
785
  hookEvents[entry.name] ?? { hook: entry.name },
@@ -746,7 +798,9 @@ async function runRegistrationProbes(entry, retainedEntry, captureIndex, options
746
798
  }
747
799
 
748
800
  const descriptor =
749
- retainedEntry.arguments?.find((value) => value && typeof value === "object") ?? retainedEntry.returnValue;
801
+ entry.name === "registerGatewayMethod" && typeof retainedEntry.arguments?.[0] === "string"
802
+ ? retainedEntry.returnValue
803
+ : retainedEntry.arguments?.find((value) => value && typeof value === "object") ?? retainedEntry.returnValue;
750
804
  if (!descriptor || typeof descriptor !== "object") {
751
805
  return [blockedResult(entry, captureIndex, "captured registration has no object descriptor")];
752
806
  }
@@ -759,17 +813,29 @@ async function runRegistrationProbes(entry, retainedEntry, captureIndex, options
759
813
  return [blockedResult(entry, captureIndex, "captured registration has no supported callable probe")];
760
814
  }
761
815
 
762
- return Promise.all(
763
- invocations.map((invocation) =>
764
- runProbe({
816
+ // The profile owns lifecycle order; finish each callback before starting the next.
817
+ const results = [];
818
+ for (const invocation of invocations) {
819
+ if (options.signal.aborted) {
820
+ results.push({
821
+ ...blockedResult(entry, captureIndex, options.signal.reason.message),
822
+ label: invocation.label,
823
+ });
824
+ continue;
825
+ }
826
+ results.push(
827
+ await runProbe({
765
828
  captureIndex,
766
829
  kind: "registration",
767
830
  seam: entry.name,
768
831
  label: invocation.label,
832
+ timeoutMs: options.timeoutMs,
833
+ controller: options.controller,
769
834
  invoke: invocation.invoke,
770
835
  }),
771
- ),
772
- );
836
+ );
837
+ }
838
+ return results;
773
839
  }
774
840
 
775
841
  function registrationInvocations(registrar, descriptor, returnValue, profile, options) {
@@ -786,20 +852,100 @@ function registrationInvocations(registrar, descriptor, returnValue, profile, op
786
852
  if (typeof callable === "function") {
787
853
  invocations.push({
788
854
  label: `${registrar}.${property}`,
789
- invoke: () => invokeRegistrationCallable(callable, registrar, property, options),
855
+ invoke: () => registrar === "registerGatewayMethod"
856
+ ? invokeGatewayCallable(callable, property, descriptor, options)
857
+ : invokeRegistrationCallable(callable, registrar, property, options),
790
858
  });
859
+ // Gateway aliases describe one method, not independent lifecycle callbacks.
860
+ if (registrar === "registerGatewayMethod") break;
791
861
  }
792
862
  }
793
863
  return invocations;
794
864
  }
795
865
 
796
- function invokeRegistrationCallable(callable, registrar, property, options) {
797
- const event = syntheticRegistrationEvent(registrar, property, options);
866
+ function invokeRegistrationCallable(
867
+ callable, registrar, property, options,
868
+ event = syntheticRegistrationEvent(registrar, property, options),
869
+ ) {
798
870
  const inputFactory = options.registrationProbeInputs?.[registrar]?.[property] ?? defaultSyntheticRegistrationProbeInputs[registrar]?.[property];
799
871
  const args = inputFactory ? inputFactory(event, options) : [event];
800
872
  return callable(...args);
801
873
  }
802
874
 
875
+ async function invokeGatewayCallable(callable, property, descriptor, options) {
876
+ let responded = false;
877
+ let closed = false;
878
+ let resolveResponse;
879
+ const response = new Promise((resolve) => { resolveResponse = resolve; });
880
+ const onAbort = () => {
881
+ closed = true;
882
+ resolveResponse({ error: options.signal.reason });
883
+ };
884
+ options.signal.addEventListener("abort", onAbort, { once: true });
885
+ const respond = (ok, payload, error) => {
886
+ if (closed || responded) return;
887
+ // Snapshot the first emission, including invalid frames. Later responses
888
+ // cannot recover it; logging metadata is not part of the wire envelope.
889
+ responded = true;
890
+ try {
891
+ resolveResponse({ frame: gatewayResponseFrame(ok, payload, error) });
892
+ } catch (error) {
893
+ resolveResponse({ error });
894
+ }
895
+ };
896
+ try {
897
+ options.signal.throwIfAborted();
898
+ const event = {
899
+ ...syntheticRegistrationEvent("registerGatewayMethod", property, options),
900
+ method: descriptor.method ?? descriptor.name,
901
+ respond,
902
+ };
903
+ const result = await invokeRegistrationCallable(callable, "registerGatewayMethod", property, options, event);
904
+ // OpenClaw's plugin registrar adapts returns only without an explicit reply.
905
+ if (!responded && result !== undefined) respond(true, result);
906
+ // A void handler may respond later. The existing probe deadline bounds this
907
+ // observation as well as handler settlement; it owns timeout/cancellation.
908
+ const outcome = await response;
909
+ if (outcome.error) throw outcome.error;
910
+ if (!outcome.frame.ok) {
911
+ throw new Error(`Gateway response error: ${outcome.frame.error?.message ?? "request failed"}`);
912
+ }
913
+ return outcome.frame;
914
+ } finally {
915
+ closed = true;
916
+ options.signal.removeEventListener("abort", onAbort);
917
+ }
918
+ }
919
+
920
+ function gatewayResponseFrame(ok, payload, error) {
921
+ let frame;
922
+ try {
923
+ frame = JSON.parse(JSON.stringify({ type: "res", id: "fixture-request", ok, payload, error }));
924
+ } catch {
925
+ throw new Error("Gateway response is not JSON serializable");
926
+ }
927
+ // ResponseFrameSchema / ErrorShapeSchema in OpenClaw v2026.9.3. Payload and
928
+ // error are optional independently of ok; error codes are nonempty strings.
929
+ const responseError = frame.error;
930
+ if (
931
+ typeof frame.ok !== "boolean" ||
932
+ (responseError !== undefined && (
933
+ !responseError ||
934
+ typeof responseError !== "object" ||
935
+ Array.isArray(responseError) ||
936
+ typeof responseError.code !== "string" || responseError.code.length === 0 ||
937
+ typeof responseError.message !== "string" || responseError.message.length === 0 ||
938
+ (responseError.retryable !== undefined && typeof responseError.retryable !== "boolean") ||
939
+ (responseError.retryAfterMs !== undefined &&
940
+ (!Number.isInteger(responseError.retryAfterMs) || responseError.retryAfterMs < 0)) ||
941
+ Object.keys(responseError).some((key) => !["code", "message", "details", "retryable", "retryAfterMs"].includes(key))
942
+ ))
943
+ ) {
944
+ throw new Error("Gateway response is malformed");
945
+ }
946
+ return frame;
947
+ }
948
+
803
949
  function syntheticRegistrationEvent(registrar, property, options) {
804
950
  const hookEvents = options.hookEvents ?? defaultSyntheticHookEvents;
805
951
  const beforeToolCall = hookEvents.before_tool_call ?? defaultSyntheticHookEvents.before_tool_call;
@@ -822,21 +968,21 @@ function syntheticRegistrationEvent(registrar, property, options) {
822
968
  };
823
969
  }
824
970
 
825
- function toolRunProbeArgs(event) {
971
+ function toolRunProbeArgs(event, options = {}) {
826
972
  return [
827
973
  event.params,
828
974
  {
829
975
  source: event.source,
830
976
  toolName: event.toolName,
831
977
  toolCallId: event.toolCall.id,
832
- signal: new AbortController().signal,
978
+ signal: options.signal ?? new AbortController().signal,
833
979
  logger: console,
834
980
  },
835
981
  ];
836
982
  }
837
983
 
838
- function toolExecuteProbeArgs(event) {
839
- return [event.toolCall.id, event.params, new AbortController().signal, () => undefined];
984
+ function toolExecuteProbeArgs(event, options = {}) {
985
+ return [event.toolCall.id, event.params, options.signal ?? new AbortController().signal, () => undefined];
840
986
  }
841
987
 
842
988
  function httpRouteProbeArgs(event) {
@@ -858,34 +1004,31 @@ function httpRouteProbeArgs(event) {
858
1004
  ];
859
1005
  }
860
1006
 
861
- function commandProbeArgs(event) {
1007
+ function commandProbeArgs(event, options = {}) {
862
1008
  return [
863
1009
  event.input,
864
1010
  {
865
1011
  source: event.source,
866
- signal: new AbortController().signal,
1012
+ signal: options.signal ?? new AbortController().signal,
867
1013
  logger: console,
868
1014
  },
869
1015
  ];
870
1016
  }
871
1017
 
872
- function gatewayProbeArgs(event) {
1018
+ function gatewayProbeArgs(event, options = {}) {
873
1019
  return [
874
1020
  {
875
1021
  ...event,
876
- params: event.params,
877
- body: event.body,
878
- headers: event.headers,
879
- respond: event.respond,
880
- },
881
- {
882
- source: event.source,
883
- logger: console,
1022
+ req: { type: "req", id: "fixture-request", method: event.method ?? "fixture.gateway.method", params: event.params },
1023
+ client: null,
1024
+ isWebchatConnect: () => false,
1025
+ context: { source: event.source, logger: console },
1026
+ signal: options.signal,
884
1027
  },
885
1028
  ];
886
1029
  }
887
1030
 
888
- function channelSendProbeArgs(event) {
1031
+ function channelSendProbeArgs(event, options = {}) {
889
1032
  return [
890
1033
  {
891
1034
  source: event.source,
@@ -896,12 +1039,12 @@ function channelSendProbeArgs(event) {
896
1039
  replyToId: "fixture-reply",
897
1040
  threadId: "fixture-thread",
898
1041
  logger: console,
899
- signal: new AbortController().signal,
1042
+ signal: options.signal ?? new AbortController().signal,
900
1043
  },
901
1044
  ];
902
1045
  }
903
1046
 
904
- function channelReceiveProbeArgs(event) {
1047
+ function channelReceiveProbeArgs(event, options = {}) {
905
1048
  return [
906
1049
  {
907
1050
  source: event.source,
@@ -921,7 +1064,7 @@ function channelReceiveProbeArgs(event) {
921
1064
  to: "fixture-channel",
922
1065
  },
923
1066
  logger: console,
924
- signal: new AbortController().signal,
1067
+ signal: options.signal ?? new AbortController().signal,
925
1068
  },
926
1069
  ];
927
1070
  }
@@ -939,7 +1082,7 @@ function interactiveProbeArgs(event) {
939
1082
  ];
940
1083
  }
941
1084
 
942
- function lifecycleProbeArgs(event) {
1085
+ function lifecycleProbeArgs(event, options = {}) {
943
1086
  return [
944
1087
  {
945
1088
  source: event.source,
@@ -947,7 +1090,7 @@ function lifecycleProbeArgs(event) {
947
1090
  logger: console,
948
1091
  runtime: { env: {}, logger: console },
949
1092
  secrets: { get: async () => null, has: async () => false },
950
- signal: new AbortController().signal,
1093
+ signal: options.signal ?? new AbortController().signal,
951
1094
  },
952
1095
  ];
953
1096
  }
@@ -965,9 +1108,9 @@ function speechProbeArgs(event) {
965
1108
  ];
966
1109
  }
967
1110
 
968
- async function runProbe({ captureIndex, kind, seam, label, invoke }) {
1111
+ async function runProbe({ captureIndex, kind, seam, label, invoke, timeoutMs, controller }) {
969
1112
  try {
970
- const output = await invoke();
1113
+ const output = await invokeWithTimeout(invoke, timeoutMs, controller);
971
1114
  return {
972
1115
  captureIndex,
973
1116
  kind,
@@ -988,6 +1131,30 @@ async function runProbe({ captureIndex, kind, seam, label, invoke }) {
988
1131
  }
989
1132
  }
990
1133
 
1134
+ function invokeWithTimeout(invoke, timeoutMs, controller) {
1135
+ const { signal } = controller;
1136
+ let rejectAborted;
1137
+ const aborted = new Promise((_, reject) => { rejectAborted = reject; });
1138
+ const onAbort = () => rejectAborted(signal.reason);
1139
+ signal.addEventListener("abort", onAbort, { once: true });
1140
+ const timeoutId = setTimeout(() => {
1141
+ controller.abort(new Error(`Synthetic probe timed out after ${timeoutMs}ms`));
1142
+ }, timeoutMs);
1143
+ // Keep observing settlement after abort without starting dependent callbacks.
1144
+ // In-process JavaScript itself is not preempted by this deadline.
1145
+ const result = Promise.resolve().then(() => {
1146
+ signal.throwIfAborted();
1147
+ return invoke();
1148
+ }).then((output) => {
1149
+ signal.throwIfAborted();
1150
+ return output;
1151
+ });
1152
+ return Promise.race([result, aborted]).finally(() => {
1153
+ clearTimeout(timeoutId);
1154
+ signal.removeEventListener("abort", onAbort);
1155
+ });
1156
+ }
1157
+
991
1158
  function blockedResult(entry, captureIndex, reason) {
992
1159
  return {
993
1160
  captureIndex,