Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_TITLE: &str = "Warp";
|
||||
const BASE_APP_PATH: &str = "/app";
|
||||
|
||||
pub fn update_browser_url(url: Option<Url>, force_redirect: bool) {
|
||||
let mut new_url = url;
|
||||
if new_url.is_none() {
|
||||
new_url = get_base_app_url()
|
||||
}
|
||||
|
||||
if let Some(unwrapped_url) = new_url {
|
||||
let window = gloo::utils::window();
|
||||
if force_redirect {
|
||||
let _ = window.location().set_href(unwrapped_url.as_str());
|
||||
} else if let Ok(history) = window.history() {
|
||||
history
|
||||
.replace_state_with_url(
|
||||
&wasm_bindgen::JsValue::null(),
|
||||
DEFAULT_TITLE,
|
||||
Some(unwrapped_url.as_str()),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
log::error!("Failed to replace browser state");
|
||||
crate::platform::wasm::emit_event(
|
||||
crate::platform::wasm::WarpEvent::ErrorLogged {
|
||||
error: String::from("Failed to replace browser state"),
|
||||
},
|
||||
);
|
||||
});
|
||||
} else {
|
||||
log::error!("Failed to get gloo history while trying to update browser url");
|
||||
}
|
||||
} else {
|
||||
log::error!("Failed to get new url to update browser with");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_current_url() -> Option<Url> {
|
||||
let loc = gloo::utils::document().location();
|
||||
let unwrapped_loc = loc.as_ref()?;
|
||||
|
||||
let the_href = unwrapped_loc.href();
|
||||
if the_href.is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(parsed_url) = Url::parse(the_href.expect("Invalid href parsed from url").as_str()) {
|
||||
return Some(parsed_url);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn get_base_app_url() -> Option<Url> {
|
||||
if let Some(current_url) = parse_current_url() {
|
||||
let mut new_url = current_url.clone();
|
||||
new_url.set_path(BASE_APP_PATH);
|
||||
new_url.set_query(None);
|
||||
return Some(new_url);
|
||||
}
|
||||
log::error!("Failed to get the base url");
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use std::{collections::HashMap, fmt::Display};
|
||||
|
||||
use crate::{
|
||||
send_telemetry_from_app_ctx, server::telemetry::TelemetryEvent, terminal::shell::ShellType,
|
||||
};
|
||||
use regex::Regex;
|
||||
use url::Url;
|
||||
use warp_util::path::{is_posix_portable_pathname, ShellFamily};
|
||||
use warpui::AppContext;
|
||||
|
||||
use crate::root_view::SubshellCommandArg;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
/// String of hex digits meant to represent a Docker container ID.
|
||||
#[derive(Debug)]
|
||||
struct DockerContainerId(String);
|
||||
|
||||
impl TryFrom<String> for DockerContainerId {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(input: String) -> Result<Self, Self::Error> {
|
||||
// Note: We could do a further check to validate that this Docker container ID actually exists and/or is running.
|
||||
|
||||
if input.is_empty() || input.len() > 64 {
|
||||
Err(anyhow!(
|
||||
"Docker container IDs must be between 1 and 64 bytes long"
|
||||
))
|
||||
} else if input.chars().any(|c| !c.is_ascii_hexdigit()) {
|
||||
Err(anyhow!(
|
||||
"Could not find valid docker container id to open warpified shell"
|
||||
))
|
||||
} else {
|
||||
Ok(DockerContainerId(input))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for DockerContainerId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a Url with query parameters in the correct format, dispatch an action to create a new tab
|
||||
/// (or open a new window if there is no window), then run a command to open a subshell into the
|
||||
/// specified Docker container, and then warpify that new subshell.
|
||||
pub fn open_docker_container(url: &Url, ctx: &mut AppContext) -> Result<()> {
|
||||
let query_params: HashMap<String, String> = url
|
||||
.query_pairs()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
|
||||
let container_id = query_params
|
||||
.get("container_id")
|
||||
.and_then(|container_id| DockerContainerId::try_from(container_id.to_owned()).ok())
|
||||
.ok_or(anyhow!("no valid container ID parameter found"))?;
|
||||
|
||||
let shell_path = query_params
|
||||
.get("shell")
|
||||
// TODO(CORE-2658): Make this filter less restrictive without reducing security.
|
||||
.filter(|shell_path| is_posix_portable_pathname(shell_path))
|
||||
// TODO(CORE-2658): Our Docker extension lets users specify any shell, but we're only accepting
|
||||
// shells we can bootstrap. We should probably change the Docker extension to only surface
|
||||
// shells we can bootstrap.
|
||||
.filter(|shell_path| ShellType::from_name(shell_path).is_some())
|
||||
.ok_or(anyhow!("no valid shell parameter found"))?;
|
||||
|
||||
// This NAME_REGEX specifies this format of linux user names. It's a very
|
||||
// common pattern, but some systems might have a different configuration.
|
||||
let username_pattern =
|
||||
Regex::new(r"^[a-z][-a-z0-9_]*\$?$").expect("NAME_REGEX should be valid.");
|
||||
|
||||
let user = match query_params.get("user") {
|
||||
Some(user) if username_pattern.is_match(user.as_str()) => Some(user),
|
||||
Some(_) => anyhow::bail!("Invalid user parameter found."),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Command example: docker exec -it --user 'admin' 'container_id' 'zsh'.
|
||||
// TODO(CORE-2658): This [`ShellFamily::shell_escape`] function is built with `bash` in mind but we need to
|
||||
// properly escape for all our officially supported shells.
|
||||
// Assume MacOS/Linux and therefore POSIX shell. Running Docker on Windows requires WSL anyway.
|
||||
let mut docker_exec_command = String::from("docker exec -it");
|
||||
if let Some(user) = user {
|
||||
docker_exec_command
|
||||
.push_str(format!(" --user '{}' ", ShellFamily::Posix.shell_escape(user)).as_str());
|
||||
}
|
||||
// We don't need to escape the container_id because we already checked that it had no special
|
||||
// characters.
|
||||
docker_exec_command.push_str(
|
||||
format!(
|
||||
" '{}' '{}'",
|
||||
container_id,
|
||||
ShellFamily::Posix.shell_escape(shell_path)
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
let shell_type = ShellType::from_name(shell_path);
|
||||
|
||||
// Opens a new window if there is none.
|
||||
ctx.dispatch_global_action(
|
||||
"root_view:open_new_tab_insert_subshell_command_and_bootstrap_if_supported",
|
||||
&SubshellCommandArg {
|
||||
command: docker_exec_command,
|
||||
shell_type,
|
||||
},
|
||||
);
|
||||
|
||||
send_telemetry_from_app_ctx!(
|
||||
TelemetryEvent::OpenAndWarpifyDockerSubshell { shell_type },
|
||||
ctx
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "docker_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,58 @@
|
||||
use warpui::App;
|
||||
|
||||
use crate::{
|
||||
auth::{auth_manager::AuthManager, AuthStateProvider},
|
||||
server::{
|
||||
server_api::ServerApiProvider, telemetry::context_provider::AppTelemetryContextProvider,
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
// Tests behavior based on which query parameters are required.
|
||||
fn test_open_docker_container() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(AuthManager::new_for_test);
|
||||
|
||||
let base_url = Url::parse("warplocal://action/docker/open_subshell")
|
||||
.expect("base url should be successfully parsed");
|
||||
|
||||
let container_id = (
|
||||
"container_id",
|
||||
"85aa47c9ef3fbd338cb3cfe45c99eaed0c57f374c3c47bf3da3e44fd6b5c3399",
|
||||
);
|
||||
let shell_path = ("shell", "/bin/bash");
|
||||
let user = ("user", "root");
|
||||
|
||||
let mut container_and_shell = base_url.to_owned();
|
||||
container_and_shell
|
||||
.query_pairs_mut()
|
||||
.append_pair(container_id.0, container_id.1)
|
||||
.append_pair(shell_path.0, shell_path.1);
|
||||
let mut container_shell_user = base_url.to_owned();
|
||||
container_shell_user
|
||||
.query_pairs_mut()
|
||||
.append_pair(container_id.0, container_id.1)
|
||||
.append_pair(shell_path.0, shell_path.1)
|
||||
.append_pair(user.0, user.1);
|
||||
let mut missing_container_id = base_url.to_owned();
|
||||
missing_container_id
|
||||
.query_pairs_mut()
|
||||
.append_pair(shell_path.0, shell_path.1);
|
||||
let mut missing_shell = base_url.to_owned();
|
||||
missing_shell
|
||||
.query_pairs_mut()
|
||||
.append_pair(container_id.0, container_id.1);
|
||||
|
||||
app.update(|app_ctx| {
|
||||
assert!(open_docker_container(&container_shell_user, app_ctx).is_ok());
|
||||
assert!(open_docker_container(&container_and_shell, app_ctx).is_ok());
|
||||
assert!(open_docker_container(&missing_shell, app_ctx).is_err());
|
||||
assert!(open_docker_container(&missing_container_id, app_ctx).is_err());
|
||||
});
|
||||
});
|
||||
}
|
||||
+1350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
use crate::cloud_object::extract_server_id_and_object_type_from_warp_drive_link;
|
||||
use crate::drive::OpenWarpDriveObjectArgs;
|
||||
use crate::ChannelState;
|
||||
use url::Url;
|
||||
|
||||
#[derive(PartialEq, Debug)]
|
||||
pub enum WarpWebLink {
|
||||
Session,
|
||||
DriveObject(Box<OpenWarpDriveObjectArgs>),
|
||||
}
|
||||
|
||||
pub fn get_item_data_from_warp_link(url: &Url) -> Option<WarpWebLink> {
|
||||
if url.origin() == ChannelState::server_root_domain() {
|
||||
url.path_segments().and_then(|mut path_segments| {
|
||||
path_segments.next().and_then(|segment| match segment {
|
||||
"drive" => extract_server_id_and_object_type_from_warp_drive_link(url)
|
||||
.map(|args| WarpWebLink::DriveObject(Box::new(args))),
|
||||
"session" => Some(WarpWebLink::Session),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
use self::parse_url_paths::{get_item_data_from_warp_link, WarpWebLink};
|
||||
use super::*;
|
||||
use crate::launch_configs::launch_config::make_mock_single_window_launch_config;
|
||||
use crate::linear::{LinearAction, LinearIssueWork};
|
||||
use crate::ChannelState;
|
||||
|
||||
#[test]
|
||||
fn test_find_matching_config() {
|
||||
let mut configs: Vec<LaunchConfig> = vec![];
|
||||
for i in 0..5 {
|
||||
add_mock_config_with_name(
|
||||
(String::from("config") + i.to_string().as_str()).as_str(),
|
||||
&mut configs,
|
||||
);
|
||||
}
|
||||
|
||||
let with_extension = "config1.yaml";
|
||||
assert_eq!(
|
||||
find_matching_config(with_extension, &configs),
|
||||
Some(&configs[1])
|
||||
);
|
||||
|
||||
let no_extension = "config4";
|
||||
assert_eq!(
|
||||
find_matching_config(no_extension, &configs),
|
||||
Some(&configs[4])
|
||||
);
|
||||
|
||||
let caps_insensitive = "ConFig3";
|
||||
assert_eq!(
|
||||
find_matching_config(caps_insensitive, &configs),
|
||||
Some(&configs[3])
|
||||
);
|
||||
|
||||
let missing_config = "missing";
|
||||
assert_eq!(find_matching_config(missing_config, &configs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_matching_config_with_spaces() {
|
||||
let mut configs: Vec<LaunchConfig> = vec![];
|
||||
for i in 0..3 {
|
||||
add_mock_config_with_name(
|
||||
(String::from("config") + i.to_string().as_str()).as_str(),
|
||||
&mut configs,
|
||||
);
|
||||
}
|
||||
|
||||
let with_space = "config 3.yaml";
|
||||
add_mock_config_with_name(with_space, &mut configs);
|
||||
assert_eq!(
|
||||
find_matching_config(with_space, &configs),
|
||||
Some(&configs[3])
|
||||
);
|
||||
|
||||
let more_space = " a ";
|
||||
add_mock_config_with_name(more_space, &mut configs);
|
||||
assert_eq!(
|
||||
find_matching_config(more_space, &configs),
|
||||
Some(&configs[4])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_matching_configs_special_chars() {
|
||||
let mut configs: Vec<LaunchConfig> = vec![];
|
||||
for i in 0..3 {
|
||||
add_mock_config_with_name(
|
||||
(String::from("config") + i.to_string().as_str()).as_str(),
|
||||
&mut configs,
|
||||
);
|
||||
}
|
||||
|
||||
// test special characters
|
||||
let special_ascii = "yes! this_works,too-even[braces}and(parens'.";
|
||||
add_mock_config_with_name(special_ascii, &mut configs);
|
||||
assert_eq!(
|
||||
find_matching_config(special_ascii, &configs),
|
||||
Some(&configs[3])
|
||||
);
|
||||
|
||||
// test emojis
|
||||
let bread = "🍞";
|
||||
add_mock_config_with_name(bread, &mut configs);
|
||||
assert_eq!(find_matching_config(bread, &configs), Some(&configs[4]));
|
||||
}
|
||||
|
||||
fn add_mock_config_with_name(name: &str, configs: &mut Vec<LaunchConfig>) {
|
||||
let mut new_config = make_mock_single_window_launch_config();
|
||||
new_config.name = name.to_string();
|
||||
new_config.windows[0].tabs[0].title = Some(String::from("First tab from config ") + name);
|
||||
configs.push(new_config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_launch_config_path() {
|
||||
assert_eq!(
|
||||
get_launch_config_path("/path/to/a/config"),
|
||||
Some(String::from("path/to/a/config")),
|
||||
);
|
||||
assert_eq!(
|
||||
get_launch_config_path("/hello%20world.yaml"),
|
||||
Some(String::from("hello world.yaml")),
|
||||
);
|
||||
assert_eq!(
|
||||
get_launch_config_path("/%3Bhello%20%23world!"),
|
||||
Some(String::from(";hello #world!")),
|
||||
);
|
||||
assert_eq!(
|
||||
get_launch_config_path("/yes%21%20this_works%2Ctoo-even%5Bbraces%7Dand%28parens%27."),
|
||||
Some(String::from("yes! this_works,too-even[braces}and(parens'."))
|
||||
);
|
||||
assert_eq!(
|
||||
get_launch_config_path("/%F0%9F%8D%9E"),
|
||||
Some(String::from("🍞"))
|
||||
);
|
||||
assert_eq!(
|
||||
get_launch_config_path("/..filename_.with_dots.."),
|
||||
Some(String::from("..filename_.with_dots.."))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_launch_config_path_invalid() {
|
||||
assert_eq!(get_launch_config_path(""), None);
|
||||
assert_eq!(get_launch_config_path("/"), None);
|
||||
assert_eq!(get_launch_config_path("%2F"), None);
|
||||
assert_eq!(get_launch_config_path("/../outside"), None);
|
||||
assert_eq!(get_launch_config_path("/..%2Foutside"), None);
|
||||
assert_eq!(get_launch_config_path("/A/.."), None);
|
||||
assert_eq!(get_launch_config_path("/A/../B"), None);
|
||||
assert_eq!(get_launch_config_path("//absolute"), None);
|
||||
assert_eq!(get_launch_config_path("/%2Fabsolute sneaky"), None);
|
||||
assert_eq!(get_launch_config_path("//../very_bad/.."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_extension() {
|
||||
assert_eq!(remove_extension(""), None);
|
||||
assert_eq!(remove_extension(".yaml"), Some(""));
|
||||
assert_eq!(remove_extension(" .yaml"), Some(" "));
|
||||
assert_eq!(remove_extension("config.yaml"), Some("config"));
|
||||
assert_eq!(remove_extension("..yaml"), Some("."));
|
||||
assert_eq!(remove_extension("config"), None);
|
||||
assert_eq!(remove_extension("🍞.yaml"), Some("🍞"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_warp_web_link_notebook() {
|
||||
assert_eq!(
|
||||
get_item_data_from_warp_link(
|
||||
&Url::parse(&format!(
|
||||
"{}/drive/notebook/Performance-Analysis-LkDlnAe34vfYD2JXsAkssc?focused_folder_id=test_uid00000000000123&invitee_email=test@example.com",
|
||||
ChannelState::server_root_url()
|
||||
))
|
||||
.unwrap()
|
||||
),
|
||||
Some(WarpWebLink::DriveObject(Box::new(OpenWarpDriveObjectArgs {
|
||||
object_type: ObjectType::Notebook,
|
||||
server_id: ServerId::from_string_lossy("LkDlnAe34vfYD2JXsAkssc"),
|
||||
settings: OpenWarpDriveObjectSettings {
|
||||
focused_folder_id: Some(ServerId::from(123)),
|
||||
invitee_email: Some(String::from("test@example.com")),
|
||||
},
|
||||
})))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_warp_web_link_session() {
|
||||
assert_eq!(
|
||||
get_item_data_from_warp_link(
|
||||
&Url::parse(&format!(
|
||||
"{}/session/317d0686-7a0b-4b67-806b-aaa3e9df501b?
|
||||
pwd=6f727249-af9f-4025-a240-59df40a4c64b",
|
||||
ChannelState::server_root_url()
|
||||
))
|
||||
.unwrap()
|
||||
),
|
||||
Some(WarpWebLink::Session)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_warp_web_link_workflow() {
|
||||
assert_eq!(
|
||||
get_item_data_from_warp_link(
|
||||
&Url::parse(&format!(
|
||||
"{}/drive/workflow/Remove-all-stopped-docker-container-image-and-volumes-ZCJSkai2gpwTqpBFs5HOfZ",
|
||||
ChannelState::server_root_url()
|
||||
))
|
||||
.unwrap()
|
||||
),
|
||||
Some(WarpWebLink::DriveObject(Box::new(OpenWarpDriveObjectArgs {
|
||||
object_type: ObjectType::Workflow,
|
||||
server_id: ServerId::from_string_lossy("ZCJSkai2gpwTqpBFs5HOfZ"),
|
||||
settings: OpenWarpDriveObjectSettings::default(),
|
||||
})))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_warp_web_link_failure() {
|
||||
assert_eq!(
|
||||
get_item_data_from_warp_link(&Url::parse("https://google.com").unwrap()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_create_environment_parse() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://action/create_environment?repo=foo&repo=bar",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let action = Action::parse(&url).unwrap();
|
||||
match action {
|
||||
Action::CreateEnvironment { repos } => {
|
||||
assert_eq!(repos, vec!["foo".to_owned(), "bar".to_owned()]);
|
||||
}
|
||||
_ => panic!("unexpected action: {action:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_focus_cloud_mode_parse() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://action/focus_cloud_mode",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let action = Action::parse(&url).unwrap();
|
||||
assert!(matches!(action, Action::FocusCloudMode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_create_environment_parse_no_repos() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://action/create_environment",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let action = Action::parse(&url).unwrap();
|
||||
match action {
|
||||
Action::CreateEnvironment { repos } => {
|
||||
assert!(repos.is_empty());
|
||||
}
|
||||
_ => panic!("unexpected action: {action:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_cloud_agent_setup_parse() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://action/cloud_agent_setup",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let action = Action::parse(&url).unwrap();
|
||||
assert!(matches!(action, Action::CloudAgentSetup));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_new_cloud_agent_conversation_parse() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://action/new_cloud_agent_conversation",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let action = Action::parse(&url).unwrap();
|
||||
assert!(matches!(action, Action::NewCloudAgentConversation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_new_agent_conversation_parse() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://action/new_agent_conversation",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let action = Action::parse(&url).unwrap();
|
||||
assert!(matches!(action, Action::NewAgentConversation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_custom_uri_linear() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://linear/work?prompt=hello",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
let host = validate_custom_uri(&url).unwrap();
|
||||
assert!(matches!(host, UriHost::Linear));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_linear_action_parse_work() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://linear/work?prompt=hello",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
let action = LinearAction::parse(&url).unwrap();
|
||||
assert_eq!(action, LinearAction::WorkOnIssue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_linear_action_parse_unknown_path() {
|
||||
let url = Url::parse(&format!("{}://linear/unknown", ChannelState::url_scheme())).unwrap();
|
||||
assert!(LinearAction::parse(&url).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_linear_issue_work_with_prompt() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://linear/work?prompt=fix+the+bug",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
let args = LinearIssueWork::from_url(&url);
|
||||
assert_eq!(args.prompt.as_deref(), Some("fix the bug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_linear_issue_work_without_prompt() {
|
||||
let url = Url::parse(&format!("{}://linear/work", ChannelState::url_scheme())).unwrap();
|
||||
let args = LinearIssueWork::from_url(&url);
|
||||
assert!(args.prompt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_linear_issue_work_empty_prompt() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://linear/work?prompt=",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
let args = LinearIssueWork::from_url(&url);
|
||||
assert!(args.prompt.is_none());
|
||||
}
|
||||
|
||||
// -- handle_incoming_uri redaction -------------------------------------------
|
||||
//
|
||||
// These tests cover the fix for GH #737: the entry log inside
|
||||
// `handle_incoming_uri` used to write the full URL (including the Firebase
|
||||
// `refresh_token` query parameter) to `warp.log` at `info` level before any
|
||||
// redaction ran. They validate the redaction helper and the error messages
|
||||
// produced by `validate_custom_uri` to ensure that the fallback `warn`
|
||||
// emitted on invalid URIs never embeds the query string either.
|
||||
|
||||
/// The redacted log representation must contain scheme/host/path for triage
|
||||
/// but must never contain the query string or any token material.
|
||||
#[test]
|
||||
fn safe_url_log_fields_redacts_refresh_token() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://auth/desktop_redirect?refresh_token=SENSITIVE_TOKEN&state=abc&user_uid=u",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let logged = safe_url_log_fields(&url);
|
||||
|
||||
assert!(
|
||||
logged.contains(&format!("scheme={}", ChannelState::url_scheme())),
|
||||
"expected scheme in redacted log, got: {logged}"
|
||||
);
|
||||
assert!(
|
||||
logged.contains("host=auth"),
|
||||
"expected host in redacted log, got: {logged}"
|
||||
);
|
||||
assert!(
|
||||
logged.contains("path=/desktop_redirect"),
|
||||
"expected path in redacted log, got: {logged}"
|
||||
);
|
||||
assert!(
|
||||
!logged.contains("refresh_token"),
|
||||
"redacted log must not contain refresh_token: {logged}"
|
||||
);
|
||||
assert!(
|
||||
!logged.contains("SENSITIVE_TOKEN"),
|
||||
"redacted log must not contain the token value: {logged}"
|
||||
);
|
||||
assert!(
|
||||
!logged.contains("state="),
|
||||
"redacted log must not contain state query param: {logged}"
|
||||
);
|
||||
assert!(
|
||||
!logged.contains("user_uid"),
|
||||
"redacted log must not contain user_uid: {logged}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The redacted log representation must drop generic OAuth query parameters
|
||||
/// (`code=`, `access_token=`, `custom_token=`, `token=`) regardless of host.
|
||||
#[test]
|
||||
fn safe_url_log_fields_redacts_generic_oauth_params() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://mcp/oauth_callback?code=AUTH_CODE&state=xyz&access_token=AT&custom_token=CT&token=RAW",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let logged = safe_url_log_fields(&url);
|
||||
|
||||
for forbidden in [
|
||||
"code=",
|
||||
"AUTH_CODE",
|
||||
"access_token",
|
||||
"AT",
|
||||
"custom_token",
|
||||
"CT",
|
||||
"token=RAW",
|
||||
"state=",
|
||||
] {
|
||||
assert!(
|
||||
!logged.contains(forbidden),
|
||||
"redacted log must not contain {forbidden:?}: {logged}"
|
||||
);
|
||||
}
|
||||
assert!(logged.contains("host=mcp"), "expected host: {logged}");
|
||||
assert!(
|
||||
logged.contains("path=/oauth_callback"),
|
||||
"expected path: {logged}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive links carry user-identifiable `invitee_email` values in the query.
|
||||
/// The entry log must not surface them on non-dogfood channels.
|
||||
#[test]
|
||||
fn safe_url_log_fields_redacts_invitee_email() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://drive/notebook?id=abc&invitee_email=alice@example.com",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let logged = safe_url_log_fields(&url);
|
||||
|
||||
assert!(
|
||||
!logged.contains("alice@example.com"),
|
||||
"redacted log must not contain invitee email: {logged}"
|
||||
);
|
||||
assert!(
|
||||
!logged.contains("invitee_email"),
|
||||
"redacted log must not contain invitee_email key: {logged}"
|
||||
);
|
||||
assert!(logged.contains("host=drive"), "expected host: {logged}");
|
||||
}
|
||||
|
||||
/// URL fragments are not currently used as secret carriers by Warp today, but
|
||||
/// the entry log's contract is "scheme + host + path only", so fragments must
|
||||
/// be dropped as well.
|
||||
#[test]
|
||||
fn safe_url_log_fields_drops_fragment() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://auth/desktop_redirect#sensitive_fragment",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let logged = safe_url_log_fields(&url);
|
||||
|
||||
assert!(
|
||||
!logged.contains("sensitive_fragment"),
|
||||
"redacted log must not contain url fragment: {logged}"
|
||||
);
|
||||
assert!(
|
||||
!logged.contains('#'),
|
||||
"redacted log must not contain any fragment separator: {logged}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `file://` URLs route through the same entry log. `file://` URLs on macOS
|
||||
/// have no host; the helper must not panic and must report `host=-` so the
|
||||
/// format string stays well-formed.
|
||||
#[test]
|
||||
fn safe_url_log_fields_handles_file_urls_without_host() {
|
||||
let url = Url::parse("file:///tmp/foo.md").unwrap();
|
||||
|
||||
let logged = safe_url_log_fields(&url);
|
||||
|
||||
assert!(logged.contains("scheme=file"), "expected scheme: {logged}");
|
||||
assert!(
|
||||
logged.contains("host=-"),
|
||||
"expected host placeholder: {logged}"
|
||||
);
|
||||
assert!(
|
||||
logged.contains("path=/tmp/foo.md"),
|
||||
"expected path: {logged}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `validate_custom_uri` returns `anyhow::Error`s whose messages feed the
|
||||
/// non-dogfood `log::warn!("Custom URI is invalid: {e:?}")` fallback in
|
||||
/// `handle_incoming_uri`. Those messages must never embed the full URL, its
|
||||
/// query string, or its fragment — otherwise the fallback warn line becomes
|
||||
/// a second secret leak.
|
||||
#[test]
|
||||
fn validate_custom_uri_errors_do_not_leak_query_string() {
|
||||
// Unexpected scheme.
|
||||
let url = Url::parse("https://auth/desktop_redirect?refresh_token=LEAKED").unwrap();
|
||||
let err = validate_custom_uri(&url).unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(!msg.contains("refresh_token"), "{msg}");
|
||||
assert!(!msg.contains("LEAKED"), "{msg}");
|
||||
|
||||
// Unexpected host.
|
||||
let url = Url::parse(&format!(
|
||||
"{}://unknown_host/desktop_redirect?refresh_token=LEAKED",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
let err = validate_custom_uri(&url).unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(!msg.contains("refresh_token"), "{msg}");
|
||||
assert!(!msg.contains("LEAKED"), "{msg}");
|
||||
|
||||
// Unexpected path for a host that doesn't allow arbitrary paths.
|
||||
let url = Url::parse(&format!(
|
||||
"{}://auth/not_the_redirect?refresh_token=LEAKED",
|
||||
ChannelState::url_scheme()
|
||||
))
|
||||
.unwrap();
|
||||
let err = validate_custom_uri(&url).unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(!msg.contains("refresh_token"), "{msg}");
|
||||
assert!(!msg.contains("LEAKED"), "{msg}");
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
#[cfg(target_family = "wasm")]
|
||||
use crate::uri::browser_url_handler::parse_current_url;
|
||||
use crate::ChannelState;
|
||||
use anyhow::{anyhow, Result};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
use warp_core::context_flag::ContextFlag;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Represents an intent parsed from a web url
|
||||
pub enum WebIntent {
|
||||
SessionView(Url),
|
||||
ConversationView(Url),
|
||||
DriveObject(Url),
|
||||
SettingsView(Url),
|
||||
Home(Url),
|
||||
Action(Url),
|
||||
}
|
||||
|
||||
impl WebIntent {
|
||||
pub fn try_from_url(url: &Url) -> Result<Self> {
|
||||
// Only handle URLs that point at the current channel's web server.
|
||||
let server_root = ChannelState::server_root_url();
|
||||
let server_root_url = Url::parse(&server_root)?;
|
||||
if url.scheme() != server_root_url.scheme()
|
||||
|| url.domain() != server_root_url.domain()
|
||||
|| url.port_or_known_default() != server_root_url.port_or_known_default()
|
||||
{
|
||||
return Err(anyhow!("Attempting to parse invalid url: {}", url));
|
||||
}
|
||||
|
||||
let segments = url
|
||||
.path_segments()
|
||||
.map(|segments| segments.collect::<Vec<_>>());
|
||||
if let Some(segments) = segments {
|
||||
let url_scheme = ChannelState::url_scheme();
|
||||
if segments.is_empty() {
|
||||
return Ok(WebIntent::Home(Url::parse(&format!(
|
||||
"{url_scheme}://home"
|
||||
))?));
|
||||
} else {
|
||||
match segments[0] {
|
||||
"app" => {
|
||||
return Ok(WebIntent::Home(Url::parse(&format!(
|
||||
"{url_scheme}://home"
|
||||
))?));
|
||||
}
|
||||
// For sessions, we expect the URL to be in the format: {scheme}/session/{session_id}
|
||||
"session" => {
|
||||
if segments.len() != 2 {
|
||||
return Err(anyhow!("Attempting to parse invalid url: {}", url));
|
||||
}
|
||||
|
||||
let session_id = segments[1];
|
||||
|
||||
// Validate that the session ID is a UUID. If it's not, this isn't a
|
||||
// valid shared-session URL and we should return an error so the
|
||||
// caller can ignore it.
|
||||
if Uuid::parse_str(session_id).is_err() {
|
||||
return Err(anyhow!("Attempting to parse invalid url: {}", url));
|
||||
}
|
||||
|
||||
let mut session_intent = Url::parse(
|
||||
format!("{url_scheme}://shared_session/{session_id}").as_str(),
|
||||
)
|
||||
.map_err(|_| anyhow!("Attempting to parse invalid url: {}", url))?;
|
||||
|
||||
// Preserve any query parameters (e.g. pwd, preview) from the original URL.
|
||||
if let Some(query) = url.query() {
|
||||
session_intent.set_query(Some(query));
|
||||
}
|
||||
|
||||
return Ok(WebIntent::SessionView(session_intent));
|
||||
}
|
||||
// For conversations, we expect the URL to be in the format: {scheme}/conversation/{conversation_id}
|
||||
"conversation" => {
|
||||
if segments.len() != 2 {
|
||||
return Err(anyhow!("Attempting to parse invalid url: {}", url));
|
||||
}
|
||||
|
||||
let conversation_id = segments[1];
|
||||
let conversation_intent = Url::parse(
|
||||
format!("{url_scheme}://conversation/{conversation_id}").as_str(),
|
||||
)
|
||||
.map_err(|_| anyhow!("Attempting to parse invalid url: {}", url))?;
|
||||
|
||||
return Ok(WebIntent::ConversationView(conversation_intent));
|
||||
}
|
||||
// For drive objects, we expect the URL to be of the format: {scheme}/drive/{object-type}/{object-name}-{object-id}?focused_folder_id={focused_folder_id}
|
||||
// The focused_folder_id is optional, and if it is not provided, we will not include it in the intent url.
|
||||
"drive" => {
|
||||
if segments.len() != 3 {
|
||||
return Err(anyhow!("Attempting to parse invalid url: {}", url));
|
||||
}
|
||||
let id_and_name: Vec<&str> =
|
||||
segments[segments.len() - 1].split('-').collect();
|
||||
let id = id_and_name[id_and_name.len() - 1];
|
||||
let object_type = segments[segments.len() - 2];
|
||||
if let Ok(mut drive_intent) =
|
||||
Url::parse(format!("{url_scheme}://drive/{object_type}").as_str())
|
||||
{
|
||||
drive_intent.set_query(url.query());
|
||||
drive_intent.query_pairs_mut().append_pair("id", id);
|
||||
return Ok(WebIntent::DriveObject(drive_intent));
|
||||
}
|
||||
}
|
||||
"settings" => {
|
||||
// For the settings links, we expect the URL to be of the format: {scheme}/settings/{sub_section}?{query_str}
|
||||
if segments.len() != 2 {
|
||||
return Err(anyhow!("Attempting to parse invalid url: {}", url));
|
||||
}
|
||||
let sub_section = segments[segments.len() - 1];
|
||||
let query_str = url.query().unwrap_or_default();
|
||||
if query_str.is_empty() {
|
||||
return Err(anyhow!("Attempting to parse invalid url: {}", url));
|
||||
}
|
||||
if let Ok(settings_intent) = Url::parse(
|
||||
format!("{url_scheme}://settings/{sub_section}?{query_str}").as_str(),
|
||||
) {
|
||||
return Ok(WebIntent::SettingsView(settings_intent));
|
||||
}
|
||||
}
|
||||
"action" => {
|
||||
if segments.len() != 2 {
|
||||
return Err(anyhow!("Attempting to parse invalid url: {}", url));
|
||||
}
|
||||
let action_type = segments[1];
|
||||
// Allowlist of valid actions,
|
||||
// since we shouldn't expose all Warp actions as web URLs.
|
||||
const ALLOWED_ACTIONS: &[&str] = &["open-repo", "focus_cloud_mode"];
|
||||
if !ALLOWED_ACTIONS.contains(&action_type) {
|
||||
return Err(anyhow!("Unknown action type in url: {}", action_type));
|
||||
}
|
||||
if let Ok(action_intent) =
|
||||
Url::parse(format!("{url_scheme}://action/{action_type}").as_str())
|
||||
{
|
||||
return Ok(WebIntent::Action(action_intent));
|
||||
}
|
||||
}
|
||||
_ => return Err(anyhow!("Attempting to parse invalid url: {}", url)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("Attempting to parse invalid url: {}", url))
|
||||
}
|
||||
|
||||
/// Convert this web intent into the underlying native desktop URL.
|
||||
pub fn into_intent_url(self) -> Url {
|
||||
match self {
|
||||
WebIntent::SessionView(url) => url,
|
||||
WebIntent::ConversationView(url) => url,
|
||||
WebIntent::DriveObject(url) => url,
|
||||
WebIntent::SettingsView(url) => url,
|
||||
WebIntent::Home(url) => url,
|
||||
WebIntent::Action(url) => url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to rewrite a Warp web URL into a native desktop intent URL (warp://...).
|
||||
/// Returns `None` if the URL is not a recognized Warp web intent.
|
||||
pub fn maybe_rewrite_web_url_to_intent(url: &Url) -> Option<Url> {
|
||||
WebIntent::try_from_url(url)
|
||||
.ok()
|
||||
.map(WebIntent::into_intent_url)
|
||||
}
|
||||
|
||||
/// On WASM warp, fires an event to try and open the given link on the desktop app.
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn open_url_on_desktop(url: &Url) {
|
||||
match WebIntent::try_from_url(url) {
|
||||
Ok(WebIntent::ConversationView(intent))
|
||||
| Ok(WebIntent::DriveObject(intent))
|
||||
| Ok(WebIntent::SessionView(intent))
|
||||
| Ok(WebIntent::Action(intent)) => {
|
||||
crate::platform::wasm::emit_event(crate::platform::wasm::WarpEvent::OpenOnNative {
|
||||
url: intent.into(),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Attempting to open invalid url on desktop app:{url}");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
fn set_context_flags_from_url(url: Url) {
|
||||
match WebIntent::try_from_url(&url) {
|
||||
Ok(WebIntent::SessionView(_)) => ContextFlag::set_shared_session_only(),
|
||||
Ok(WebIntent::ConversationView(_)) => ContextFlag::set_conversation_only(),
|
||||
Ok(WebIntent::DriveObject(_)) => ContextFlag::set_warp_drive_link_only(),
|
||||
Ok(WebIntent::SettingsView(_)) => ContextFlag::set_settings_link_only(),
|
||||
Ok(WebIntent::Home(_)) => ContextFlag::set_warp_home_link_only(),
|
||||
Ok(WebIntent::Action(_)) => {} // No special context flag for actions
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Allow directly setting flags through query params in dogfood.
|
||||
if ChannelState::channel().is_dogfood() {
|
||||
for (param, value) in url.query_pairs() {
|
||||
let Ok(flag) = param.parse::<ContextFlag>() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(bool_value) = value.parse::<bool>() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
flag.set(bool_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks at the current URL and converts it into an app intent.
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn current_web_intent() -> Option<WebIntent> {
|
||||
let Some(current_url) = parse_current_url() else {
|
||||
log::warn!("Unable to parse the current url");
|
||||
return None;
|
||||
};
|
||||
|
||||
WebIntent::try_from_url(¤t_url).ok()
|
||||
}
|
||||
|
||||
// Looks at the current url and converts it into an app intent.
|
||||
// NOTE: This is only intended for use with target_family = "wasm"
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn parse_web_intent_from_current_url() -> Option<Url> {
|
||||
current_web_intent().map(WebIntent::into_intent_url)
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn set_context_flags_from_current_url() {
|
||||
let Some(current_url) = parse_current_url() else {
|
||||
log::warn!("Unable to parse the current url");
|
||||
return;
|
||||
};
|
||||
|
||||
set_context_flags_from_url(current_url);
|
||||
}
|
||||
Reference in New Issue
Block a user