@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.
- package/ai/skills/testdriver-ci-cd/SKILL.md +1 -1
- package/ai/skills/testdriver-client/SKILL.md +1 -1
- package/ai/skills/testdriver-quickstart/SKILL.md +3 -3
- package/debugger/index.html +5 -3
- package/docs/docs.json +74 -65
- package/docs/v7/agent.mdx +1 -1
- package/docs/v7/aws-setup.mdx +1 -1
- package/docs/v7/caching.mdx +1 -1
- package/docs/v7/ci-cd.mdx +1 -1
- package/docs/v7/copilot/auto-healing.mdx +1 -1
- package/docs/v7/copilot/running-tests.mdx +1 -1
- package/docs/v7/customizing-devices.mdx +1 -0
- package/docs/v7/debugging-with-screenshots.mdx +1 -1
- package/docs/v7/elements.mdx +1 -1
- package/docs/v7/generating-tests.mdx +1 -1
- package/docs/v7/hosted.mdx +1 -1
- package/docs/v7/performing-actions.mdx +1 -1
- package/docs/v7/provision.mdx +1 -1
- package/docs/v7/quickstart.mdx +1 -1
- package/docs/v7/reusable-code.mdx +1 -0
- package/docs/v7/screenshots.mdx +1 -1
- package/docs/v7/secrets.mdx +1 -0
- package/docs/v7/self-hosted.mdx +1 -1
- package/docs/v7/test-results-json.mdx +1 -1
- package/docs/v7/variables.mdx +1 -0
- package/docs/v7/what-is-testdriver.mdx +1 -0
- package/interfaces/vitest-plugin.mjs +15 -0
- package/mcp-server/dist/core/actions.d.ts +29 -0
- package/mcp-server/dist/core/actions.js +79 -11
- package/mcp-server/src/core/actions.test.ts +62 -2
- package/mcp-server/src/core/actions.ts +114 -12
- package/mcp-server/src/core/session-isolation.test.ts +74 -0
- package/package.json +1 -1
- package/sdk.js +8 -0
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
360
|
+
href="https://console.testdriver.ai/settings"
|
|
361
361
|
arrow
|
|
362
362
|
horizontal
|
|
363
363
|
>
|
package/debugger/index.html
CHANGED
|
@@ -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
|
|
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 =
|
|
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": "
|
|
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": "
|
|
81
|
+
"group": "Reference",
|
|
71
82
|
"pages": [
|
|
72
|
-
"/v7/variables",
|
|
73
|
-
"/v7/secrets",
|
|
74
|
-
"/v7/reusable-code",
|
|
75
83
|
{
|
|
76
|
-
"group": "
|
|
77
|
-
"icon": "
|
|
84
|
+
"group": "Commands",
|
|
85
|
+
"icon": "terminal",
|
|
78
86
|
"pages": [
|
|
79
|
-
"/v7/
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"/v7/
|
|
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
package/docs/v7/aws-setup.mdx
CHANGED
package/docs/v7/caching.mdx
CHANGED
package/docs/v7/ci-cd.mdx
CHANGED
package/docs/v7/elements.mdx
CHANGED
package/docs/v7/hosted.mdx
CHANGED
package/docs/v7/provision.mdx
CHANGED
package/docs/v7/quickstart.mdx
CHANGED
package/docs/v7/screenshots.mdx
CHANGED
package/docs/v7/secrets.mdx
CHANGED
package/docs/v7/self-hosted.mdx
CHANGED
package/docs/v7/variables.mdx
CHANGED
|
@@ -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
|
-
/**
|
|
273
|
-
|
|
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:
|
|
277
|
-
"
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
399
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
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,
|