feat: extension export via api

This commit is contained in:
zhom
2026-08-16 19:50:12 +04:00
parent 2be0d4df0b
commit d7f002d8ac
26 changed files with 4489 additions and 443 deletions
+743 -33
View File
@@ -7,7 +7,7 @@ use crate::tag_manager::TAG_MANAGER;
use axum::{
extract::{
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
Path, Query, State,
DefaultBodyLimit, Path, Query, State,
},
http::{header, HeaderMap, Method, StatusCode},
middleware::{self, Next},
@@ -41,6 +41,10 @@ pub struct ApiProfile {
pub is_running: bool,
pub proxy_bypass_rules: Vec<String>,
pub vpn_id: Option<String>,
/// Extension group loaded into the browser at launch. Settable via
/// `PUT /v1/profiles/{id}`; exposed here so a caller can read back what it
/// set instead of having to go through the desktop app.
pub extension_group_id: Option<String>,
pub clear_on_close: bool,
/// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`.
/// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read
@@ -84,6 +88,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
is_running: profile.process_id.is_some(),
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
extension_group_id: profile.extension_group_id.clone(),
clear_on_close: profile.clear_on_close,
sync_mode: format!("{:?}", profile.sync_mode),
cloud_sync_enabled: profile.is_sync_enabled(),
@@ -416,6 +421,59 @@ struct ImportCookiesResponse {
errors: Vec<String>,
}
/// Add an extension from exactly one source: an uploaded payload
/// (`file_name` together with `file_data_base64`), or `source_path` on the
/// machine running Donut. Supplying both, or neither, is a 400.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateExtensionRequest {
/// Display name. Optional — the manifest's own name wins when it has one,
/// and a blank name is only rejected when the manifest has none either.
pub name: Option<String>,
/// Name of the uploaded file. Its suffix picks the type: `.crx` or `.zip`.
pub file_name: Option<String>,
/// Payload bytes, standard base64. Only meaningful with `file_name`.
pub file_data_base64: Option<String>,
/// Path on this machine: a `.crx`/`.zip`, or an unpacked extension
/// directory holding a top-level `manifest.json`.
pub source_path: Option<String>,
/// Load a `source_path` directory in place instead of copying it into the
/// store, so edits to the folder apply on the next browser start. Directory
/// sources only, and a linked extension never syncs.
pub link: Option<bool>,
}
/// Replace an extension's payload, rename it, or both. Every field is
/// optional, but a request that carries neither a name nor a source has
/// nothing to do and is a 400.
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateExtensionRequest {
/// New display name.
pub name: Option<String>,
/// Name of the replacement upload. Its suffix picks the type: `.crx` or `.zip`.
pub file_name: Option<String>,
/// Replacement payload bytes, standard base64. Only meaningful with `file_name`.
pub file_data_base64: Option<String>,
/// Path on this machine to re-import from: a `.crx`/`.zip`, or an unpacked
/// extension directory.
pub source_path: Option<String>,
/// Load a `source_path` directory in place instead of copying it in.
pub link: Option<bool>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateExtensionGroupRequest {
pub name: String,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateExtensionGroupRequest {
/// New group name.
pub name: Option<String>,
/// Replaces the whole membership list. Omit to leave it untouched; use the
/// `/extensions/{extension_id}` sub-routes to add or remove one member.
pub extension_ids: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, ToSchema)]
struct BatchRunRequest {
/// Profile IDs to launch.
@@ -561,9 +619,17 @@ struct ImportProxiesResponse {
update_vpn,
delete_vpn,
get_extensions,
get_extension_groups,
create_extension_api,
get_extension_api,
update_extension_api,
delete_extension_api,
get_extension_groups,
create_extension_group_api,
get_extension_group_api,
update_extension_group_api,
delete_extension_group_api,
add_extension_to_group_api,
remove_extension_from_group_api,
download_browser_api,
get_browser_versions,
check_browser_downloaded,
@@ -624,6 +690,12 @@ struct ImportProxiesResponse {
OpenUrlRequest,
ImportCookiesRequest,
ImportCookiesResponse,
CreateExtensionRequest,
UpdateExtensionRequest,
CreateExtensionGroupRequest,
UpdateExtensionGroupRequest,
crate::extension_manager::Extension,
crate::extension_manager::ExtensionGroup,
ProxySettings,
DetectedProfilesResponse,
ImportProfilesRequest,
@@ -838,15 +910,37 @@ fn build_v1_router() -> Router<ApiServerState> {
.routes(routes!(import_vpn))
.routes(routes!(export_vpn))
.routes(routes!(get_vpn, update_vpn, delete_vpn))
.routes(routes!(get_extensions))
.routes(routes!(delete_extension_api))
.routes(routes!(get_extension_groups))
.routes(routes!(delete_extension_group_api))
.routes(routes!(get_extension_groups, create_extension_group_api))
.routes(routes!(
get_extension_group_api,
update_extension_group_api,
delete_extension_group_api
))
.routes(routes!(
add_extension_to_group_api,
remove_extension_from_group_api
))
.routes(routes!(download_browser_api))
.routes(routes!(get_browser_versions))
.routes(routes!(check_browser_downloaded))
.split_for_parts();
routes
// The two paths that carry an extension payload, kept apart so the raised
// body limit reaches them and nothing else. Axum's 2 MiB default is smaller
// than plenty of real `.crx` files, and a create that 413s before the
// handler runs is indistinguishable from a broken endpoint. The GET and
// DELETE on these paths ride along because a limit has to be attached per
// path, and neither reads a body.
let (extension_payload_routes, _) = OpenApiRouter::new()
.routes(routes!(get_extensions, create_extension_api))
.routes(routes!(
get_extension_api,
update_extension_api,
delete_extension_api
))
.split_for_parts();
routes.merge(extension_payload_routes.layer(DefaultBodyLimit::max(64 * 1024 * 1024)))
}
// Terms and Conditions check middleware
@@ -1140,6 +1234,11 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
|| lower.contains("not supported on your platform")
|| lower.contains("is not downloaded")
|| lower.contains("terms and conditions")
// Extension-group compatibility: a group holding a Firefox-only add-on,
// or a browser that takes no extensions at all. Both are a caller pairing
// two things that don't go together, not a fault of this machine.
|| lower.contains("is not compatible with")
|| lower.contains("not supported for browser")
{
StatusCode::BAD_REQUEST
} else {
@@ -1509,6 +1608,27 @@ async fn update_profile(
} else {
Some(extension_group_id)
};
// Assigning a group that does not exist, or one holding an extension this
// profile's browser cannot load, fails at launch instead of here unless it
// is checked now — which is what the Tauri and MCP paths already do.
if let Some(group_id) = ext_group.as_deref() {
let browser = {
let profiles = profile_manager
.list_profiles()
.map_err(manager_error_response)?;
profiles
.iter()
.find(|p| p.id.to_string() == id)
.map(|p| p.browser.clone())
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
format!("Profile with ID '{id}' not found"),
)
})?
};
with_extension_manager(|mgr| mgr.validate_group_compatibility(group_id, &browser))?;
}
if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) {
return Err(manager_error_response(e));
}
@@ -2255,44 +2375,214 @@ async fn delete_vpn(
// Extension API endpoints
/// Take the extension store once, in one place, so a poisoned lock answers 500
/// instead of panicking the request thread, and every handler classifies the
/// manager's `{"code": ...}` errors identically.
fn with_extension_manager<T>(
action: impl FnOnce(
&crate::extension_manager::ExtensionManager,
) -> Result<T, Box<dyn std::error::Error>>,
) -> Result<T, (StatusCode, String)> {
let manager = crate::extension_manager::EXTENSION_MANAGER
.lock()
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"extension manager unavailable".to_string(),
)
})?;
action(&manager).map_err(manager_error_response)
}
/// The payload an extension write carries, once the request body has been
/// reduced to the single source it is allowed to name.
#[derive(Debug)]
enum ExtensionSource {
Upload {
file_name: String,
data: Vec<u8>,
},
LocalPath {
path: std::path::PathBuf,
link: bool,
},
}
fn extension_request_error(code: &str) -> (StatusCode, String) {
(
StatusCode::BAD_REQUEST,
serde_json::json!({ "code": code }).to_string(),
)
}
/// Reduce a create/update body to the one source it names. `Ok(None)` means it
/// named none, which only an update (a plain rename) may do.
fn resolve_extension_source(
file_name: Option<String>,
file_data_base64: Option<String>,
source_path: Option<String>,
link: Option<bool>,
) -> Result<Option<ExtensionSource>, (StatusCode, String)> {
use base64::Engine as _;
let upload = match (file_name, file_data_base64) {
(Some(name), Some(encoded)) => Some((name, encoded)),
(None, None) => None,
// Half an upload is not a source: honouring it would mean storing an empty
// payload, or inventing a file name and with it a file type.
_ => return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")),
};
let path = source_path.filter(|p| !p.trim().is_empty());
let link = link.unwrap_or(false);
match (upload, path) {
// Two sources is as unanswerable as none: there is no rule for which one
// the caller meant, so neither is guessed at.
(Some(_), Some(_)) => Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")),
(Some((file_name, encoded)), None) => {
if link {
// Linking means "keep loading the folder where it already is". An
// uploaded archive has no folder on this machine to point at.
return Err(extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY"));
}
let data = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|_| extension_request_error("EXTENSION_INVALID_BASE64"))?;
Ok(Some(ExtensionSource::Upload { file_name, data }))
}
(None, Some(path)) => Ok(Some(ExtensionSource::LocalPath {
path: std::path::PathBuf::from(path),
link,
})),
(None, None) => Ok(None),
}
}
#[utoipa::path(
get,
path = "/v1/extensions",
responses(
(status = 200, description = "List of extensions"),
(status = 200, description = "List of extensions", body = Vec<crate::extension_manager::Extension>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extensions(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::Extension>>, StatusCode> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.list_extensions()
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
) -> Result<Json<Vec<crate::extension_manager::Extension>>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.list_extensions()).map(Json)
}
/// Add an extension.
///
/// The body names exactly one source:
/// - `file_name` plus `file_data_base64` uploads a `.crx`/`.zip`.
/// - `source_path` reads a `.crx`/`.zip`, or packs an unpacked extension
/// directory, from the machine running Donut. `link: true` loads that
/// directory in place instead, so edits apply on the next browser start;
/// a linked extension is machine-local and never syncs.
///
/// `name` may be omitted: the manifest's own name is preferred anyway, and it
/// is only an error when the manifest has none either.
#[utoipa::path(
get,
path = "/v1/extension-groups",
post,
path = "/v1/extensions",
request_body = CreateExtensionRequest,
responses(
(status = 200, description = "List of extension groups"),
(status = 201, description = "Extension added", body = crate::extension_manager::Extension),
(status = 400, description = "No source, two sources, undecodable payload, unsupported file type, or an unreadable unpacked directory"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "source_path does not exist"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extension_groups(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, StatusCode> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.list_groups()
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
async fn create_extension_api(
Json(request): Json<CreateExtensionRequest>,
) -> Result<(StatusCode, Json<crate::extension_manager::Extension>), (StatusCode, String)> {
let source = resolve_extension_source(
request.file_name,
request.file_data_base64,
request.source_path,
request.link,
)?
.ok_or_else(|| extension_request_error("EXTENSION_SOURCE_REQUIRED"))?;
let name = request.name.unwrap_or_default();
let extension = with_extension_manager(|mgr| match source {
ExtensionSource::Upload { file_name, data } => mgr.add_extension(name, file_name, data),
ExtensionSource::LocalPath { path, link } => mgr.add_extension_from_path(name, &path, link),
})?;
Ok((StatusCode::CREATED, Json(extension)))
}
#[utoipa::path(
get,
path = "/v1/extensions/{id}",
params(("id" = String, Path, description = "Extension ID")),
responses(
(status = 200, description = "Extension", body = crate::extension_manager::Extension),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extension_api(
Path(id): Path<String>,
) -> Result<Json<crate::extension_manager::Extension>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.get_extension(&id)).map(Json)
}
/// Replace an extension's payload, rename it, or both.
///
/// Sources are the same as on create, and every field is optional — but a body
/// carrying neither a name nor a source asks for nothing and is refused rather
/// than answered with an unchanged extension.
#[utoipa::path(
put,
path = "/v1/extensions/{id}",
params(("id" = String, Path, description = "Extension ID")),
request_body = UpdateExtensionRequest,
responses(
(status = 200, description = "Extension updated", body = crate::extension_manager::Extension),
(status = 400, description = "Nothing to change, two sources, undecodable payload, unsupported file type, or an unreadable unpacked directory"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension not found, or source_path does not exist"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn update_extension_api(
Path(id): Path<String>,
Json(request): Json<UpdateExtensionRequest>,
) -> Result<Json<crate::extension_manager::Extension>, (StatusCode, String)> {
let name = request.name;
let source = resolve_extension_source(
request.file_name,
request.file_data_base64,
request.source_path,
request.link,
)?;
if name.is_none() && source.is_none() {
return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED"));
}
with_extension_manager(|mgr| match source {
Some(ExtensionSource::Upload { file_name, data }) => {
mgr.update_extension(&id, name, Some(file_name), Some(data))
}
Some(ExtensionSource::LocalPath { path, link }) => {
mgr.update_extension_from_path(&id, name, &path, link)
}
None => mgr.update_extension(&id, name, None, None),
})
.map(Json)
}
#[utoipa::path(
@@ -2312,11 +2602,88 @@ async fn delete_extension_api(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.delete_extension(&state.app_handle, &id)
with_extension_manager(|mgr| mgr.delete_extension(&state.app_handle, &id))
.map(|_| StatusCode::NO_CONTENT)
.map_err(manager_error_response)
}
#[utoipa::path(
get,
path = "/v1/extension-groups",
responses(
(status = 200, description = "List of extension groups", body = Vec<crate::extension_manager::ExtensionGroup>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extension_groups(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.list_groups()).map(Json)
}
#[utoipa::path(
post,
path = "/v1/extension-groups",
request_body = CreateExtensionGroupRequest,
responses(
(status = 201, description = "Extension group created", body = crate::extension_manager::ExtensionGroup),
(status = 400, description = "Empty or duplicate name"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn create_extension_group_api(
Json(request): Json<CreateExtensionGroupRequest>,
) -> Result<(StatusCode, Json<crate::extension_manager::ExtensionGroup>), (StatusCode, String)> {
let group = with_extension_manager(|mgr| mgr.create_group(request.name))?;
Ok((StatusCode::CREATED, Json(group)))
}
#[utoipa::path(
get,
path = "/v1/extension-groups/{id}",
params(("id" = String, Path, description = "Extension Group ID")),
responses(
(status = 200, description = "Extension group", body = crate::extension_manager::ExtensionGroup),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension group not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extension_group_api(
Path(id): Path<String>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.get_group(&id)).map(Json)
}
/// Rename a group, replace its whole membership list, or both. `extension_ids`
/// is a replacement, not an addition — omit it to leave membership alone.
#[utoipa::path(
put,
path = "/v1/extension-groups/{id}",
params(("id" = String, Path, description = "Extension Group ID")),
request_body = UpdateExtensionGroupRequest,
responses(
(status = 200, description = "Extension group updated", body = crate::extension_manager::ExtensionGroup),
(status = 400, description = "Empty or duplicate name"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension group not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn update_extension_group_api(
Path(id): Path<String>,
Json(request): Json<UpdateExtensionGroupRequest>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.update_group(&id, request.name, request.extension_ids)).map(Json)
}
#[utoipa::path(
@@ -2336,11 +2703,55 @@ async fn delete_extension_group_api(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.delete_group(&state.app_handle, &id)
with_extension_manager(|mgr| mgr.delete_group(&state.app_handle, &id))
.map(|_| StatusCode::NO_CONTENT)
.map_err(manager_error_response)
}
/// Add one extension to a group. Adding a member it already has is a no-op,
/// not an error, so a client re-running its setup converges.
#[utoipa::path(
post,
path = "/v1/extension-groups/{id}/extensions/{extension_id}",
params(
("id" = String, Path, description = "Extension Group ID"),
("extension_id" = String, Path, description = "Extension ID to add"),
),
responses(
(status = 200, description = "Extension group with the extension added", body = crate::extension_manager::ExtensionGroup),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension or extension group not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn add_extension_to_group_api(
Path((id, extension_id)): Path<(String, String)>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.add_extension_to_group(&id, &extension_id)).map(Json)
}
/// Remove one extension from a group. The extension itself is untouched.
#[utoipa::path(
delete,
path = "/v1/extension-groups/{id}/extensions/{extension_id}",
params(
("id" = String, Path, description = "Extension Group ID"),
("extension_id" = String, Path, description = "Extension ID to remove"),
),
responses(
(status = 200, description = "Extension group with the extension removed", body = crate::extension_manager::ExtensionGroup),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension group not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn remove_extension_from_group_api(
Path((id, extension_id)): Path<(String, String)>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.remove_extension_from_group(&id, &extension_id)).map(Json)
}
// API Handler - Run Profile with Remote Debugging
@@ -4304,6 +4715,21 @@ mod tests {
(Method::GET, "/v1/remote-hours"),
// A run id is required; the collection DELETE is not a route.
(Method::DELETE, "/v1/cookie-bot/runs/"),
// Extension writes touch this machine's own store. They start no
// browser, so metering them would spend an automation client's quota on
// uploading a `.crx`.
(Method::POST, "/v1/extensions"),
(Method::PUT, "/v1/extensions/extension-id"),
(Method::DELETE, "/v1/extensions/extension-id"),
(Method::POST, "/v1/extension-groups"),
(
Method::POST,
"/v1/extension-groups/group-id/extensions/extension-id",
),
(
Method::DELETE,
"/v1/extension-groups/group-id/extensions/extension-id",
),
] {
assert!(
!is_automation_request(&method, path),
@@ -4642,6 +5068,44 @@ mod tests {
"{field} must be optional on the quota, required list: {quota:?}"
);
}
// An extension arrives either as an upload or as a path, and its name is
// usually read from the manifest. Marking any one of these required would
// make a generated client send a field that contradicts the source it
// actually has.
for request in ["CreateExtensionRequest", "UpdateExtensionRequest"] {
let fields = schema_required(&spec, request);
assert!(
fields.is_empty(),
"every field of {request} must be optional, required list: {fields:?}"
);
}
let update_extension_group = schema_required(&spec, "UpdateExtensionGroupRequest");
for field in ["name", "extension_ids"] {
assert!(
!update_extension_group.iter().any(|f| f == field),
"{field} must be optional on a group update, required list: {update_extension_group:?}"
);
}
// A group cannot be created without one.
let create_extension_group = schema_required(&spec, "CreateExtensionGroupRequest");
assert!(
create_extension_group.iter().any(|f| f == "name"),
"name is required to create a group, required list: {create_extension_group:?}"
);
// `linked_path` is only set for an extension loaded in place, and every
// extension stored before unpacked support has neither it nor a
// `source_kind`, so a required marking would break reading them back.
let extension = schema_required(&spec, "Extension");
for field in ["linked_path", "version", "description", "author"] {
assert!(
!extension.iter().any(|f| f == field),
"{field} must be optional on an extension, required list: {extension:?}"
);
}
}
#[test]
@@ -4686,6 +5150,174 @@ mod tests {
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn a_rejected_extension_upload_is_the_callers_problem_not_a_server_fault() {
// Every one of these is something about the request: the wrong file type, a
// folder with no readable manifest, a link asked for on a file. Answering
// 500 would tell a client to retry a body that can never be accepted.
for code in [
"EXTENSION_UNSUPPORTED_FILE_TYPE",
"EXTENSION_NOT_A_DIRECTORY",
"EXTENSION_MANIFEST_MISSING",
"EXTENSION_MANIFEST_INVALID",
"EXTENSION_DIR_TOO_LARGE",
"EXTENSION_PATH_HAS_COMMA",
"EXTENSION_LINK_REQUIRES_DIRECTORY",
"NAME_CANNOT_BE_EMPTY",
] {
let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
assert_eq!(status, StatusCode::BAD_REQUEST, "{code} must be a 400");
assert!(body.contains(code), "{code} must reach the caller");
}
// A path that is not there is the one refusal that names a missing thing.
let (status, _) =
manager_error_response(serde_json::json!({ "code": "EXTENSION_DIR_NOT_FOUND" }).to_string());
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[test]
fn an_incompatible_extension_group_is_refused_rather_than_reported_as_broken() {
// `validate_group_compatibility` answers in prose, so both of its refusals
// fell through to 500 — which reads as "this server is broken" for what is
// really a group the caller cannot put on that profile.
for message in [
"Extension 'uBlock' (crx) is not compatible with gecko browsers",
"Extensions are not supported for browser 'firefox'",
] {
let (status, body) = manager_error_response(message);
assert_eq!(status, StatusCode::BAD_REQUEST, "{message} must be a 400");
assert_eq!(body, message, "the diagnostic must reach the caller");
}
// A group that is simply absent stays a 404.
let (status, _) = manager_error_response("Extension group with id 'gone' not found");
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[test]
fn an_extension_write_names_exactly_one_source() {
use base64::Engine as _;
let encoded = base64::engine::general_purpose::STANDARD.encode(b"PK\x03\x04");
// An upload decodes to the bytes the caller sent.
match resolve_extension_source(
Some("ublock.crx".to_string()),
Some(encoded.clone()),
None,
None,
) {
Ok(Some(ExtensionSource::Upload { file_name, data })) => {
assert_eq!(file_name, "ublock.crx");
assert_eq!(data, b"PK\x03\x04");
}
other => panic!("an upload must resolve to its bytes: {other:?}"),
}
// A path is taken as-is, and `link` rides with it.
match resolve_extension_source(None, None, Some("/srv/ext".to_string()), Some(true)) {
Ok(Some(ExtensionSource::LocalPath { path, link })) => {
assert_eq!(path, std::path::PathBuf::from("/srv/ext"));
assert!(link);
}
other => panic!("a path must resolve to a path: {other:?}"),
}
// Naming both sources has no answer: neither one is guessed at.
let both = resolve_extension_source(
Some("ublock.crx".to_string()),
Some(encoded.clone()),
Some("/srv/ext".to_string()),
None,
);
assert_eq!(
both.expect_err("two sources must be refused"),
extension_request_error("EXTENSION_SOURCE_REQUIRED")
);
// Half an upload is not a source. Storing an empty payload, or inventing a
// file name, would both produce an extension that never loads.
for half in [
(Some("ublock.crx".to_string()), None),
(None, Some(encoded.clone())),
] {
let (file_name, data) = half;
assert_eq!(
resolve_extension_source(file_name, data, None, None)
.expect_err("half an upload must be refused"),
extension_request_error("EXTENSION_SOURCE_REQUIRED")
);
}
// Linking means "load the folder where it is"; an upload has no folder.
assert_eq!(
resolve_extension_source(
Some("ublock.crx".to_string()),
Some(encoded),
None,
Some(true)
)
.expect_err("a linked upload must be refused"),
extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY")
);
// Undecodable base64 is named as such rather than reaching the store as an
// empty or truncated archive.
assert_eq!(
resolve_extension_source(
Some("ublock.crx".to_string()),
Some("not base64!!".to_string()),
None,
None
)
.expect_err("undecodable base64 must be refused"),
extension_request_error("EXTENSION_INVALID_BASE64")
);
// No source at all is legal on the wire — it is a rename, and only the
// update handler accepts it.
assert!(resolve_extension_source(None, None, None, None)
.expect("naming no source is not an error here")
.is_none());
// An empty string is not a path.
assert!(
resolve_extension_source(None, None, Some(" ".to_string()), None)
.expect("a blank path is not an error here")
.is_none()
);
}
#[test]
fn an_extension_update_that_asks_for_nothing_is_refused() {
// Every field is optional, so an empty body parses. Answering 200 with an
// untouched extension would tell a client its rename landed.
let empty: UpdateExtensionRequest =
serde_json::from_str("{}").expect("an empty update body must deserialize");
assert!(empty.name.is_none());
assert!(resolve_extension_source(
empty.file_name,
empty.file_data_base64,
empty.source_path,
empty.link
)
.expect("no source is not an error on update")
.is_none());
}
#[test]
fn creating_an_extension_needs_neither_a_name_nor_a_link() {
// The manifest's own name is preferred, so a caller uploading a `.crx`
// sends two fields and nothing else.
let minimal: CreateExtensionRequest =
serde_json::from_str(r#"{"file_name": "ublock.crx", "file_data_base64": "UEsDBA=="}"#)
.expect("a minimal create body must deserialize");
assert!(minimal.name.is_none());
assert!(minimal.link.is_none());
assert_eq!(minimal.file_name.as_deref(), Some("ublock.crx"));
}
#[test]
fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() {
// Without this route `run-remote` hands back a session id that nothing
@@ -4763,6 +5395,7 @@ mod tests {
"/v1/extension-groups",
"/v1/extensions/{id}",
"/v1/extension-groups/{id}",
"/v1/extension-groups/{id}/extensions/{extension_id}",
"/v1/profiles/import",
"/v1/profiles/import/detect",
"/v1/proxies/import",
@@ -4801,6 +5434,26 @@ mod tests {
("/v1/cookie-bot/runs", "get"),
("/v1/cookie-bot/runs", "post"),
("/v1/cookie-bot/runs/{run_id}", "delete"),
// The extension surface is five paths carrying eleven methods, so it is
// the densest place in the router for one `routes!` to swallow another.
("/v1/extensions", "get"),
("/v1/extensions", "post"),
("/v1/extensions/{id}", "get"),
("/v1/extensions/{id}", "put"),
("/v1/extensions/{id}", "delete"),
("/v1/extension-groups", "get"),
("/v1/extension-groups", "post"),
("/v1/extension-groups/{id}", "get"),
("/v1/extension-groups/{id}", "put"),
("/v1/extension-groups/{id}", "delete"),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"post",
),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"delete",
),
] {
assert!(
paths[path].get(method).is_some(),
@@ -4865,6 +5518,14 @@ mod tests {
"RemoteHoursQuota",
"RemoteHoursMember",
"RemoteHoursBreakdown",
// Neither extension type was registered while only the list and delete
// routes existed, so every extension response resolved to nothing.
"Extension",
"ExtensionGroup",
"CreateExtensionRequest",
"UpdateExtensionRequest",
"CreateExtensionGroupRequest",
"UpdateExtensionGroupRequest",
] {
assert!(
spec["components"]["schemas"][schema]["properties"].is_object(),
@@ -4895,6 +5556,15 @@ mod tests {
"RemoteSessionState",
),
("/v1/remote-hours", "get", "200", "RemoteHoursQuota"),
("/v1/extensions", "post", "201", "Extension"),
("/v1/extensions/{id}", "put", "200", "Extension"),
("/v1/extension-groups", "post", "201", "ExtensionGroup"),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"post",
"200",
"ExtensionGroup",
),
] {
let reference =
&paths[path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"];
@@ -4905,6 +5575,23 @@ mod tests {
);
}
// Both extension lists declared a 200 with no body at all, so a generated
// client got a call that returns nothing from a route that returns
// everything. Each must be an array of the same component its single-item
// route resolves to.
for (path, schema) in [
("/v1/extensions", "Extension"),
("/v1/extension-groups", "ExtensionGroup"),
] {
let item = &paths[path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
["items"]["$ref"];
assert_eq!(
item.as_str(),
Some(format!("#/components/schemas/{schema}").as_str()),
"get {path} 200 is not a list of {schema}: {item:?}"
);
}
// The presets a client may choose from must never carry the behaviour they
// expand to. A site list, a dwell range or a step programme appearing here
// would mean the browsing model had leaked out of the server.
@@ -4967,5 +5654,28 @@ mod tests {
"a schedule write must not declare a 429: {method}"
);
}
// Extension writes launch nothing and lease nothing, so the limiter never
// sees them. A declared 429 would be a status the server cannot produce.
for (path, method) in [
("/v1/extensions", "post"),
("/v1/extensions/{id}", "put"),
("/v1/extensions/{id}", "delete"),
("/v1/extension-groups", "post"),
("/v1/extension-groups/{id}", "put"),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"post",
),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"delete",
),
] {
assert!(
paths[path][method]["responses"].get("429").is_none(),
"an extension write must not declare a 429: {method} {path}"
);
}
}
}
+7
View File
@@ -1328,6 +1328,13 @@ impl BrowserRunner {
crate::profile::clear_on_close::clear_profile_browsing_data(profile).await;
}
// The browser held these open for the life of the process; nothing reads
// them once it has exited, and they are plaintext extension code sitting
// on real disk even for an ephemeral profile.
crate::extension_manager::ExtensionManager::cleanup_unpacked_for_profile(
&profile.id.to_string(),
);
log::info!(
"Wayfern process cleanup completed for profile: {} (ID: {})",
profile.name,
+7
View File
@@ -804,6 +804,13 @@ impl CloudAuthManager {
/// Account is in a paid/active state. Used for the "any active plan" gates
/// (sync token); per-feature access uses the capability helpers.
pub async fn has_active_paid_subscription(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
{
return true;
}
self.entitlements().await.map(|e| e.active).unwrap_or(false)
}
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -188,9 +188,10 @@ use profile_importer::{
};
use extension_manager::{
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon,
list_extension_groups, list_extensions, remove_extension_from_group, update_extension,
add_extension, add_extension_to_group, add_unpacked_extension, assign_extension_group_to_profile,
create_extension_group, delete_extension, delete_extension_group,
get_extension_group_for_profile, get_extension_icon, list_extension_groups, list_extensions,
remove_extension_from_group, update_extension, update_extension_from_path,
update_extension_group,
};
@@ -2750,7 +2751,9 @@ pub fn run_with_builder(
list_extensions,
get_extension_icon,
add_extension,
add_unpacked_extension,
update_extension,
update_extension_from_path,
delete_extension,
list_extension_groups,
create_extension_group,
+262 -2
View File
@@ -1307,6 +1307,33 @@ impl McpServer {
"required": []
}),
},
McpTool {
name: "add_extension".to_string(),
description: "Add a managed browser extension from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so edits to it apply on the next browser start and the extension is machine-local and never synced. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path on the machine running Donut to a .crx/.zip file or to an unpacked extension folder" },
"name": { "type": "string", "description": "Display name, used only when the manifest carries no name of its own" },
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
},
"required": ["path"]
}),
},
McpTool {
name: "update_extension".to_string(),
description: "Rename a managed extension and/or replace its payload from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so the extension becomes machine-local and never syncs. At least one of name or path must be given. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"extension_id": { "type": "string", "description": "The extension ID to update" },
"name": { "type": "string", "description": "New display name" },
"path": { "type": "string", "description": "Path on the machine running Donut to the .crx/.zip file or unpacked extension folder to replace the payload with" },
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
},
"required": ["extension_id"]
}),
},
McpTool {
name: "create_extension_group".to_string(),
description: "Create a new extension group. Requires Pro subscription.".to_string(),
@@ -1318,6 +1345,47 @@ impl McpServer {
"required": ["name"]
}),
},
McpTool {
name: "update_extension_group".to_string(),
description: "Rename an extension group and/or replace its membership with an exact list of extension IDs. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID to update" },
"name": { "type": "string", "description": "New name for the extension group" },
"extension_ids": {
"type": "array",
"items": { "type": "string" },
"description": "The complete set of extension IDs the group should contain, replacing the current membership"
}
},
"required": ["group_id"]
}),
},
McpTool {
name: "add_extension_to_group".to_string(),
description: "Add an extension to an extension group. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID" },
"extension_id": { "type": "string", "description": "The extension ID to add to the group" }
},
"required": ["group_id", "extension_id"]
}),
},
McpTool {
name: "remove_extension_from_group".to_string(),
description: "Remove an extension from an extension group. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID" },
"extension_id": { "type": "string", "description": "The extension ID to remove from the group" }
},
"required": ["group_id", "extension_id"]
}),
},
McpTool {
name: "delete_extension".to_string(),
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
@@ -2215,7 +2283,12 @@ impl McpServer {
// Extension management
"list_extensions" => self.handle_list_extensions().await,
"list_extension_groups" => self.handle_list_extension_groups().await,
"add_extension" => self.handle_add_extension(arguments).await,
"update_extension" => self.handle_update_extension(arguments).await,
"create_extension_group" => self.handle_create_extension_group(arguments).await,
"update_extension_group" => self.handle_update_extension_group(arguments).await,
"add_extension_to_group" => self.handle_add_extension_to_group(arguments).await,
"remove_extension_from_group" => self.handle_remove_extension_from_group(arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
"assign_extension_group_to_profile" => {
@@ -4402,6 +4475,88 @@ impl McpServer {
Ok(serde_json::to_value(groups).unwrap())
}
async fn handle_add_extension(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let path = arguments
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: path".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let link = arguments
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = mgr
.add_extension_from_path(name, std::path::Path::new(path), link)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to add extension: {e}"),
})?;
Ok(serde_json::to_value(extension).unwrap())
}
async fn handle_update_extension(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let extension_id = arguments
.get("extension_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: extension_id".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string);
let path = arguments.get("path").and_then(|v| v.as_str());
if name.is_none() && path.is_none() {
return Err(McpError {
code: -32602,
message: "Provide at least one of: name, path".to_string(),
});
}
let link = arguments
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = match path {
Some(path) => {
mgr.update_extension_from_path(extension_id, name, std::path::Path::new(path), link)
}
None => mgr.update_extension(extension_id, name, None, None),
}
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update extension: {e}"),
})?;
Ok(serde_json::to_value(extension).unwrap())
}
async fn handle_create_extension_group(
&self,
arguments: &serde_json::Value,
@@ -4427,6 +4582,106 @@ impl McpServer {
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_update_extension_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: group_id".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string);
let extension_ids = arguments
.get("extension_ids")
.and_then(|v| v.as_array())
.map(|ids| {
ids
.iter()
.filter_map(|id| id.as_str().map(str::to_string))
.collect::<Vec<String>>()
});
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.update_group(group_id, name, extension_ids)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update extension group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_add_extension_to_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.add_extension_to_group(group_id, extension_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to add extension to group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_remove_extension_from_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.remove_extension_from_group(group_id, extension_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to remove extension from group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
fn group_and_extension_ids(arguments: &serde_json::Value) -> Result<(&str, &str), McpError> {
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: group_id".to_string(),
})?;
let extension_id = arguments
.get("extension_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: extension_id".to_string(),
})?;
Ok((group_id, extension_id))
}
async fn handle_delete_extension_mcp(
&self,
arguments: &serde_json::Value,
@@ -6035,9 +6290,9 @@ mod tests {
let server = McpServer::new();
let tools = server.get_tools();
// Should have at least 54 tools (34 + 7 browser interaction + 13 remote
// Should have at least 59 tools (39 + 7 browser interaction + 13 remote
// fleet and cookie-bot tools)
assert!(tools.len() >= 54);
assert!(tools.len() >= 59);
// Names are the contract an MCP client is written against, so a duplicate
// silently shadows one of the two in dispatch and the tool that loses is
@@ -6092,7 +6347,12 @@ mod tests {
// Extension tools
assert!(tool_names.contains(&"list_extensions"));
assert!(tool_names.contains(&"list_extension_groups"));
assert!(tool_names.contains(&"add_extension"));
assert!(tool_names.contains(&"update_extension"));
assert!(tool_names.contains(&"create_extension_group"));
assert!(tool_names.contains(&"update_extension_group"));
assert!(tool_names.contains(&"add_extension_to_group"));
assert!(tool_names.contains(&"remove_extension_from_group"));
assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
+18 -2
View File
@@ -2084,6 +2084,13 @@ impl SyncEngine {
manager.get_extension(ext_id).ok()
};
// A linked extension is an absolute path on this machine with no payload in
// the store. Uploading it would publish metadata another device could never
// resolve, so it stays local whatever queued this run.
if local_ext.as_ref().is_some_and(|e| e.is_linked()) {
return Ok(());
}
let remote_key = format!("extensions/{}.json", ext_id);
let stat = self.client.stat(&remote_key).await?;
@@ -3251,7 +3258,9 @@ pub async fn enable_extension_group_sync_if_needed(extension_group_id: &str) ->
manager
.get_extension(ext_id)
.ok()
.map(|e| e.sync_enabled)
// A linked extension has no binary to hand the other device, only a
// path that means nothing there, so the cascade must not pick it up.
.map(|e| e.sync_enabled || e.is_linked())
.unwrap_or(true)
};
if !already_synced {
@@ -3983,7 +3992,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
.map_err(|e| format!("Failed to list extensions: {e}"))?
};
for ext in &exts {
if !ext.sync_enabled {
// Linked extensions are machine-local by definition and are skipped
// rather than reported as a failure on every sync setup.
if !ext.sync_enabled && !ext.is_linked() {
if let Err(e) = set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await {
log::warn!("Failed to enable sync for extension {}: {e}", ext.id);
}
@@ -4029,6 +4040,11 @@ pub async fn set_extension_sync_enabled(
};
if enabled {
// A linked extension is a path on this machine and nothing else; there is
// no payload to upload and the path would be meaningless on another device.
if ext.is_linked() {
return Err(serde_json::json!({ "code": "EXTENSION_LINKED_CANNOT_SYNC" }).to_string());
}
ensure_sync_configured(&app_handle).await?;
}