@testdriverai/agent 7.11.100-test → 7.11.101-test

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.
@@ -14,7 +14,7 @@ workflow is running inside your org and TestDriver exchanges that proof for your
14
14
  team's key at run time. See the GitHub Actions tab below.
15
15
 
16
16
  For other CI providers (or self-hosted runners without OIDC), fall back to a
17
- stored API key from [console.testdriver.ai/team](https://console.testdriver.ai/team),
17
+ stored API key from [console.testdriver.ai/settings](https://console.testdriver.ai/settings),
18
18
  added as a `TD_API_KEY` secret in your CI provider's settings.
19
19
 
20
20
  <Note>
@@ -17,7 +17,7 @@ const testdriver = new TestDriver(apiKey, options)
17
17
  ### Parameters
18
18
 
19
19
  <ParamField path="apiKey" type="string" required>
20
- Your TestDriver API key from the [dashboard](https://console.testdriver.ai/team)
20
+ Your TestDriver API key from the [dashboard](https://console.testdriver.ai/settings)
21
21
  </ParamField>
22
22
 
23
23
  <ParamField path="options" type="object">
@@ -116,7 +116,7 @@ TestDriver makes it easy to write automated computer-use tests for web browsers,
116
116
  `init` detects clients already present in your project and pre-selects them in the picker. Re-running `init` is safe — it merges the TestDriver entry into existing config without overwriting your other servers.
117
117
  </Info>
118
118
 
119
- You'll need a TestDriver API key. Create one at [console.testdriver.ai/team](https://console.testdriver.ai/team) and `init` will save it to `.env` as `TD_API_KEY`.
119
+ You'll need a TestDriver API key. Create one at [console.testdriver.ai/settings](https://console.testdriver.ai/settings) and `init` will save it to `.env` as `TD_API_KEY`.
120
120
 
121
121
  ### The agent
122
122
 
@@ -261,7 +261,7 @@ TestDriver makes it easy to write automated computer-use tests for web browsers,
261
261
  {
262
262
  "type": "promptString",
263
263
  "id": "testdriver-api-key",
264
- "description": "TestDriver API Key From https://console.testdriver.ai/team",
264
+ "description": "TestDriver API Key From https://console.testdriver.ai/settings",
265
265
  "password": true
266
266
  }
267
267
  ]
@@ -357,7 +357,7 @@ TestDriver makes it easy to write automated computer-use tests for web browsers,
357
357
  <Card
358
358
  title="Get an API Key"
359
359
  icon="user-plus"
360
- href="https://console.testdriver.ai/team"
360
+ href="https://console.testdriver.ai/settings"
361
361
  arrow
362
362
  horizontal
363
363
  >
@@ -458,18 +458,20 @@
458
458
  };
459
459
  }
460
460
 
461
- // Responsive: scale and center the overlay to fit the window
461
+ // Responsive: scale and center the overlay to fit the window. No 1x cap —
462
+ // the embedder sizes the iframe to the sandbox's aspect ratio, so the
463
+ // overlay should scale up to fill it rather than sit letterboxed at 1x.
462
464
  const resizeOverlay = () => {
463
465
  const ww = window.innerWidth;
464
466
  const wh = window.innerHeight;
465
- const scale = Math.min(ww / overlayWidth, wh / overlayHeight, 1);
467
+ const scale = Math.min(ww / overlayWidth, wh / overlayHeight);
466
468
  const sw = overlayWidth * scale;
467
469
  const sh = overlayHeight * scale;
468
470
 
469
471
  overlay.style.width = overlayWidth + "px";
470
472
  overlay.style.height = overlayHeight + "px";
471
473
  overlay.style.position = "absolute";
472
- overlay.style.transform = scale < 1 ? `scale(${scale})` : "none";
474
+ overlay.style.transform = `scale(${scale})`;
473
475
  overlay.style.left = `${(ww - sw) / 2}px`;
474
476
  overlay.style.top = `${(wh - sh) / 2}px`;
475
477
  };
package/docs/docs.json CHANGED
@@ -17,9 +17,45 @@
17
17
  "version": "v7",
18
18
  "groups": [
19
19
  {
20
- "group": "Overview",
20
+ "group": "Quick Start",
21
21
  "pages": [
22
+ "/v7/what-is-testdriver",
23
+ "/v7/hosted",
24
+ "/v7/agent",
22
25
  "/v7/quickstart",
26
+ "/changelog"
27
+ ]
28
+ },
29
+ {
30
+ "group": "Tutorial",
31
+ "pages": [
32
+ "/v7/provision",
33
+ "/v7/screenshots",
34
+ "/v7/elements",
35
+ "/v7/performing-actions",
36
+ "/v7/making-assertions",
37
+ "/v7/generating-tests"
38
+ ]
39
+ },
40
+ {
41
+ "group": "Guides",
42
+ "pages": [
43
+ "/v7/copilot/running-tests",
44
+ "/v7/variables",
45
+ "/v7/secrets",
46
+ "/v7/reusable-code",
47
+ {
48
+ "group": "Self-host TestDriver",
49
+ "icon": "server",
50
+ "pages": [
51
+ "/v7/self-hosted",
52
+ "/v7/aws-setup"
53
+ ]
54
+ },
55
+ "/v7/ci-cd",
56
+ "/v7/caching",
57
+ "/v7/debugging-with-screenshots",
58
+ "/v7/copilot/auto-healing",
23
59
  {
24
60
  "group": "Examples",
25
61
  "icon": "code",
@@ -38,77 +74,50 @@
38
74
  "/v7/examples/scroll",
39
75
  "/v7/examples/type"
40
76
  ]
41
- },
42
- "/v7/what-is-testdriver",
43
- "/v7/agent",
44
- {
45
- "group": "Deployment",
46
- "icon": "server",
47
- "pages": [
48
- "/v7/hosted",
49
- "/v7/self-hosted"
50
- ]
51
- },
52
- "/changelog"
53
- ]
54
- },
55
- {
56
- "group": "Guide",
57
- "pages": [
58
- "/v7/generating-tests",
59
- "/v7/caching",
60
- "/v7/copilot/running-tests",
61
- "/v7/making-assertions",
62
- "/v7/performing-actions",
63
- "/v7/debugging-with-screenshots",
64
- "/v7/copilot/auto-healing",
65
- "/v7/ci-cd",
66
- "/v7/test-results-json"
77
+ }
67
78
  ]
68
79
  },
