import assert from "node:assert/strict"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import { withApp } from "../lib/app.mjs"; import { extensionZipBase64, LOCALIZED_EXTENSION_MESSAGES, localizedExtensionZipBase64, OVERSIZED_EXTENSION_NAME, oversizedExtensionZipBase64, writeUnpackedExtension, } from "../lib/fixtures.mjs"; const VLESS_URI = "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#MCP"; async function jsonRequest( url, { method = "GET", token, body, headers = {} } = {}, ) { const response = await fetch(url, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }), ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); let value = null; if (text) { try { value = JSON.parse(text); } catch { value = text; } } return { response, value }; } async function seedTerms(app) { const home = path.join(app.root, "home"); const directory = process.platform === "darwin" ? path.join(home, "Library", "Application Support", "Wayfern") : process.platform === "win32" ? path.join(app.root, "windows", "roaming", "Wayfern") : path.join(app.root, "xdg", "config", "Wayfern"); await mkdir(directory, { recursive: true }); await writeFile( path.join(directory, "license-accepted"), String(Math.floor(Date.now() / 1000)), ); } async function invokeContract(app, command, args = {}) { try { return { ok: true, value: await app.invoke(command, args) }; } catch (error) { return { ok: false, error: String(error) }; } } async function assertCommandErrorCode(app, command, code, args = {}) { const error = await app.invokeError(command, args); assert.match(error, new RegExp(`"code":"${code}"`)); } test("authenticated REST API serves its complete OpenAPI contract and CRUD lifecycle", async () => { await withApp("integrations-rest", async (app) => { await seedTerms(app); const settings = await app.invoke("get_app_settings"); const saved = await app.invoke("save_app_settings", { settings: { ...settings, api_enabled: true, api_port: 0, api_token: null, onboarding_completed: true, }, }); assert.ok(saved.api_token?.length >= 32); const port = await app.invoke("start_api_server", { port: 0 }); assert.equal(await app.invoke("get_api_server_status"), port); const base = `http://127.0.0.1:${port}`; const openapi = await jsonRequest(`${base}/openapi.json`); assert.equal(openapi.response.status, 200); assert.equal(openapi.value.openapi.startsWith("3."), true); const paths = Object.keys(openapi.value.paths); for (const required of [ "/v1/profiles", "/v1/profiles/{id}/run", "/v1/groups", "/v1/proxies", "/v1/vpns/{id}/export", "/v1/extensions", "/v1/browsers/{browser}/versions", ]) { assert.ok(paths.includes(required), `OpenAPI is missing ${required}`); } // The served spec comes from the hand-maintained ApiDoc derive, not from // the router, so an extension route can answer requests while being absent // from the contract an agent generates its client from. for (const [route, methods] of [ ["/v1/extensions", ["get", "post"]], ["/v1/extensions/{id}", ["get", "put", "delete"]], ["/v1/extension-groups", ["get", "post"]], ["/v1/extension-groups/{id}", ["get", "put", "delete"]], [ "/v1/extension-groups/{id}/extensions/{extension_id}", ["post", "delete"], ], ]) { assert.ok(paths.includes(route), `OpenAPI is missing ${route}`); for (const method of methods) { assert.ok( openapi.value.paths[route][method], `OpenAPI is missing ${method.toUpperCase()} ${route}`, ); } } const unauthorized = await jsonRequest(`${base}/v1/profiles`); assert.equal(unauthorized.response.status, 401); const wrongToken = await jsonRequest(`${base}/v1/profiles`, { token: "wrong", }); assert.equal(wrongToken.response.status, 401); const groupsInitially = await jsonRequest(`${base}/v1/groups`, { token: saved.api_token, }); assert.equal(groupsInitially.response.status, 200); assert.deepEqual(groupsInitially.value, []); const createdGroup = await jsonRequest(`${base}/v1/groups`, { method: "POST", token: saved.api_token, body: { name: "REST Group" }, }); assert.equal(createdGroup.response.status, 200); assert.equal(createdGroup.value.name, "REST Group"); const groupId = createdGroup.value.id; const updatedGroup = await jsonRequest(`${base}/v1/groups/${groupId}`, { method: "PUT", token: saved.api_token, body: { name: "REST Group Updated" }, }); assert.equal(updatedGroup.value.name, "REST Group Updated"); const createdProxy = await jsonRequest(`${base}/v1/proxies`, { method: "POST", token: saved.api_token, body: { name: "REST Proxy", proxy_settings: { proxy_type: "http", host: "127.0.0.1", port: 8080, username: null, password: null, }, }, }); assert.equal(createdProxy.response.status, 200); assert.equal(createdProxy.value.proxy_settings.port, 8080); const proxyId = createdProxy.value.id; const fetchedProxy = await jsonRequest(`${base}/v1/proxies/${proxyId}`, { token: saved.api_token, }); assert.equal(fetchedProxy.value.name, "REST Proxy"); const createdVless = await jsonRequest(`${base}/v1/proxies`, { method: "POST", token: saved.api_token, body: { name: "REST VLESS Reality", proxy_settings: { proxy_type: "vless", host: "127.0.0.1", port: 443, username: null, password: null, vless_uri: VLESS_URI, }, }, }); assert.equal(createdVless.response.status, 200); assert.equal(createdVless.value.proxy_settings.vless_uri, VLESS_URI); assert.equal(createdVless.value.proxy_settings.host, "127.0.0.1"); assert.equal(createdVless.value.proxy_settings.port, 443); const vlessProxyId = createdVless.value.id; const invalidVless = await jsonRequest( `${base}/v1/proxies/${vlessProxyId}`, { method: "PUT", token: saved.api_token, body: { proxy_settings: { ...createdVless.value.proxy_settings, vless_uri: VLESS_URI.replace("security=reality", "security=tls"), }, }, }, ); assert.equal(invalidVless.response.status, 400); assert.match(JSON.stringify(invalidVless.value), /VLESS_CONFIG_INVALID/); assert.equal( ( await jsonRequest(`${base}/v1/proxies/${vlessProxyId}`, { token: saved.api_token, }) ).value.proxy_settings.vless_uri, VLESS_URI, ); const imported = await jsonRequest(`${base}/v1/proxies/import`, { method: "POST", token: saved.api_token, body: { format: "txt", content: "http://127.0.0.1:8081", name_prefix: "API", }, }); assert.equal(imported.response.status, 200); assert.equal(imported.value.imported_count, 1); // Extensions arrive either as an inline payload or as a path the app can // read, and the folder form is the whole point: it is how an agent reaches // the "load unpacked" flow that the desktop offers through a file picker. const archiveExtension = await jsonRequest(`${base}/v1/extensions`, { method: "POST", token: saved.api_token, body: { name: "REST Archive Extension", file_name: "fixture.zip", file_data_base64: extensionZipBase64(), }, }); assert.equal( archiveExtension.response.status, 201, JSON.stringify(archiveExtension.value), ); assert.equal(archiveExtension.value.name, "Donut E2E Fixture"); assert.equal(archiveExtension.value.source_kind, "archive"); assert.equal(archiveExtension.value.linked_path, null); const unpackedDir = await writeUnpackedExtension( path.join(app.root, "fixtures", "rest-unpacked-extension"), { name: "Donut REST Unpacked", version: "1.2.0" }, ); const folderExtension = await jsonRequest(`${base}/v1/extensions`, { method: "POST", token: saved.api_token, body: { name: "REST Folder Extension", source_path: unpackedDir }, }); assert.equal( folderExtension.response.status, 201, JSON.stringify(folderExtension.value), ); assert.equal(folderExtension.value.name, "Donut REST Unpacked"); assert.equal(folderExtension.value.version, "1.2.0"); assert.equal(folderExtension.value.source_kind, "unpacked"); assert.equal(folderExtension.value.linked_path, null); // Two sources in one request have no defined winner, so the request is // refused rather than silently resolved. const ambiguousSource = await jsonRequest(`${base}/v1/extensions`, { method: "POST", token: saved.api_token, body: { name: "REST Ambiguous Extension", file_name: "fixture.zip", file_data_base64: extensionZipBase64(), source_path: unpackedDir, }, }); assert.equal(ambiguousSource.response.status, 400); assert.match( JSON.stringify(ambiguousSource.value), /EXTENSION_SOURCE_REQUIRED/, ); const sourcelessExtension = await jsonRequest(`${base}/v1/extensions`, { method: "POST", token: saved.api_token, body: { name: "REST Sourceless Extension" }, }); assert.equal(sourcelessExtension.response.status, 400); assert.match( JSON.stringify(sourcelessExtension.value), /EXTENSION_SOURCE_REQUIRED/, ); // An archive has no folder to keep loading from, so linking one is refused // rather than quietly stored as a copy. const linkedArchive = await jsonRequest(`${base}/v1/extensions`, { method: "POST", token: saved.api_token, body: { name: "REST Linked Archive", file_name: "fixture.zip", file_data_base64: extensionZipBase64(), link: true, }, }); assert.equal(linkedArchive.response.status, 400); assert.match( JSON.stringify(linkedArchive.value), /EXTENSION_LINK_REQUIRES_DIRECTORY/, ); const extensionId = folderExtension.value.id; assert.equal( ( await jsonRequest(`${base}/v1/extensions/${extensionId}`, { token: saved.api_token, }) ).value.id, extensionId, ); const renamedExtension = await jsonRequest( `${base}/v1/extensions/${extensionId}`, { method: "PUT", token: saved.api_token, body: { name: "REST Renamed Extension" }, }, ); assert.equal( renamedExtension.response.status, 200, JSON.stringify(renamedExtension.value), ); assert.equal(renamedExtension.value.name, "REST Renamed Extension"); assert.equal( (await jsonRequest(`${base}/v1/extensions`, { token: saved.api_token })) .value.length, 2, ); // Axum's default body limit is 2 MiB, which plenty of real `.crx` files // exceed: every one of them was refused before the handler ran until the // extension payload routes got a limit of their own. The fixture below is // stored rather than deflated, so the body genuinely stays over the // default and a 201 can only come from the raised limit. const oversizedBody = { name: "REST Oversized Extension", file_name: "oversized.zip", file_data_base64: oversizedExtensionZipBase64(), }; assert.ok( Buffer.byteLength(JSON.stringify(oversizedBody)) > 2 * 1024 * 1024, "the oversized fixture must exceed the default body limit it tests", ); const oversized = await jsonRequest(`${base}/v1/extensions`, { method: "POST", token: saved.api_token, body: oversizedBody, }); assert.equal( oversized.response.status, 201, JSON.stringify(oversized.value), ); // Read out of the archive that arrived, so the payload landed whole rather // than merely being accepted. assert.equal(oversized.value.name, OVERSIZED_EXTENSION_NAME); assert.equal(oversized.value.version, "1.0.0"); assert.equal(oversized.value.file_type, "zip"); // The raised limit is scoped to the two paths that carry a payload. A // group name is never megabytes long, so a route that accepted one would // mean the layer had been attached to the whole router. const oversizedGroupBody = { name: "G".repeat(3 * 1024 * 1024) }; assert.ok( Buffer.byteLength(JSON.stringify(oversizedGroupBody)) > 2 * 1024 * 1024, ); const oversizedGroup = await jsonRequest(`${base}/v1/extension-groups`, { method: "POST", token: saved.api_token, body: oversizedGroupBody, }); assert.equal( oversizedGroup.response.status, 413, JSON.stringify(oversizedGroup.value), ); assert.deepEqual( ( await jsonRequest(`${base}/v1/extension-groups`, { token: saved.api_token, }) ).value, [], "the refused group request must not have stored anything", ); // Chrome Web Store extensions overwhelmingly localize their manifest: the // name a user recognizes sits in `_locales//messages.json` // and the manifest holds `__MSG_extName__`. Storing the manifest verbatim // is what puts a raw placeholder in the extension list. const localized = await jsonRequest(`${base}/v1/extensions`, { method: "POST", token: saved.api_token, body: { name: "REST Localized Extension", file_name: "localized.zip", file_data_base64: localizedExtensionZipBase64(), }, }); assert.equal( localized.response.status, 201, JSON.stringify(localized.value), ); assert.equal(localized.value.name, LOCALIZED_EXTENSION_MESSAGES.extName); assert.equal( localized.value.description, LOCALIZED_EXTENSION_MESSAGES.extDescription, ); assert.equal( localized.value.author, LOCALIZED_EXTENSION_MESSAGES.extAuthor, ); assert.doesNotMatch(JSON.stringify(localized.value), /__MSG_/); // The resolved strings have to be what was persisted, not something the // create response computed on its way out. assert.equal( ( await jsonRequest(`${base}/v1/extensions/${localized.value.id}`, { token: saved.api_token, }) ).value.name, LOCALIZED_EXTENSION_MESSAGES.extName, ); // A placeholder the locale file cannot resolve falls back to the name the // caller sent. What it must never do is store `__MSG_extName__` itself. const unresolved = await jsonRequest(`${base}/v1/extensions`, { method: "POST", token: saved.api_token, body: { name: "REST Unresolved Placeholder", file_name: "unresolved.zip", file_data_base64: localizedExtensionZipBase64({ messages: {} }), }, }); assert.equal( unresolved.response.status, 201, JSON.stringify(unresolved.value), ); assert.equal(unresolved.value.name, "REST Unresolved Placeholder"); assert.equal(unresolved.value.description, null); assert.equal(unresolved.value.author, null); assert.doesNotMatch(JSON.stringify(unresolved.value), /__MSG_/); const extensionGroup = await jsonRequest(`${base}/v1/extension-groups`, { method: "POST", token: saved.api_token, body: { name: "REST Extension Group" }, }); assert.equal( extensionGroup.response.status, 201, JSON.stringify(extensionGroup.value), ); assert.equal(extensionGroup.value.name, "REST Extension Group"); assert.deepEqual(extensionGroup.value.extension_ids, []); const extensionGroupId = extensionGroup.value.id; const renamedExtensionGroup = await jsonRequest( `${base}/v1/extension-groups/${extensionGroupId}`, { method: "PUT", token: saved.api_token, body: { name: "REST Extension Group Updated" }, }, ); assert.equal( renamedExtensionGroup.response.status, 200, JSON.stringify(renamedExtensionGroup.value), ); assert.equal( renamedExtensionGroup.value.name, "REST Extension Group Updated", ); const membershipUrl = `${base}/v1/extension-groups/${extensionGroupId}/extensions/${extensionId}`; const joined = await jsonRequest(membershipUrl, { method: "POST", token: saved.api_token, }); assert.equal(joined.response.status, 200, JSON.stringify(joined.value)); assert.deepEqual(joined.value.extension_ids, [extensionId]); assert.deepEqual( ( await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, { token: saved.api_token, }) ).value.extension_ids, [extensionId], ); const left = await jsonRequest(membershipUrl, { method: "DELETE", token: saved.api_token, }); assert.equal(left.response.status, 200, JSON.stringify(left.value)); assert.deepEqual(left.value.extension_ids, []); assert.deepEqual( ( await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, { token: saved.api_token, }) ).value.extension_ids, [], ); // The whole path an automation client takes: an extension, a group holding // it, and a profile that will load that group the next time it launches. // Each piece already had coverage; the sequence did not, and it is the // sequence that has to work for extensions to be usable over REST at all. const launchProfile = await app.invoke("create_browser_profile_new", { name: "REST Extension Profile", browserStr: "wayfern", version: "150.0.7871.100", releaseType: "stable", proxyId: null, vpnId: null, // A stored fingerprint keeps this suite off the real browser; the // browser suite covers generation. wayfernConfig: { fingerprint: "{}" }, groupId: null, ephemeral: false, dnsBlocklist: null, launchHook: null, }); const launchGroup = await jsonRequest(`${base}/v1/extension-groups`, { method: "POST", token: saved.api_token, body: { name: "REST Launch Extension Group" }, }); assert.equal( launchGroup.response.status, 201, JSON.stringify(launchGroup.value), ); const launchGroupId = launchGroup.value.id; assert.deepEqual( ( await jsonRequest( `${base}/v1/extension-groups/${launchGroupId}/extensions/${archiveExtension.value.id}`, { method: "POST", token: saved.api_token }, ) ).value.extension_ids, [archiveExtension.value.id], ); const assigned = await jsonRequest( `${base}/v1/profiles/${launchProfile.id}`, { method: "PUT", token: saved.api_token, body: { extension_group_id: launchGroupId }, }, ); assert.equal(assigned.response.status, 200, JSON.stringify(assigned.value)); assert.equal(assigned.value.profile.id, launchProfile.id); // A caller that can set the assignment must be able to read it back without // dropping to the desktop app. assert.equal( assigned.value.profile.extension_group_id, launchGroupId, "the update response must echo the assignment it just made", ); assert.equal( ( await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, { token: saved.api_token, }) ).value.profile.extension_group_id, launchGroupId, "a fresh GET must report the assignment", ); // Also read it back through the surface the launcher itself resolves, so // the REST field and the launch path cannot drift apart. const assignedGroup = () => app.invoke("get_extension_group_for_profile", { profileId: launchProfile.id, }); assert.equal((await assignedGroup()).id, launchGroupId); assert.deepEqual((await assignedGroup()).extension_ids, [ archiveExtension.value.id, ]); // A group that does not exist used to be stored anyway and fail at launch, // far from the request that caused it. const missingExtensionGroup = await jsonRequest( `${base}/v1/profiles/${launchProfile.id}`, { method: "PUT", token: saved.api_token, body: { extension_group_id: "00000000-0000-0000-0000-0000000000ee" }, }, ); assert.equal( missingExtensionGroup.response.status, 404, JSON.stringify(missingExtensionGroup.value), ); assert.equal( (await assignedGroup()).id, launchGroupId, "a refused assignment must leave the previous one in place", ); assert.equal( ( await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, { method: "PUT", token: saved.api_token, body: { extension_group_id: "" }, }) ).response.status, 200, ); assert.equal(await assignedGroup(), null); assert.equal( ( await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, { token: saved.api_token, }) ).value.profile.extension_group_id, null, "clearing the assignment must be visible over REST too", ); assert.equal( ( await jsonRequest(`${base}/v1/extension-groups/${launchGroupId}`, { method: "DELETE", token: saved.api_token, }) ).response.status, 204, ); await app.invoke("delete_profile", { profileId: launchProfile.id }); for (const id of [ extensionId, archiveExtension.value.id, oversized.value.id, localized.value.id, unresolved.value.id, ]) { assert.equal( ( await jsonRequest(`${base}/v1/extensions/${id}`, { method: "DELETE", token: saved.api_token, }) ).response.status, 204, ); } assert.equal( ( await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, { method: "DELETE", token: saved.api_token, }) ).response.status, 204, ); const missing = await jsonRequest(`${base}/v1/groups/missing`, { token: saved.api_token, }); assert.equal(missing.response.status, 404); const invalidProfile = await jsonRequest(`${base}/v1/profiles`, { method: "POST", token: saved.api_token, body: { name: "Bad", browser: "unsupported", version: "latest" }, }); assert.equal(invalidProfile.response.status, 400); assert.equal( ( await jsonRequest(`${base}/v1/proxies/${proxyId}`, { method: "DELETE", token: saved.api_token, }) ).response.status, 204, ); assert.equal( ( await jsonRequest(`${base}/v1/proxies/${vlessProxyId}`, { method: "DELETE", token: saved.api_token, }) ).response.status, 204, ); for (const importedProxy of imported.value.proxies) { await jsonRequest(`${base}/v1/proxies/${importedProxy.id}`, { method: "DELETE", token: saved.api_token, }); } assert.equal( ( await jsonRequest(`${base}/v1/groups/${groupId}`, { method: "DELETE", token: saved.api_token, }) ).response.status, 204, ); await app.invoke("stop_api_server"); assert.equal(await app.invoke("get_api_server_status"), null); }); }); test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated agent install", async () => { await withApp("integrations-mcp", async (app) => { await seedTerms(app); await assertCommandErrorCode( app, "stop_mcp_server", "MCP_SERVER_NOT_RUNNING", ); const port = await app.invoke("start_mcp_server"); await assertCommandErrorCode( app, "start_mcp_server", "MCP_SERVER_ALREADY_RUNNING", ); assert.equal(await app.invoke("get_mcp_server_status"), true); const config = await app.invoke("get_mcp_config"); assert.equal(config.port, port); assert.ok(config.token.length >= 32); const base = `http://127.0.0.1:${port}`; assert.equal((await fetch(`${base}/health`)).status, 200); assert.equal( ( await jsonRequest(`${base}/mcp`, { method: "POST", body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, }) ).response.status, 401, ); const initialized = await jsonRequest(`${base}/mcp/${config.token}`, { method: "POST", body: { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "donut-e2e", version: "1" }, }, }, }); assert.equal(initialized.response.status, 200); assert.equal(initialized.value.result.serverInfo.name, "donut-browser"); const sessionId = initialized.response.headers.get("mcp-session-id"); assert.ok(sessionId); const mcpHeaders = { "mcp-session-id": sessionId }; const notification = await jsonRequest(`${base}/mcp/${config.token}`, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", method: "notifications/initialized" }, }); assert.equal(notification.response.status, 202); const tools = await jsonRequest(`${base}/mcp/${config.token}`, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, }); assert.equal(tools.response.status, 200); const names = tools.value.result.tools.map((tool) => tool.name); for (const name of [ "list_profiles", "create_profile", "run_profile", "list_proxies", "create_proxy", "update_proxy", "get_page_content", "get_interactive_elements", // The remote loop has to be complete from MCP alone: start a session, // watch it become usable, drive it with the interaction tools above, stop // it. Any one of these missing leaves an agent able to lease a host it // cannot use, or unable to lease one at all. "run_profile_remote", "get_remote_session", "stop_remote_session", // Extension management is only usable from an agent if importing and // grouping are reachable, not just listing and deleting. "add_extension", "update_extension", "add_extension_to_group", "remove_extension_from_group", "update_extension_group", ]) { assert.ok(names.includes(name), `MCP is missing ${name}`); } const listed = await jsonRequest(`${base}/mcp/${config.token}`, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "list_profiles", arguments: {} }, }, }); assert.equal(listed.response.status, 200); assert.equal(listed.value.error, undefined); assert.ok(listed.value.result); const createdVless = await jsonRequest(`${base}/mcp/${config.token}`, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "create_proxy", arguments: { name: "MCP VLESS Reality", proxy_type: "vless", vless_uri: VLESS_URI, }, }, }, }); assert.equal(createdVless.response.status, 200); assert.equal(createdVless.value.error, undefined); let vlessProxy = (await app.invoke("get_stored_proxies")).find( (proxy) => proxy.name === "MCP VLESS Reality", ); assert.ok(vlessProxy); assert.equal(vlessProxy.proxy_settings.proxy_type, "vless"); assert.equal(vlessProxy.proxy_settings.vless_uri, VLESS_URI); const updatedVless = await jsonRequest(`${base}/mcp/${config.token}`, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "update_proxy", arguments: { proxy_id: vlessProxy.id, name: "MCP VLESS Updated", vless_uri: VLESS_URI, }, }, }, }); assert.equal(updatedVless.value.error, undefined); vlessProxy = (await app.invoke("get_stored_proxies")).find( (proxy) => proxy.id === vlessProxy.id, ); assert.equal(vlessProxy.name, "MCP VLESS Updated"); const invalidVless = await jsonRequest(`${base}/mcp/${config.token}`, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "update_proxy", arguments: { proxy_id: vlessProxy.id, vless_uri: VLESS_URI.replace("security=reality", "security=tls"), }, }, }, }); assert.match(invalidVless.value.error.message, /VLESS_CONFIG_INVALID/); assert.equal( (await app.invoke("get_stored_proxies")).find( (proxy) => proxy.id === vlessProxy.id, ).proxy_settings.vless_uri, VLESS_URI, ); await app.invoke("delete_stored_proxy", { proxyId: vlessProxy.id }); let toolCallId = 7; const callTool = (name, args) => jsonRequest(`${base}/mcp/${config.token}`, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", id: toolCallId++, method: "tools/call", params: { name, arguments: args }, }, }); const unpackedDir = await writeUnpackedExtension( path.join(app.root, "fixtures", "mcp-unpacked-extension"), { name: "Donut MCP Unpacked", version: "1.0.0" }, ); const addedExtension = await callTool("add_extension", { path: unpackedDir, name: "MCP Folder Extension", }); assert.equal(addedExtension.response.status, 200); const subscriptionGated = /subscription/i.test( addedExtension.value.error?.message ?? "", ); // The e2e build overrides the paid-plan gate whenever a Wayfern test token // is present, so with one in the environment a gated answer means the // override stopped working and everything below it silently stopped // running. assert.ok( !subscriptionGated || !process.env.WAYFERN_TEST_TOKEN, `the e2e paid-plan override did not apply: ${addedExtension.value.error?.message}`, ); if (subscriptionGated) { // Every extension tool is gated on an active paid plan and this session // is signed out, so the call path is unreachable here. The tool list // above still proves the tools are published. console.warn( "Skipping the MCP extension tool calls: this session has no paid entitlement", ); } else { assert.equal(addedExtension.value.error, undefined); const stored = (await app.invoke("list_extensions")).find( (item) => item.name === "Donut MCP Unpacked", ); assert.ok(stored, "the MCP import must produce a stored extension"); assert.equal(stored.source_kind, "unpacked"); assert.equal(stored.linked_path, null); const renamedExtension = await callTool("update_extension", { extension_id: stored.id, name: "MCP Renamed Extension", }); assert.equal(renamedExtension.value.error, undefined); assert.equal( (await app.invoke("list_extensions")).find( (item) => item.id === stored.id, ).name, "MCP Renamed Extension", ); const extensionGroup = await app.invoke("create_extension_group", { name: "MCP Extension Group", }); const joined = await callTool("add_extension_to_group", { group_id: extensionGroup.id, extension_id: stored.id, }); assert.equal(joined.value.error, undefined); const readGroup = async () => (await app.invoke("list_extension_groups")).find( (item) => item.id === extensionGroup.id, ); assert.deepEqual((await readGroup()).extension_ids, [stored.id]); const renamedGroup = await callTool("update_extension_group", { group_id: extensionGroup.id, name: "MCP Extension Group Updated", }); assert.equal(renamedGroup.value.error, undefined); assert.equal((await readGroup()).name, "MCP Extension Group Updated"); const removed = await callTool("remove_extension_from_group", { group_id: extensionGroup.id, extension_id: stored.id, }); assert.equal(removed.value.error, undefined); assert.deepEqual((await readGroup()).extension_ids, []); await app.invoke("delete_extension", { extensionId: stored.id }); await app.invoke("delete_extension_group", { groupId: extensionGroup.id, }); } const agents = await app.invoke("list_mcp_agents"); assert.ok(agents.some((agent) => agent.id === "cursor")); await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", { agentId: "missing-e2e-agent", }); await app.invoke("add_mcp_to_agent", { agentId: "cursor" }); assert.equal( (await app.invoke("list_mcp_agents")).find( (agent) => agent.id === "cursor", ).connected, true, ); await app.invoke("remove_mcp_from_agent", { agentId: "cursor" }); assert.equal( (await app.invoke("list_mcp_agents")).find( (agent) => agent.id === "cursor", ).connected, false, ); assert.equal( ( await jsonRequest(`${base}/mcp/${config.token}`, { method: "DELETE", headers: mcpHeaders, }) ).response.status, 200, ); await app.invoke("stop_mcp_server"); assert.equal(await app.invoke("get_mcp_server_status"), false); }); }); test("REST and MCP share the browser automation rate limit", async () => { await withApp( "integrations-rate-limit", async (app) => { await seedTerms(app); const settings = await app.invoke("get_app_settings"); const saved = await app.invoke("save_app_settings", { settings: { ...settings, api_enabled: true, api_port: 0, api_token: null, onboarding_completed: true, }, }); const apiPort = await app.invoke("start_api_server", { port: 0 }); const mcpPort = await app.invoke("start_mcp_server"); const mcpConfig = await app.invoke("get_mcp_config"); const apiBase = `http://127.0.0.1:${apiPort}`; const mcpUrl = `http://127.0.0.1:${mcpPort}/mcp/${mcpConfig.token}`; const initialized = await jsonRequest(mcpUrl, { method: "POST", body: { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "donut-e2e-rate-limit", version: "1" }, }, }, }); assert.equal(initialized.response.status, 200); const mcpHeaders = { "mcp-session-id": initialized.response.headers.get("mcp-session-id"), }; const missingProfileId = "00000000-0000-0000-0000-000000000000"; const first = await jsonRequest( `${apiBase}/v1/profiles/${missingProfileId}/run`, { method: "POST", token: saved.api_token, body: {}, }, ); assert.equal(first.response.status, 404); const second = await jsonRequest(mcpUrl, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "run_profile", arguments: { profile_id: missingProfileId }, }, }, }); assert.equal(second.response.status, 200); assert.equal(second.value.error.code, -32000); const restLimited = await jsonRequest( `${apiBase}/v1/profiles/${missingProfileId}/run`, { method: "POST", token: saved.api_token, body: {}, }, ); assert.equal(restLimited.response.status, 429); assert.ok(Number(restLimited.response.headers.get("retry-after")) > 0); const mcpLimited = await jsonRequest(mcpUrl, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "run_profile", arguments: { profile_id: missingProfileId }, }, }, }); assert.equal(mcpLimited.response.status, 429); assert.ok(Number(mcpLimited.response.headers.get("retry-after")) > 0); const freeCall = await jsonRequest(mcpUrl, { method: "POST", headers: mcpHeaders, body: { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "list_profiles", arguments: {} }, }, }); assert.equal(freeCall.response.status, 200); assert.equal(freeCall.value.error, undefined); await app.invoke("stop_mcp_server"); await app.invoke("stop_api_server"); }, { extraEnv: { DONUT_E2E_REQUESTS_PER_HOUR: "2", WAYFERN_TEST_TOKEN: "donut-e2e-rate-limit", }, }, ); }); test("offline cloud, update, team-lock, trial, and synchronizer contracts are deterministic", async () => { await withApp( "integrations-contracts", async (app) => { await assertCommandErrorCode( app, "start_mcp_server", "WAYFERN_TERMS_REQUIRED", ); assert.equal(await app.invoke("cloud_get_user"), null); assert.equal(await app.invoke("cloud_get_proxy_usage"), null); assert.ok(await app.invoke("cloud_get_wayfern_token")); assert.deepEqual(await app.invoke("get_team_locks"), []); assert.equal( await app.invoke("get_team_lock_status", { profileId: "00000000-0000-0000-0000-000000000000", }), null, ); assert.deepEqual(await app.invoke("get_sync_sessions"), []); const startResult = await invokeContract(app, "start_sync_session", { leaderProfileId: "00000000-0000-0000-0000-000000000001", followerProfileIds: ["00000000-0000-0000-0000-000000000002"], }); assert.equal(startResult.ok, false); const stopError = await app.invokeError("stop_sync_session", { sessionId: "missing", }); assert.match(stopError, /not found|session/i); const removeError = await app.invokeError("remove_sync_follower", { sessionId: "missing", followerProfileId: "missing", }); assert.match(removeError, /not found|session/i); assert.equal(await app.invoke("check_for_app_updates"), null); assert.equal(await app.invoke("check_for_app_updates_manual"), null); assert.ok( await invokeContract(app, "cloud_exchange_device_code", { code: "DONUT-E2E-INVALID-CODE", }), ); assert.ok(await invokeContract(app, "cloud_refresh_profile")); assert.ok(await invokeContract(app, "cloud_get_countries")); assert.ok( await invokeContract(app, "create_cloud_location_proxy", { name: "E2E unavailable cloud proxy", country: "ZZ", region: null, city: null, isp: null, }), ); assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token")); assert.ok(await invokeContract(app, "trigger_manual_version_update")); assert.ok( await invokeContract(app, "clear_all_version_cache_and_refetch"), ); assert.ok(await invokeContract(app, "check_for_browser_updates")); await app.invoke("dismiss_update_notification", { notificationId: "missing-e2e-notification", }); assert.deepEqual( await app.invoke("complete_browser_update_with_auto_update", { browser: "wayfern", newVersion: "150.0.7871.100", }), [], ); const prepareError = await app.invokeError( "download_and_prepare_app_update", { updateInfo: { current_version: "0.0.0", new_version: "0.0.1-e2e", release_notes: "E2E invalid update contract", download_url: `${process.env.DONUT_E2E_FIXTURE_URL}/invalid-update.zip`, is_nightly: false, published_at: "2026-01-01T00:00:00Z", manual_update_required: false, release_page_url: null, repo_update: false, checksums_url: null, asset_digest: null, }, }, ); assert.match(prepareError, /checksum|verif|Failed to download/i); const versionStatus = await app.invoke("get_version_update_status"); assert.ok(versionStatus && typeof versionStatus === "object"); assert.equal(typeof (await app.invoke("is_default_browser")), "boolean"); // Remote sessions and the cookie bot are brokered by the cloud backend. // Signed out, every one of them must fail as a code the UI can // translate — a raw English string from the transport would reach the // user untranslated, which is what the {"code":…} convention prevents. const notSignedIn = /"code":"CLOUD_NOT_SIGNED_IN"/; const missingProfileId = "00000000-0000-0000-0000-0000000000ff"; assert.match(await app.invokeError("list_remote_sessions"), notSignedIn); assert.match( await app.invokeError("get_remote_session", { sessionId: "missing-e2e-session", }), notSignedIn, ); assert.match( await app.invokeError("stop_remote_session", { sessionId: "missing-e2e-session", }), notSignedIn, ); // The local-launch gate. Nothing has run remotely in this session, so it // is empty — but it must answer, because a UI that cannot read it shows // an enabled Run button over a profile the backend will refuse. const handoff = await app.invoke("get_remote_handoff_states"); assert.ok( handoff && typeof handoff === "object" && !Array.isArray(handoff), "the handoff gate must answer with a profile-keyed object", ); assert.equal(Object.keys(handoff).length, 0); // The transition stream is what the desktop uses instead of polling, so // its subscriber has to start, report itself, and stop on demand. Both // calls are repeated: a second start must not open a second socket, and // a second stop must not fail. assert.equal(await app.invoke("get_remote_session_events_status"), false); await app.invoke("start_remote_session_events"); assert.equal(await app.invoke("get_remote_session_events_status"), true); await app.invoke("start_remote_session_events"); assert.equal(await app.invoke("get_remote_session_events_status"), true); await app.invoke("stop_remote_session_events"); assert.equal(await app.invoke("get_remote_session_events_status"), false); await app.invoke("stop_remote_session_events"); assert.equal(await app.invoke("get_remote_session_events_status"), false); assert.match( await app.invokeError("get_cookie_bot_schedules", { scope: "mine" }), notSignedIn, ); assert.match( await app.invokeError("get_cookie_bot_schedule", { profileId: missingProfileId, }), notSignedIn, ); assert.match( await app.invokeError("delete_cookie_bot_schedule", { profileId: missingProfileId, }), notSignedIn, ); // Saved site lists are cloud-backed like the schedules above, so they // must refuse the same way rather than appearing to work offline. assert.match( await app.invokeError("get_cookie_bot_user_templates", {}), notSignedIn, ); assert.match( await app.invokeError("create_cookie_bot_user_template", { name: "e2e list", sites: ["example.com"], }), notSignedIn, ); assert.match( await app.invokeError("update_cookie_bot_user_template", { id: "00000000-0000-0000-0000-000000000000", name: "renamed", sites: null, }), notSignedIn, ); assert.match( await app.invokeError("delete_cookie_bot_user_template", { id: "00000000-0000-0000-0000-000000000000", }), notSignedIn, ); assert.match( await app.invokeError("check_cookie_bot_conflicts", { profileId: missingProfileId, runAtMinute: 120, daysMask: 127, }), notSignedIn, ); assert.match( await app.invokeError("get_cookie_bot_runs", { limit: 10 }), notSignedIn, ); assert.match( await app.invokeError("cancel_cookie_bot_run", { runId: "missing-e2e-run", }), notSignedIn, ); assert.match( await app.invokeError("get_cookie_bot_presets"), notSignedIn, ); assert.match( await app.invokeError("get_remote_hours_quota"), notSignedIn, ); assert.match( await app.invokeError("get_cookie_bot_usage", { period: "2026-01" }), notSignedIn, ); // Enrolling and running act on a profile this machine holds: both are // refused before any network call when it does not exist, so a bad id // can never reach a leased host or an hour of the pooled budget. assert.match( await app.invokeError("save_cookie_bot_schedule", { profileId: missingProfileId, schedule: { profile_name: "E2E missing profile", platform: "windows", enabled: true, run_at_minute: 120, days_mask: 127, timezone: "UTC", preset: "balanced", max_minutes: 60, sites: ["https://example.com"], }, acknowledgeConflict: false, }), /"code":"PROFILE_NOT_FOUND"/, ); assert.match( await app.invokeError("run_cookie_bot_now", { profileId: missingProfileId, maxMinutes: 30, }), /"code":"PROFILE_NOT_FOUND"/, ); const trial = await app.invoke("get_commercial_trial_status"); assert.ok(trial && typeof trial === "object"); await app.invoke("acknowledge_trial_expiration"); assert.equal(await app.invoke("has_acknowledged_trial_expiration"), true); await app.invoke("cloud_logout"); assert.equal(await app.invoke("cloud_get_user"), null); }, { wayfernTermsAccepted: false }, ); });