69
80
  {
70
- "group": "Scaling",
81
+ "group": "Reference",
71
82
  "pages": [
72
- "/v7/variables",
73
- "/v7/secrets",
74
- "/v7/reusable-code",
75
83
  {
76
- "group": "Self-Hosting",
77
- "icon": "server",
84
+ "group": "Commands",
85
+ "icon": "terminal",
78
86
  "pages": [
79
- "/v7/aws-setup"
87
+ "/v7/assert",
88
+ "/v7/captcha",
89
+ "/v7/click",
90
+ "/v7/double-click",
91
+ "/v7/exec",
92
+ "/v7/extract",
93
+ "/v7/find",
94
+ "/v7/focus-application",
95
+ "/v7/hover",
96
+ "/v7/mouse-down",
97
+ "/v7/mouse-up",
98
+ "/v7/parse",
99
+ "/v7/press-keys",
100
+ "/v7/right-click",
101
+ "/v7/screenshot",
102
+ "/v7/scroll",
103
+ "/v7/type",
104
+ "/v7/wait"
80
105
  ]
81
- }
82
- ]
83
- },
84
- {
85
- "group": "Actions",
86
- "pages": [
87
- "/v7/assert",
88
- "/v7/captcha",
89
- "/v7/click",
90
- "/v7/double-click",
91
- "/v7/exec",
92
- "/v7/extract",
93
- "/v7/find",
94
- "/v7/focus-application",
95
- "/v7/hover",
96
- "/v7/mouse-down",
97
- "/v7/mouse-up",
98
- "/v7/parse",
99
- "/v7/press-keys",
100
- "/v7/right-click",
101
- "/v7/screenshot",
102
- "/v7/scroll",
103
- "/v7/type"
104
- ]
105
- },
106
- {
107
- "group": "SDK Reference",
108
- "pages": [
109
- "/v7/client",
110
- "/v7/elements",
111
- "/v7/dashcam"
106
+ },
107
+ {
108
+ "group": "Configuration",
109
+ "icon": "sliders",
110
+ "pages": [
111
+ "/v7/customizing-devices",
112
+ "/v7/client",
113
+ "/v7/cache",
114
+ "/v7/redraw",
115
+ "/v7/dashcam",
116
+ "/v7/events",
117
+ "/v7/errors"
118
+ ]
119
+ },
120
+ "/v7/test-results-json"
112
121
  ]
113
122
  }
114
123
  ]
package/docs/v7/agent.mdx CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "The TestDriver Agent"
3
- sidebarTitle: "Agent"
3
+ sidebarTitle: "GitHub App"
4
4
  description: "How the TestDriver agent behaves on GitHub issues, pull requests, and @mentions"
5
5
  icon: "robot"
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "AWS Setup Guide"
3
- sidebarTitle: "AWS Setup Guide"
3
+ sidebarTitle: "AWS setup"
4
4
  description: "Deploy TestDriver on your AWS infrastructure using CloudFormation"
5
5
  icon: "aws"
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Learn"
3
- sidebarTitle: "Learn"
3
+ sidebarTitle: "Cache test runs"
4
4
  description: "How TestDriver learns your app and caches what it discovers for instant, deterministic replays"
5
5
  icon: "brain"
6
6
  ---
package/docs/v7/ci-cd.mdx CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Integrating into CI/CD"
3
- sidebarTitle: "Integrating into CI/CD"
3
+ sidebarTitle: "Run tests in CI"
4
4
  description: "Run TestDriver tests in CI/CD with parallel execution and cross-platform support"
5
5
  icon: "code-branch"
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Prevent"
3
- sidebarTitle: "Prevent"
3
+ sidebarTitle: "Auto-heal tests"
4
4
  description: "Catch regressions automatically with CI, auto-healing, and GitHub integration"
5
5
  icon: "shield-check"
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Run"
3
- sidebarTitle: "Run"
3
+ sidebarTitle: "Run tests locally"
4
4
  description: "Set up your environment and run TestDriver tests locally and in CI"
5
5
  icon: "play"
6
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: "Customizing Devices"
3
+ sidebarTitle: "Options"
3
4
  description: "Configure TestDriver sandbox options and environment settings"
4
5
  icon: "computer"
5
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Debug"
3
- sidebarTitle: "Debug"
3
+ sidebarTitle: "Debug failing tests"
4
4
  description: "Diagnose failing tests with screenshots, replays, and logs"
5
5
  icon: "bug"
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Element Finding"
3
- sidebarTitle: "Elements"
3
+ sidebarTitle: "Find elements"
4
4
  description: "Locate and interact with UI elements using AI"
5
5
  icon: "mouse-pointer"
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Explore"
3
- sidebarTitle: "Explore"
3
+ sidebarTitle: "Put it together"
4
4
  description: "Generate tests by exploring your app with the AI vision agent"
5
5
  icon: "compass"
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Hosted"
3
- sidebarTitle: "Hosted"
3
+ sidebarTitle: "TestDriver Console"
4
4
  description: "The fastest way to get started with TestDriver. Just set your API key and start testing."
5
5
  icon: "cloud"
6
6
  mode: "wide"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Adapt"
3
- sidebarTitle: "Adapt"
3
+ sidebarTitle: "Interact"
4
4
  description: "Perform actions and handle dynamic, async UI so tests adapt to change"
5
5
  icon: "arrows-rotate"
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Provision"
3
- sidebarTitle: "Provision"
3
+ sidebarTitle: "Set up a sandbox"
4
4
  description: "Launch browsers, desktop apps, and extensions in your sandbox"
5
5
  icon: "rocket"
6
6
  mode: "wide"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Quick Start"
3
- sidebarTitle: "Quickstart"
3
+ sidebarTitle: "CLI"
4
4
  description: "Run your first computer-use test in minutes."
5
5
  icon: "rocket"
6
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: "Reusable Code Snippets"
3
+ sidebarTitle: "Use code snippets"
3
4
  description: "Build maintainable test suites with reusable code patterns"
4
5
  icon: "recycle"
5
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Screenshots"
3
- sidebarTitle: "Screenshots"
3
+ sidebarTitle: "Look at the screen"
4
4
  description: "Capture and manage screenshots during test execution"
5
5
  icon: "camera"
6
6
  mode: "wide"
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: "Using Secrets"
3
+ sidebarTitle: "Use secrets"
3
4
  description: "Securely manage passwords and sensitive data in your tests"
4
5
  icon: "key"
5
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Self-Hosted (Enterprise)"
3
- sidebarTitle: "Self-Hosted"
3
+ sidebarTitle: "Overview"
4
4
  description: "Our enterprise solution with unlimited test execution, assisted setup, and dedicated support."
5
5
  icon: "server"
6
6
  mode: "wide"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Test Result JSON"
3
- sidebarTitle: "Test Result JSON"
3
+ sidebarTitle: "Test format"
4
4
  description: "Per-test JSON result files with metadata, versions, and infrastructure details"
5
5
  icon: "file-code"
6
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: "Using Variables"
3
+ sidebarTitle: "Use variables"
3
4
  description: "Use dynamic data and secure secrets in your tests"
4
5
  icon: "square-root-variable"
5
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: "What is TestDriver?"
3
+ sidebarTitle: "What is TestDriver?"
3
4
  description: "Reliably test your most difficult user flows"
4
5
  icon: "circle-info"
5
6
  ---
@@ -295,6 +295,7 @@ export const pluginState = {
295
295
  detectedPlatform: null,
296
296
  pendingTestCaseRecords: new Set(),
297
297
  ciProvider: null,
298
+ isCI: false,
298
299
  gitInfo: {},
299
300
  apiKey: null,
300
301
  apiRoot: null,
@@ -773,6 +774,7 @@ export default function testDriverPlugin(options = {}) {
773
774
  process.env.TD_API_ROOT ||
774
775
  channelConfig.channels[channelConfig.active];
775
776
  pluginState.ciProvider = detectCI();
777
+ pluginState.isCI = detectIsCI();
776
778
  pluginState.gitInfo = getGitInfo();
777
779
 
778
780
  // Store TestDriver-specific options (excluding plugin-specific ones)
@@ -788,6 +790,7 @@ export default function testDriverPlugin(options = {}) {
788
790
  logger.debug("API key from options:", !!options.apiKey);
789
791
  logger.debug("API key from env (at config time):", !!process.env.TD_API_KEY);
790
792
  logger.debug("CI Provider:", pluginState.ciProvider || "none");
793
+ logger.debug("Is CI:", pluginState.isCI);
791
794
  if (Object.keys(testDriverOptions).length > 0) {
792
795
  logger.debug("Global TestDriver options:", testDriverOptions);
793
796
  }
@@ -882,6 +885,7 @@ class TestDriverReporter {
882
885
  if (pluginState.ciProvider) {
883
886
  testRunData.ciProvider = pluginState.ciProvider;
884
887
  }
888
+ testRunData.isCI = pluginState.isCI;
885
889
 
886
890
  // Platform will be set from the first test result file
887
891
  // Default to linux if no tests write platform info
@@ -1471,6 +1475,17 @@ function detectCI() {
1471
1475
  return null;
1472
1476
  }
1473
1477
 
1478
+ // Broader than detectCI(): virtually every CI provider exports CI=true, so this
1479
+ // still reports true on providers we don't name (Azure, TeamCity, Woodpecker,
1480
+ // self-hosted runners...). Vitest and other tools also set CI=true themselves
1481
+ // when they detect one, so this is the value we trust for "was this run in CI".
1482
+ function detectIsCI() {
1483
+ if (detectCI()) return true;
1484
+ const ci = process.env.CI;
1485
+ if (!ci) return false;
1486
+ return ci !== "false" && ci !== "0";
1487
+ }
1488
+
1474
1489
  function getGitInfo() {
1475
1490
  const info = {};
1476
1491
 
@@ -83,6 +83,18 @@ export interface CoreContext {
83
83
  sdkGeneration: number;
84
84
  lastScreenshotBase64: string | null;
85
85
  reconnectResolver: ReconnectResolver | null;
86
+ /** Notified once a sandbox is proven gone, so durable adapters drop their handle. */
87
+ onRecoveryFailed: RecoveryFailedHook | null;
88
+ /**
89
+ * Sandboxes this context has already failed to reconnect to.
90
+ *
91
+ * A dead sandbox never comes back, but nothing used to remember that: the
92
+ * durable handle still named it, so every following action re-entered recovery,
93
+ * and each attempt bumped {@link sdkGeneration} before failing. Those bumps are
94
+ * what turned one expiry into the SESSION_SUPERSEDED loop — a genuine
95
+ * `session_start` racing a queue of doomed rebuilds could never win the publish.
96
+ */
97
+ deadSandboxIds: Set<string>;
86
98
  /** In-flight reconnect, so concurrent actions in one context share one rebuild. */
87
99
  reconnecting: Promise<void> | null;
88
100
  /** Stored element instances from `find`/`findall`, addressable by ref. */
@@ -147,11 +159,20 @@ export declare function getSessionTimeRemaining(sessionId: string): number;
147
159
  * so a stale closure can't outlive the process it was bound to.
148
160
  */
149
161
  export type ReconnectResolver = () => Promise<ReconnectParams | null>;
162
+ /**
163
+ * Called once mcp-core has proven a sandbox is unreachable, so the durable owner
164
+ * can drop its handle. Without it the two stores disagree: mcp-core knows the
165
+ * sandbox is dead while eve's durable slot still names it, so the resolver keeps
166
+ * handing back the same corpse and every action re-enters recovery.
167
+ */
168
+ export type RecoveryFailedHook = (sandboxId: string) => void;
150
169
  /**
151
170
  * Register (or clear, with `null`) the recovery hook. Durable adapters call this
152
171
  * at the start of each step with a resolver bound to the current tool context.
153
172
  */
154
173
  export declare function setReconnectResolver(resolver: ReconnectResolver | null): void;
174
+ /** Register (or clear) the callback fired when a sandbox is proven unreachable. */
175
+ export declare function setRecoveryFailedHook(hook: RecoveryFailedHook | null): void;
155
176
  /** Expose internals the adapters legitimately need (read-only intent). */
156
177
  export declare function getSdk(): any;
157
178
  export declare function getLastScreenshotBase64(): string | null;
@@ -204,6 +225,14 @@ export declare function clearSession(): void;
204
225
  export interface SessionStartHooks {
205
226
  /** Called before a long await; return a stop fn. Lets adapters heartbeat. */
206
227
  onProgress?: (message: string) => void;
228
+ /**
229
+ * Fired once the sandbox is connected and published to the context, BEFORE
230
+ * provisioning starts. Provisioning is the slow part (~20s+), but the live
231
+ * VNC stream is already watchable at this point — this is the hook an adapter
232
+ * uses to surface the viewer URL early instead of at the end of the call.
233
+ * Runs inside the context, so `getSdk()`/`getInstance()` are valid here.
234
+ */
235
+ onSandboxLive?: () => void;
207
236
  }
208
237
  /**
209
238
  * Start a session and provision a sandbox. The adapter is responsible for
@@ -42,6 +42,8 @@ export function createCoreContext() {
42
42
  sdkGeneration: 0,
43
43
  lastScreenshotBase64: null,
44
44
  reconnectResolver: null,
45
+ onRecoveryFailed: null,
46
+ deadSandboxIds: new Set(),
45
47
  reconnecting: null,
46
48
  elementRefs: new Map(),
47
49
  // Reuse the shared SessionManager for the global context so that consumers
@@ -100,6 +102,10 @@ export function getSessionTimeRemaining(sessionId) {
100
102
  export function setReconnectResolver(resolver) {
101
103
  ctx().reconnectResolver = resolver;
102
104
  }
105
+ /** Register (or clear) the callback fired when a sandbox is proven unreachable. */
106
+ export function setRecoveryFailedHook(hook) {
107
+ ctx().onRecoveryFailed = hook;
108
+ }
103
109
  /** Expose internals the adapters legitimately need (read-only intent). */
104
110
  export function getSdk() {
105
111
  return ctx().sdk;
@@ -269,14 +275,27 @@ async function publishSdk(c, sdk, generation) {
269
275
  c.sdk = sdk;
270
276
  return true;
271
277
  }
272
- /** Result for a `sessionStart` that lost the epoch race to a newer session_start. */
273
- function supersededResult() {
278
+ /**
279
+ * Result for a `sessionStart` that lost the epoch race to a newer flow.
280
+ *
281
+ * The advice has to depend on what actually survived. A supersede does NOT imply
282
+ * a healthy winner: the flow that bumped the epoch past us may itself have failed
283
+ * (the common case is a recovery rebuild of an already-dead sandbox). Telling the
284
+ * agent to "use the active session" when there is none sent it into the
285
+ * session_status → session_end → session_start loop this guard exists to prevent.
286
+ */
287
+ function supersededResult(c) {
288
+ const current = c.sessions.getCurrentSession();
289
+ const live = sdkIsConnected(c.sdk) && !!current && c.sessions.isSessionValid(current.sessionId);
274
290
  return {
275
291
  ok: false,
276
- text: "This session_start was superseded by a newer one that is already connected. " +
277
- "The sandbox this call provisioned has been released. Use the active session, " +
278
- "or call session_end and then session_start if you need a fresh sandbox.",
279
- data: { action: "session_start", error: "SESSION_SUPERSEDED" },
292
+ text: live
293
+ ? "This session_start was superseded by a newer one that is already connected. " +
294
+ "The sandbox this call provisioned has been released. Use the active session, " +
295
+ "or call session_end and then session_start if you need a fresh sandbox."
296
+ : "This session_start raced a concurrent reconnect and neither connection survived. " +
297
+ "No sandbox is held now and no cleanup is needed — call session_start once more.",
298
+ data: { action: "session_start", error: "SESSION_SUPERSEDED", hasActiveSession: live },
280
299
  };
281
300
  }
282
301
  /**
@@ -298,6 +317,12 @@ async function tryRecoverSession() {
298
317
  const params = await resolver();
299
318
  if (!params?.sandboxId)
300
319
  return; // nothing provisioned this session
320
+ // Already proven gone — don't rebuild toward a corpse. Recovering costs an
321
+ // epoch bump, and repeating it is what starves a concurrent session_start
322
+ // of its publish. Report it as NO_SESSION so the caller re-provisions.
323
+ if (c.deadSandboxIds.has(params.sandboxId)) {
324
+ throw new NoActiveSessionError("NO_SESSION", "The previous sandbox is gone and no new one has been started. Call session_start to create one.");
325
+ }
301
326
  // Close any half-dead socket on the outgoing SDK before replacing it, so a
302
327
  // parked-then-rebuilt session doesn't leak an orphaned Ably connection.
303
328
  try {
@@ -306,7 +331,13 @@ async function tryRecoverSession() {
306
331
  catch {
307
332
  /* best effort — reconnectSession installs a fresh SDK regardless */
308
333
  }
309
- await reconnectSession(params);
334
+ try {
335
+ await reconnectSession(params);
336
+ }
337
+ catch (err) {
338
+ markSandboxDead(c, params.sandboxId);
339
+ throw err;
340
+ }
310
341
  })().finally(() => {
311
342
  c.reconnecting = null;
312
343
  });
@@ -323,6 +354,22 @@ async function tryRecoverSession() {
323
354
  const session = c.sessions.getCurrentSession();
324
355
  return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
325
356
  }
357
+ /**
358
+ * Record that `sandboxId` is unreachable and tell the durable owner to forget it.
359
+ *
360
+ * Both halves matter. The local set stops *this* process from retrying a rebuild
361
+ * it already knows will fail; the hook stops the durable store from handing the
362
+ * same id back after a recycle, which would defeat the set.
363
+ */
364
+ function markSandboxDead(c, sandboxId) {
365
+ c.deadSandboxIds.add(sandboxId);
366
+ try {
367
+ c.onRecoveryFailed?.(sandboxId);
368
+ }
369
+ catch {
370
+ /* best effort — the local set alone still breaks the retry loop */
371
+ }
372
+ }
326
373
  /**
327
374
  * Validate the active session and auto-extend it (active use keeps it alive),
328
375
  * mirroring the MCP server's `requireActiveSession`. When the in-process
@@ -393,6 +440,15 @@ async function captureScreen(sdk = ctx().sdk) {
393
440
  export async function sessionStart(params, resolved, hooks = {}) {
394
441
  const c = ctx();
395
442
  const progress = hooks.onProgress ?? (() => { });
443
+ // Best-effort: an adapter's notification must never fail the provision.
444
+ const sandboxLive = () => {
445
+ try {
446
+ hooks.onSandboxLive?.();
447
+ }
448
+ catch {
449
+ /* ignore */
450
+ }
451
+ };
396
452
  // Validate required fields for specific provision types (unless reconnecting).
397
453
  if (!params.sandboxId) {
398
454
  if (params.type === "installer" && !params.installerUrl) {
@@ -457,7 +513,8 @@ export async function sessionStart(params, resolved, hooks = {}) {
457
513
  requireSandbox: true,
458
514
  });
459
515
  if (!(await publishSdk(c, sdk, generation)))
460
- return supersededResult();
516
+ return supersededResult(c);
517
+ sandboxLive();
461
518
  const instance = sdk.getInstance();
462
519
  const newSession = c.sessions.createSession({
463
520
  os: resolved.os,
@@ -480,7 +537,8 @@ export async function sessionStart(params, resolved, hooks = {}) {
480
537
  progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
481
538
  await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
482
539
  if (!(await publishSdk(c, sdk, generation)))
483
- return supersededResult();
540
+ return supersededResult(c);
541
+ sandboxLive();
484
542
  const instance = sdk.getInstance();
485
543
  const newSession = c.sessions.createSession({
486
544
  os: resolved.os,
@@ -589,9 +647,13 @@ export async function reconnectSession(params) {
589
647
  // a loser never leaves a stray "initializing" session current.
590
648
  if (!(await publishSdk(c, sdk, generation))) {
591
649
  const current = c.sessions.getCurrentSession();
592
- if (current && c.sessions.isSessionValid(current.sessionId))
650
+ // Require a *connected* winner, not merely a current session: the flow that
651
+ // took the epoch may still be mid-`connect()`, and returning its id here told
652
+ // the caller a session was ready that no action could then use.
653
+ if (sdkIsConnected(c.sdk) && current && c.sessions.isSessionValid(current.sessionId)) {
593
654
  return current.sessionId;
594
- throw new NoActiveSessionError("SESSION_EXPIRED", "The sandbox connection was superseded and no active session remains. Call session_start again to create a new sandbox session.");
655
+ }
656
+ throw new NoActiveSessionError("NO_SESSION", "The sandbox connection was superseded and no active session remains. Call session_start again to create a new sandbox session.");
595
657
  }
596
658
  const session = c.sessions.createSession({
597
659
  os: params.os,
@@ -627,6 +689,11 @@ export async function ensureActiveSession(params) {
627
689
  if (!params?.sandboxId) {
628
690
  return false;
629
691
  }
692
+ // Proven gone already — skip the doomed rebuild (and the epoch bump it costs).
693
+ if (c.deadSandboxIds.has(params.sandboxId)) {
694
+ c.sdk = null;
695
+ throw new NoActiveSessionError("NO_SESSION", "The previous sandbox is gone and no new one has been started. Call session_start to create one.", params.sandboxId);
696
+ }
630
697
  try {
631
698
  await reconnectSession(params);
632
699
  return true;
@@ -636,6 +703,7 @@ export async function ensureActiveSession(params) {
636
703
  // id is stale). Surface a SESSION_EXPIRED so the adapter's mapper tells the
637
704
  // agent to call session_start again, instead of a raw connect error.
638
705
  c.sdk = null;
706
+ markSandboxDead(c, params.sandboxId);
639
707
  throw new NoActiveSessionError("SESSION_EXPIRED", `Could not reconnect to sandbox ${params.sandboxId}: ${err instanceof Error ? err.message : String(err)}. The sandbox has expired — call session_start again to create a new one.`, params.sandboxId);
640
708
  }
641
709
  }
@@ -12,8 +12,18 @@
12
12
  * could not serve a single command.
13
13
  */
14
14
 
15
- import { describe, it, expect } from "vitest";
16
- import { clearSession, createIsolatedContext, hasLiveSession, runInContext, type CoreContext } from "./actions.js";
15
+ import { describe, it, expect, vi } from "vitest";
16
+ import {
17
+ clearSession,
18
+ createIsolatedContext,
19
+ ensureActiveSession,
20
+ hasLiveSession,
21
+ NoActiveSessionError,
22
+ runInContext,
23
+ setRecoveryFailedHook,
24
+ type CoreContext,
25
+ type ReconnectParams,
26
+ } from "./actions.js";
17
27
 
18
28
  // ---------------------------------------------------------------------------
19
29
  // Helpers
@@ -102,6 +112,56 @@ describe("clearSession", () => {
102
112
  });
103
113
  });
104
114
 
115
+ describe("ensureActiveSession with a dead sandbox", () => {
116
+ /** Reconnect params naming a sandbox the server no longer has. */
117
+ const params = (): ReconnectParams => ({
118
+ sandboxId: "sb-dead",
119
+ os: "linux",
120
+ keepAlive: 60_000,
121
+ apiKey: "k",
122
+ });
123
+
124
+ it("does not bump the epoch again once the sandbox is known dead", async () => {
125
+ // The loop's engine: every retry against a corpse used to bump the epoch, so
126
+ // a real session_start racing them could never win its publish.
127
+ const c = createIsolatedContext();
128
+ c.deadSandboxIds.add("sb-dead");
129
+ const before = c.sdkGeneration;
130
+
131
+ await expect(
132
+ runInContext(c, () => ensureActiveSession(params())),
133
+ ).rejects.toBeInstanceOf(NoActiveSessionError);
134
+
135
+ expect(c.sdkGeneration).toBe(before);
136
+ });
137
+
138
+ it("reports NO_SESSION, not SESSION_EXPIRED, so the agent just re-provisions", async () => {
139
+ const c = createIsolatedContext();
140
+ c.deadSandboxIds.add("sb-dead");
141
+ await runInContext(c, () => ensureActiveSession(params())).catch((err) => {
142
+ expect(err).toBeInstanceOf(NoActiveSessionError);
143
+ expect((err as NoActiveSessionError).code).toBe("NO_SESSION");
144
+ });
145
+ });
146
+
147
+ it("tells the durable owner to forget a sandbox it just proved unreachable", async () => {
148
+ // Without this the durable store keeps handing back the dead id after a
149
+ // recycle wipes deadSandboxIds, and recovery loops again.
150
+ const c = createIsolatedContext();
151
+ const forgotten = vi.fn();
152
+
153
+ await runInContext(c, async () => {
154
+ setRecoveryFailedHook(forgotten);
155
+ // No SDK is loadable in unit tests, so reconnectSession throws — which is
156
+ // exactly the "sandbox is gone" path.
157
+ await ensureActiveSession(params()).catch(() => {});
158
+ });
159
+
160
+ expect(forgotten).toHaveBeenCalledWith("sb-dead");
161
+ expect(c.deadSandboxIds.has("sb-dead")).toBe(true);
162
+ });
163
+ });
164
+
105
165
  describe("createIsolatedContext", () => {
106
166
  it("starts at generation 0 so the first build wins its epoch", () => {
107
167
  expect(createIsolatedContext().sdkGeneration).toBe(0);
@@ -106,6 +106,18 @@ export interface CoreContext {
106
106
  sdkGeneration: number;
107
107
  lastScreenshotBase64: string | null;
108
108
  reconnectResolver: ReconnectResolver | null;
109
+ /** Notified once a sandbox is proven gone, so durable adapters drop their handle. */
110
+ onRecoveryFailed: RecoveryFailedHook | null;
111
+ /**
112
+ * Sandboxes this context has already failed to reconnect to.
113
+ *
114
+ * A dead sandbox never comes back, but nothing used to remember that: the
115
+ * durable handle still named it, so every following action re-entered recovery,
116
+ * and each attempt bumped {@link sdkGeneration} before failing. Those bumps are
117
+ * what turned one expiry into the SESSION_SUPERSEDED loop — a genuine
118
+ * `session_start` racing a queue of doomed rebuilds could never win the publish.
119
+ */
120
+ deadSandboxIds: Set<string>;
109
121
  /** In-flight reconnect, so concurrent actions in one context share one rebuild. */
110
122
  reconnecting: Promise<void> | null;
111
123
  /** Stored element instances from `find`/`findall`, addressable by ref. */
@@ -131,6 +143,8 @@ export function createCoreContext(): CoreContext {
131
143
  sdkGeneration: 0,
132
144
  lastScreenshotBase64: null,
133
145
  reconnectResolver: null,
146
+ onRecoveryFailed: null,
147
+ deadSandboxIds: new Set(),
134
148
  reconnecting: null,
135
149
  elementRefs: new Map(),
136
150
  // Reuse the shared SessionManager for the global context so that consumers
@@ -213,6 +227,14 @@ export function getSessionTimeRemaining(sessionId: string): number {
213
227
  */
214
228
  export type ReconnectResolver = () => Promise<ReconnectParams | null>;
215
229
 
230
+ /**
231
+ * Called once mcp-core has proven a sandbox is unreachable, so the durable owner
232
+ * can drop its handle. Without it the two stores disagree: mcp-core knows the
233
+ * sandbox is dead while eve's durable slot still names it, so the resolver keeps
234
+ * handing back the same corpse and every action re-enters recovery.
235
+ */
236
+ export type RecoveryFailedHook = (sandboxId: string) => void;
237
+
216
238
  /**
217
239
  * Register (or clear, with `null`) the recovery hook. Durable adapters call this
218
240
  * at the start of each step with a resolver bound to the current tool context.
@@ -221,6 +243,11 @@ export function setReconnectResolver(resolver: ReconnectResolver | null): void {
221
243
  ctx().reconnectResolver = resolver;
222
244
  }
223
245
 
246
+ /** Register (or clear) the callback fired when a sandbox is proven unreachable. */
247
+ export function setRecoveryFailedHook(hook: RecoveryFailedHook | null): void {
248
+ ctx().onRecoveryFailed = hook;
249
+ }
250
+
224
251
  /** Expose internals the adapters legitimately need (read-only intent). */
225
252
  export function getSdk(): any {
226
253
  return ctx().sdk;
@@ -395,15 +422,27 @@ async function publishSdk(c: CoreContext, sdk: any, generation: number): Promise
395
422
  return true;
396
423
  }
397
424
 
398
- /** Result for a `sessionStart` that lost the epoch race to a newer session_start. */
399
- function supersededResult(): ActionResult {
425
+ /**
426
+ * Result for a `sessionStart` that lost the epoch race to a newer flow.
427
+ *
428
+ * The advice has to depend on what actually survived. A supersede does NOT imply
429
+ * a healthy winner: the flow that bumped the epoch past us may itself have failed
430
+ * (the common case is a recovery rebuild of an already-dead sandbox). Telling the
431
+ * agent to "use the active session" when there is none sent it into the
432
+ * session_status → session_end → session_start loop this guard exists to prevent.
433
+ */
434
+ function supersededResult(c: CoreContext): ActionResult {
435
+ const current = c.sessions.getCurrentSession();
436
+ const live = sdkIsConnected(c.sdk) && !!current && c.sessions.isSessionValid(current.sessionId);
400
437
  return {
401
438
  ok: false,
402
- text:
403
- "This session_start was superseded by a newer one that is already connected. " +
404
- "The sandbox this call provisioned has been released. Use the active session, " +
405
- "or call session_end and then session_start if you need a fresh sandbox.",
406
- data: { action: "session_start", error: "SESSION_SUPERSEDED" },
439
+ text: live
440
+ ? "This session_start was superseded by a newer one that is already connected. " +
441
+ "The sandbox this call provisioned has been released. Use the active session, " +
442
+ "or call session_end and then session_start if you need a fresh sandbox."
443
+ : "This session_start raced a concurrent reconnect and neither connection survived. " +
444
+ "No sandbox is held now and no cleanup is needed — call session_start once more.",
445
+ data: { action: "session_start", error: "SESSION_SUPERSEDED", hasActiveSession: live },
407
446
  };
408
447
  }
409
448
 
@@ -424,6 +463,15 @@ async function tryRecoverSession(): Promise<boolean> {
424
463
  c.reconnecting = (async () => {
425
464
  const params = await resolver();
426
465
  if (!params?.sandboxId) return; // nothing provisioned this session
466
+ // Already proven gone — don't rebuild toward a corpse. Recovering costs an
467
+ // epoch bump, and repeating it is what starves a concurrent session_start
468
+ // of its publish. Report it as NO_SESSION so the caller re-provisions.
469
+ if (c.deadSandboxIds.has(params.sandboxId)) {
470
+ throw new NoActiveSessionError(
471
+ "NO_SESSION",
472
+ "The previous sandbox is gone and no new one has been started. Call session_start to create one."
473
+ );
474
+ }
427
475
  // Close any half-dead socket on the outgoing SDK before replacing it, so a
428
476
  // parked-then-rebuilt session doesn't leak an orphaned Ably connection.
429
477
  try {
@@ -431,7 +479,12 @@ async function tryRecoverSession(): Promise<boolean> {
431
479
  } catch {
432
480
  /* best effort — reconnectSession installs a fresh SDK regardless */
433
481
  }
434
- await reconnectSession(params);
482
+ try {
483
+ await reconnectSession(params);
484
+ } catch (err) {
485
+ markSandboxDead(c, params.sandboxId);
486
+ throw err;
487
+ }
435
488
  })().finally(() => {
436
489
  c.reconnecting = null;
437
490
  });
@@ -450,6 +503,22 @@ async function tryRecoverSession(): Promise<boolean> {
450
503
  return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
451
504
  }
452
505
 
506
+ /**
507
+ * Record that `sandboxId` is unreachable and tell the durable owner to forget it.
508
+ *
509
+ * Both halves matter. The local set stops *this* process from retrying a rebuild
510
+ * it already knows will fail; the hook stops the durable store from handing the
511
+ * same id back after a recycle, which would defeat the set.
512
+ */
513
+ function markSandboxDead(c: CoreContext, sandboxId: string): void {
514
+ c.deadSandboxIds.add(sandboxId);
515
+ try {
516
+ c.onRecoveryFailed?.(sandboxId);
517
+ } catch {
518
+ /* best effort — the local set alone still breaks the retry loop */
519
+ }
520
+ }
521
+
453
522
  /**
454
523
  * Validate the active session and auto-extend it (active use keeps it alive),
455
524
  * mirroring the MCP server's `requireActiveSession`. When the in-process
@@ -534,6 +603,14 @@ async function captureScreen(sdk: any = ctx().sdk): Promise<string | null> {
534
603
  export interface SessionStartHooks {
535
604
  /** Called before a long await; return a stop fn. Lets adapters heartbeat. */
536
605
  onProgress?: (message: string) => void;
606
+ /**
607
+ * Fired once the sandbox is connected and published to the context, BEFORE
608
+ * provisioning starts. Provisioning is the slow part (~20s+), but the live
609
+ * VNC stream is already watchable at this point — this is the hook an adapter
610
+ * uses to surface the viewer URL early instead of at the end of the call.
611
+ * Runs inside the context, so `getSdk()`/`getInstance()` are valid here.
612
+ */
613
+ onSandboxLive?: () => void;
537
614
  }
538
615
 
539
616
  /**
@@ -548,6 +625,14 @@ export async function sessionStart(
548
625
  ): Promise<ActionResult> {
549
626
  const c = ctx();
550
627
  const progress = hooks.onProgress ?? (() => {});
628
+ // Best-effort: an adapter's notification must never fail the provision.
629
+ const sandboxLive = () => {
630
+ try {
631
+ hooks.onSandboxLive?.();
632
+ } catch {
633
+ /* ignore */
634
+ }
635
+ };
551
636
 
552
637
  // Validate required fields for specific provision types (unless reconnecting).
553
638
  if (!params.sandboxId) {
@@ -616,7 +701,8 @@ export async function sessionStart(
616
701
  keepAlive: params.keepAlive,
617
702
  requireSandbox: true,
618
703
  });
619
- if (!(await publishSdk(c, sdk, generation))) return supersededResult();
704
+ if (!(await publishSdk(c, sdk, generation))) return supersededResult(c);
705
+ sandboxLive();
620
706
 
621
707
  const instance = sdk.getInstance();
622
708
  const newSession = c.sessions.createSession({
@@ -641,7 +727,8 @@ export async function sessionStart(
641
727
 
642
728
  progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
643
729
  await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
644
- if (!(await publishSdk(c, sdk, generation))) return supersededResult();
730
+ if (!(await publishSdk(c, sdk, generation))) return supersededResult(c);
731
+ sandboxLive();
645
732
 
646
733
  const instance = sdk.getInstance();
647
734
  const newSession = c.sessions.createSession({
@@ -781,9 +868,14 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
781
868
  // a loser never leaves a stray "initializing" session current.
782
869
  if (!(await publishSdk(c, sdk, generation))) {
783
870
  const current = c.sessions.getCurrentSession();
784
- if (current && c.sessions.isSessionValid(current.sessionId)) return current.sessionId;
871
+ // Require a *connected* winner, not merely a current session: the flow that
872
+ // took the epoch may still be mid-`connect()`, and returning its id here told
873
+ // the caller a session was ready that no action could then use.
874
+ if (sdkIsConnected(c.sdk) && current && c.sessions.isSessionValid(current.sessionId)) {
875
+ return current.sessionId;
876
+ }
785
877
  throw new NoActiveSessionError(
786
- "SESSION_EXPIRED",
878
+ "NO_SESSION",
787
879
  "The sandbox connection was superseded and no active session remains. Call session_start again to create a new sandbox session."
788
880
  );
789
881
  }
@@ -823,6 +915,15 @@ export async function ensureActiveSession(params?: ReconnectParams): Promise<boo
823
915
  if (!params?.sandboxId) {
824
916
  return false;
825
917
  }
918
+ // Proven gone already — skip the doomed rebuild (and the epoch bump it costs).
919
+ if (c.deadSandboxIds.has(params.sandboxId)) {
920
+ c.sdk = null;
921
+ throw new NoActiveSessionError(
922
+ "NO_SESSION",
923
+ "The previous sandbox is gone and no new one has been started. Call session_start to create one.",
924
+ params.sandboxId
925
+ );
926
+ }
826
927
  try {
827
928
  await reconnectSession(params);
828
929
  return true;
@@ -831,6 +932,7 @@ export async function ensureActiveSession(params?: ReconnectParams): Promise<boo
831
932
  // id is stale). Surface a SESSION_EXPIRED so the adapter's mapper tells the
832
933
  // agent to call session_start again, instead of a raw connect error.
833
934
  c.sdk = null;
935
+ markSandboxDead(c, params.sandboxId);
834
936
  throw new NoActiveSessionError(
835
937
  "SESSION_EXPIRED",
836
938
  `Could not reconnect to sandbox ${params.sandboxId}: ${err instanceof Error ? err.message : String(err)}. The sandbox has expired — call session_start again to create a new one.`,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Regression cover for cross-session sandbox adoption.
3
+ *
4
+ * Observed in an `eve dev` log: session A provisioned `sb-725a91b9`, then a
5
+ * later, unrelated session B called `session_start` and got
6
+ * `warm_noop {sandboxId: "sb-725a91b9"}` — B adopted A's sandbox, drove A's
7
+ * browser, and then ended it. The cause was every eve session sharing mcp-core's
8
+ * ONE global context on a long-lived host.
9
+ *
10
+ * These tests pin the property eve now relies on: state keyed to a context is
11
+ * invisible to another context, and the epoch that drives SESSION_SUPERSEDED is
12
+ * per-context rather than shared.
13
+ */
14
+
15
+ import { describe, it, expect } from "vitest";
16
+ import { createIsolatedContext, hasLiveSession, runInContext, type CoreContext } from "./actions.js";
17
+
18
+ /** A context that looks exactly like one holding a live, provisioned sandbox. */
19
+ function contextWithLiveSandbox(sandboxId: string): CoreContext {
20
+ const c = createIsolatedContext();
21
+ c.sdk = { connected: true };
22
+ const session = c.sessions.createSession({ os: "linux", keepAlive: 60_000 });
23
+ c.sessions.activateSession(session.sessionId, sandboxId);
24
+ return c;
25
+ }
26
+
27
+ describe("per-session context isolation", () => {
28
+ it("does not show one session's live sandbox to another session", () => {
29
+ // The exact adoption bug: B must NOT see A's sandbox as its own warm session.
30
+ const a = contextWithLiveSandbox("sb-725a91b9");
31
+ const b = createIsolatedContext();
32
+
33
+ expect(runInContext(a, () => hasLiveSession())).toBe(true);
34
+ expect(runInContext(b, () => hasLiveSession())).toBe(false);
35
+ });
36
+
37
+ it("keeps each session's sandbox id to itself", () => {
38
+ const a = contextWithLiveSandbox("sb-aaa");
39
+ const b = contextWithLiveSandbox("sb-bbb");
40
+
41
+ const idIn = (c: CoreContext) =>
42
+ runInContext(c, () => c.sessions.getCurrentSession()?.sandboxId);
43
+
44
+ expect(idIn(a)).toBe("sb-aaa");
45
+ expect(idIn(b)).toBe("sb-bbb");
46
+ });
47
+
48
+ it("does not let one session's provision bump another's SDK epoch", () => {
49
+ // Shared epochs are why two concurrent sessions could each supersede the
50
+ // other's publish and neither could ever win — the SESSION_SUPERSEDED loop.
51
+ const a = createIsolatedContext();
52
+ const b = createIsolatedContext();
53
+ const bEpochBefore = b.sdkGeneration;
54
+
55
+ runInContext(a, () => {
56
+ a.sdkGeneration++;
57
+ });
58
+
59
+ expect(b.sdkGeneration).toBe(bEpochBefore);
60
+ });
61
+
62
+ it("does not leak element refs across sessions", () => {
63
+ const a = createIsolatedContext();
64
+ const b = createIsolatedContext();
65
+
66
+ a.elementRefs.set("el-1", {
67
+ element: {},
68
+ description: "login button",
69
+ coords: { x: 1, y: 2, centerX: 1, centerY: 2 },
70
+ });
71
+
72
+ expect(b.elementRefs.has("el-1")).toBe(false);
73
+ });
74
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/agent",
3
- "version": "7.11.100-test",
3
+ "version": "7.11.101-test",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "types": "sdk.d.ts",
package/sdk.js CHANGED
@@ -3477,6 +3477,14 @@ CAPTCHA_SOLVER_EOF`,
3477
3477
  ciRunId: options.ci?.runId,
3478
3478
  ciJobId: options.ci?.jobId,
3479
3479
  ciUrl: options.ci?.url,
3480
+ // Nearly every CI provider exports CI=true, including ones we have no
3481
+ // named detection for, so this is the value to trust for "ran in CI".
3482
+ isCI:
3483
+ options.ci?.isCI ??
3484
+ (!!options.ci?.provider ||
3485
+ (!!process.env.CI &&
3486
+ process.env.CI !== "false" &&
3487
+ process.env.CI !== "0")),
3480
3488
  // Git
3481
3489
  repo: options.git?.repo,
3482
3490
  branch: options.git?.branch,