diff --git a/Cargo.lock b/Cargo.lock index d14bf43cab..fd87a89775 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7544,6 +7544,23 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" +[[package]] +name = "local_control" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "command", + "libc", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.17", + "uuid", +] + [[package]] name = "locale_config" version = "0.3.0" @@ -14528,6 +14545,7 @@ dependencies = [ "libc", "libsqlite3-sys", "line-ending", + "local_control", "log", "log-panics", "lsp", @@ -14762,7 +14780,9 @@ dependencies = [ "color-print", "humantime", "jaq-all", + "local_control", "serde", + "serde_json", "serial_test", "url", "uuid", diff --git a/Cargo.toml b/Cargo.toml index d4bc578c3c..0cb46dff34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ integration = { path = "crates/integration" } ipc = { path = "crates/ipc" } jsonrpc = { path = "crates/jsonrpc" } languages = { path = "crates/languages" } +local_control = { path = "crates/local_control" } lsp = { path = "crates/lsp" } markdown_parser = { path = "crates/markdown_parser" } mcp = { path = "crates/mcp" } diff --git a/app/Cargo.toml b/app/Cargo.toml index 8f9f5f8175..7aeb5c5fd7 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -264,6 +264,7 @@ rmcp.workspace = true warp_isolation_platform.workspace = true warp_ripgrep.workspace = true warp_managed_secrets.workspace = true +local_control.workspace = true [target.'cfg(target_os = "macos")'.dependencies] block.workspace = true @@ -972,6 +973,7 @@ vertical_tabs = [] vertical_tabs_summary_mode = [] tab_configs = [] grouped_tabs = [] +warp_control_cli = [] agent_harness = [] oz_handoff = [] handoff_local_cloud = [] diff --git a/app/src/features.rs b/app/src/features.rs index 527649ed83..e34bd34221 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -447,6 +447,8 @@ fn enabled_features() -> HashSet { FeatureFlag::TabConfigs, #[cfg(feature = "grouped_tabs")] FeatureFlag::GroupedTabs, + #[cfg(feature = "warp_control_cli")] + FeatureFlag::WarpControlCli, #[cfg(feature = "agent_harness")] FeatureFlag::AgentHarness, #[cfg(feature = "oz_handoff")] diff --git a/app/src/lib.rs b/app/src/lib.rs index 337ddc1aa3..67c734a29a 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -43,6 +43,8 @@ mod gpu_state; mod input_classifier; mod interval_timer; mod linear; +#[cfg(not(target_family = "wasm"))] +mod local_control; #[cfg(any(target_os = "macos", target_os = "windows"))] mod login_item; mod menu; @@ -585,6 +587,11 @@ pub fn run() -> Result<()> { // Ensure feature flags are initialized before parsing command-line arguments. features::init_feature_flags(); + if let Some(args) = warp_cli::local_control::ControlArgs::from_control_mode_env() { + #[cfg(windows)] + warp_util::windows::attach_to_parent_console(); + warp_cli::local_control::run_and_exit(args); + } // Parse command-line arguments. let args = warp_cli::Args::from_env(); @@ -2058,6 +2065,15 @@ pub(crate) fn initialize_app( ]; http_server::HttpServer::new(routers, ctx) }); + #[cfg(not(target_family = "wasm"))] + if matches!( + launch_mode, + LaunchMode::App { .. } | LaunchMode::Test { .. } + ) && FeatureFlag::WarpControlCli.is_enabled() + { + ctx.add_singleton_model(local_control::LocalControlBridge::new); + ctx.add_singleton_model(local_control::LocalControlServer::new); + } app_state } diff --git a/app/src/local_control/bridge.rs b/app/src/local_control/bridge.rs new file mode 100644 index 0000000000..a3fe0f0c44 --- /dev/null +++ b/app/src/local_control/bridge.rs @@ -0,0 +1,115 @@ +//! Bridge between protocol-level control requests and Warp application models. +//! +//! The bridge validates protocol version, selectors, credentials, and settings +//! before routing each supported action to an app-side handler. +use ::local_control::auth::CredentialGrant; +use ::local_control::{ + Action, ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope, ResponseEnvelope, +}; +use warpui::{Entity, ModelContext, SingletonEntity}; + +use crate::local_control::handlers::{layout, metadata}; +use crate::local_control::permissions::{ + ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version, +}; +use crate::local_control::resolver::validate_action_params; + +/// WarpUI model that executes already-authenticated local-control actions. +pub struct LocalControlBridge { + instance_id: Option, +} + +impl Entity for LocalControlBridge { + type Event = (); +} + +impl SingletonEntity for LocalControlBridge {} + +impl LocalControlBridge { + pub fn new(_ctx: &mut ModelContext) -> Self { + Self { instance_id: None } + } + + pub(super) fn set_instance_id(&mut self, instance_id: InstanceId) { + self.instance_id = Some(instance_id); + } + + pub(super) fn handle_request( + &mut self, + request: RequestEnvelope, + grant: CredentialGrant, + ctx: &mut ModelContext, + ) -> ResponseEnvelope { + if let Err(error) = ensure_feature_enabled() { + return ResponseEnvelope::error(request.request_id, error); + } + if let Err(error) = ensure_protocol_version(request.protocol_version) { + return ResponseEnvelope::error(request.request_id, error); + } + let Some(instance_id) = &self.instance_id else { + return ResponseEnvelope::error( + request.request_id, + ControlError::new( + ErrorCode::BridgeUnavailable, + "local-control bridge has no active instance identity", + ), + ); + }; + if let Err(error) = validate_request_authority(instance_id, &request.action, &grant) { + return ResponseEnvelope::error(request.request_id, error); + } + if let Err(error) = + ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) + { + return ResponseEnvelope::error(request.request_id, error); + } + match request.action.kind { + ActionKind::InstanceList => match metadata::instance(&self.instance_id) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + }, + ActionKind::AppPing => match metadata::ping(&self.instance_id) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + }, + ActionKind::AppVersion => match metadata::version(&self.instance_id) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + }, + ActionKind::TabCreate => { + match layout::create_terminal_tab(&self.instance_id, &request.target, ctx) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + } + } + action => ResponseEnvelope::error( + request.request_id, + ControlError::new( + ErrorCode::UnsupportedAction, + format!( + "{} is not implemented by this local-control bridge", + action.as_str() + ), + ), + ), + } + } +} + +pub(crate) fn validate_request_authority( + instance_id: &InstanceId, + action: &Action, + grant: &CredentialGrant, +) -> Result<(), ControlError> { + grant.verify_for_action(instance_id, action.kind)?; + if !action.kind.is_implemented() { + return Err(ControlError::new( + ErrorCode::UnsupportedAction, + format!( + "{} is not implemented by this local-control bridge", + action.kind.as_str() + ), + )); + } + validate_action_params(action) +} diff --git a/app/src/local_control/handlers.rs b/app/src/local_control/handlers.rs new file mode 100644 index 0000000000..ca3f5fa511 --- /dev/null +++ b/app/src/local_control/handlers.rs @@ -0,0 +1,3 @@ +//! App-side action handlers invoked by the local-control bridge. +pub(super) mod layout; +pub(super) mod metadata; diff --git a/app/src/local_control/handlers/layout.rs b/app/src/local_control/handlers/layout.rs new file mode 100644 index 0000000000..c604dbaa29 --- /dev/null +++ b/app/src/local_control/handlers/layout.rs @@ -0,0 +1,99 @@ +//! Layout mutation handlers for local-control actions. +#[cfg(test)] +#[path = "layout_tests.rs"] +mod tests; +use ::local_control::protocol::TargetSelector; +use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId}; +use serde::Serialize; +use warpui::{ModelContext, TypedActionView}; + +use crate::local_control::resolver::{target_window_id_for_target, validate_tab_create_target}; +use crate::local_control::LocalControlBridge; +use crate::workspace::{Workspace, WorkspaceAction}; +#[derive(Serialize)] +struct TabCreateResponse<'a> { + action: &'static str, + created: bool, + instance_id: Option<&'a str>, + window: TargetWindowResponse, + tab: TabCountsResponse, +} + +#[derive(Serialize)] +struct TargetWindowResponse { + selector: &'static str, + id: String, +} + +#[derive(Serialize)] +struct TabCountsResponse { + id: String, + previous_count: usize, + count: usize, + active_index: usize, +} + +pub(crate) fn create_terminal_tab( + instance_id: &Option, + target: &TargetSelector, + ctx: &mut ModelContext, +) -> Result { + validate_tab_create_target(target)?; + let window_id = target_window_id_for_target(ctx, target, ActionKind::TabCreate)?; + let workspace = ctx + .views_of_type::(window_id) + .and_then(|workspaces| workspaces.into_iter().next()) + .ok_or_else(|| { + ControlError::new( + ErrorCode::MissingTarget, + "tab.create requires a workspace in the target window", + ) + })?; + let (tab_id, previous_tab_count, tab_count, active_tab_index) = + workspace.update(ctx, |workspace, ctx| { + let previous_tab_count = workspace.tab_count(); + workspace.handle_action( + &WorkspaceAction::AddTerminalTab { + hide_homepage: false, + }, + ctx, + ); + let tab_id = workspace + .get_pane_group_view(workspace.active_tab_index()) + .map(|tab| tab.id().to_string()) + .ok_or_else(|| { + ControlError::new( + ErrorCode::Internal, + "tab.create did not produce an active tab identifier", + ) + })?; + Ok(( + tab_id, + previous_tab_count, + workspace.tab_count(), + workspace.active_tab_index(), + )) + })?; + serde_json::to_value(TabCreateResponse { + action: ActionKind::TabCreate.as_str(), + created: true, + instance_id: instance_id.as_ref().map(|id| id.0.as_str()), + window: TargetWindowResponse { + selector: "target", + id: window_id.to_string(), + }, + tab: TabCountsResponse { + id: tab_id, + previous_count: previous_tab_count, + count: tab_count, + active_index: active_tab_index, + }, + }) + .map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control tab.create response", + err.to_string(), + ) + }) +} diff --git a/app/src/local_control/handlers/layout_tests.rs b/app/src/local_control/handlers/layout_tests.rs new file mode 100644 index 0000000000..ba9f85ad6c --- /dev/null +++ b/app/src/local_control/handlers/layout_tests.rs @@ -0,0 +1,36 @@ +use ::local_control::protocol::TargetSelector; +use ::local_control::InstanceId; +use warpui::App; + +use super::create_terminal_tab; +use crate::local_control::LocalControlBridge; +use crate::workspace::view::tests::{initialize_app, mock_workspace}; + +#[test] +fn tab_create_handler_adds_and_activates_terminal_tab() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + let previous_count = workspace.read(&app, |workspace, _| workspace.tab_count()); + let bridge = app.add_singleton_model(LocalControlBridge::new); + let instance_id = InstanceId("inst_test".to_owned()); + + let response = bridge.update(&mut app, |bridge, ctx| { + bridge.set_instance_id(instance_id.clone()); + create_terminal_tab(&Some(instance_id.clone()), &TargetSelector::default(), ctx) + .expect("tab.create handler succeeds") + }); + + workspace.read(&app, |workspace, _| { + assert_eq!(workspace.tab_count(), previous_count + 1); + assert_eq!(workspace.active_tab_index(), previous_count); + }); + assert_eq!(response["action"], "tab.create"); + assert_eq!(response["created"], true); + assert_eq!(response["instance_id"], "inst_test"); + assert_eq!(response["tab"]["previous_count"], previous_count); + assert_eq!(response["tab"]["count"], previous_count + 1); + assert_eq!(response["tab"]["active_index"], previous_count); + assert!(response["tab"]["id"].is_string()); + }); +} diff --git a/app/src/local_control/handlers/metadata.rs b/app/src/local_control/handlers/metadata.rs new file mode 100644 index 0000000000..616c43fe13 --- /dev/null +++ b/app/src/local_control/handlers/metadata.rs @@ -0,0 +1,76 @@ +//! Metadata response builders for local-control introspection actions. +use ::local_control::{ + ActionKind, ActionMetadata, ControlError, ErrorCode, InstanceId, PROTOCOL_VERSION, +}; +use serde::Serialize; +use warp_core::channel::ChannelState; +#[derive(Serialize)] +struct InstanceResponse<'a> { + action: &'static str, + instance_id: Option<&'a str>, + pid: u32, + channel: String, + app_id: String, + protocol_version: u32, + actions: Vec, +} + +#[derive(Serialize)] +struct PingResponse<'a> { + action: &'static str, + ok: bool, + instance_id: Option<&'a str>, + protocol_version: u32, +} + +#[derive(Serialize)] +struct VersionResponse<'a> { + action: &'static str, + instance_id: Option<&'a str>, + protocol_version: u32, + channel: String, + app_id: String, +} + +pub(crate) fn instance( + instance_id: &Option, +) -> Result { + to_json_value(InstanceResponse { + action: ActionKind::InstanceList.as_str(), + instance_id: instance_id.as_ref().map(|id| id.0.as_str()), + pid: std::process::id(), + channel: ChannelState::channel().to_string(), + app_id: ChannelState::app_id().to_string(), + protocol_version: PROTOCOL_VERSION, + actions: ActionKind::implemented_metadata(), + }) +} + +pub(crate) fn ping(instance_id: &Option) -> Result { + to_json_value(PingResponse { + action: ActionKind::AppPing.as_str(), + ok: true, + instance_id: instance_id.as_ref().map(|id| id.0.as_str()), + protocol_version: PROTOCOL_VERSION, + }) +} + +pub(crate) fn version(instance_id: &Option) -> Result { + to_json_value(VersionResponse { + action: ActionKind::AppVersion.as_str(), + instance_id: instance_id.as_ref().map(|id| id.0.as_str()), + protocol_version: PROTOCOL_VERSION, + channel: ChannelState::channel().to_string(), + app_id: ChannelState::app_id().to_string(), + }) +} + +fn to_json_value(response: T) -> Result { + serde_json::to_value(response).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control metadata response", + err.to_string(), + ) + }) +} diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs new file mode 100644 index 0000000000..a1138745ea --- /dev/null +++ b/app/src/local_control/mod.rs @@ -0,0 +1,714 @@ +//! Running app-side server for local Warp control requests. +//! +//! This module owns the in-process listener, discovery registration, credential +//! broker socket, and request handoff from Axum into the WarpUI model graph. +//! It complements `crates/local_control/src/discovery.rs`: that shared module +//! defines how clients find and validate candidate instances, while this module +//! creates the app-owned endpoints and publishes their routing metadata through +//! `RegisteredInstance`. +//! +//! A client uses all three transports in order. It reads the filesystem record +//! to find an instance, connects to that instance's Unix socket to obtain +//! temporary authority, and presents that authority to the instance's loopback +//! HTTP endpoint with one typed action. The filesystem and socket are therefore +//! complementary parts of discovery and credential bootstrap, not competing +//! discovery mechanisms. +//! +//! Credential broker security flow: +//! +//! ```text +//! owner-only discovery record +//! (loopback endpoint + broker path; never a token) +//! | +//! v +//! CLI client -- instance-bound Unix socket --> credential broker +//! [0600 socket + kernel-reported peer UID] +//! | +//! v +//! feature flag + Settings > Scripting gate +//! + protocol + action metadata +//! + invocation context + required proof +//! | +//! v +//! short-lived, instance-bound, action-scoped +//! bearer grant stored only in process memory +//! | +//! v +//! CLI client -- loopback HTTP + bearer --> /v1/control +//! [reject browser Origin + require exact Host +//! + validate grant existence, expiry, instance, and scope] +//! | +//! v +//! typed allowlisted action +//! | +//! v +//! main-thread LocalControlBridge +//! [re-check current settings before dispatch] +//! ``` +//! +//! These boundaries prevent browser-origin clients, other OS users, +//! unauthenticated clients that only obtain or guess the HTTP endpoint, stale +//! or wrong-instance credentials, and accidentally over-scoped credentials from +//! invoking actions. The broker authenticates the OS account, not the calling +//! application: malicious software already running as the same user remains +//! outside this boundary. Verified inside-Warp terminal credentials remain +//! future work until the app-issued proof broker is implemented. +//! +//! The Settings > Scripting gates used here are local-only settings backed by +//! Warp's secure storage provider. +//! +//! Discovery records never include raw bearer tokens: discovery only exposes +//! endpoint metadata and credential broker references when outside-Warp control +//! is enabled. +mod bridge; +mod handlers; +mod permissions; +mod resolver; + +use std::collections::HashMap; +#[cfg(unix)] +use std::fs::Permissions; +use std::net::SocketAddr; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; +use std::sync::{Arc, Mutex}; + +use ::local_control::auth::CredentialGrant; +#[cfg(any(unix, test))] +use ::local_control::auth::{CredentialRequest, ScopedCredential}; +use ::local_control::{ + ActionKind, AuthToken, ControlEndpoint, ControlError, ControlResponse, ErrorCode, + ErrorResponseEnvelope, InstanceId, InstanceRecord, RegisteredInstance, RequestEnvelope, + ResponseEnvelope, +}; +use axum::body::Bytes; +use axum::extract::State; +use axum::http::header::{AUTHORIZATION, HOST, ORIGIN}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +pub use bridge::LocalControlBridge; +#[cfg(any(unix, test))] +use chrono::Duration; +use permissions::ensure_feature_enabled; +#[cfg(any(unix, test))] +use permissions::{ensure_action_allowed, ensure_protocol_version}; +#[cfg(unix)] +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use warp_core::channel::ChannelState; +use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; +#[cfg(any(unix, test))] +const MAX_ACTIVE_CREDENTIALS: usize = 128; + +/// App-owned authority shared by one instance's broker and HTTP listener. +/// +/// Broker-issued bearer tokens map to grants only in this process-local state. +/// Knowing the endpoint from discovery is therefore insufficient to authenticate +/// an HTTP request. +#[derive(Clone)] +struct ControlServerState { + bridge_spawner: ModelSpawner, + instance_id: InstanceId, + expected_host: String, + credentials: Arc>>, +} +/// Process-local publisher, credential broker, and HTTP server for one Warp instance. +/// +/// Holding the runtime and registration keeps both listeners and the discovery +/// route alive. Dropping them stops request handling and removes the app's +/// published record and broker socket. +pub struct LocalControlServer { + _runtime: Option, + control_endpoint: Option, + registered_instance: Option, +} + +impl Entity for LocalControlServer { + type Event = (); +} + +impl SingletonEntity for LocalControlServer {} + +impl LocalControlServer { + pub fn new(ctx: &mut ModelContext) -> Self { + let mut server = Self { + _runtime: None, + control_endpoint: None, + registered_instance: None, + }; + if let Err(error) = server.refresh_for_settings(ctx) { + log::warn!("Failed to refresh local-control server state: {error:#}"); + } + ctx.subscribe_to_model( + &crate::settings::LocalControlSettings::handle(ctx), + |server, _, ctx| { + if let Err(error) = server.refresh_for_settings(ctx) { + log::warn!("Failed to refresh local-control server state: {error:#}"); + } + }, + ); + server + } + + /// Starts, refreshes, or removes all outside-Warp publication as settings change. + fn refresh_for_settings(&mut self, ctx: &mut ModelContext) -> Result<(), ControlError> { + if !permissions::warp_control_cli_enabled() { + self.stop(); + return Ok(()); + } + if !outside_warp_publication_supported() { + self.stop(); + return Ok(()); + } + let outside_warp_control_enabled = + crate::settings::LocalControlSettings::as_ref(ctx).outside_warp_control_enabled(); + if !outside_warp_control_enabled { + self.stop(); + return Ok(()); + } + if self._runtime.is_some() { + return self.refresh_discovery_record(ctx); + } + self.start(ctx) + } + + /// Stops both listeners and removes the discovery record and broker socket. + fn stop(&mut self) { + self.registered_instance = None; + self.control_endpoint = None; + self._runtime = None; + } + + /// Binds both transports and publishes the routing record that connects them. + /// + /// Startup first binds an ephemeral loopback HTTP port, publishes that port + /// plus the instance-derived broker filename, binds the broker socket, and + /// then serves credential issuance and typed control requests concurrently. + fn start(&mut self, ctx: &mut ModelContext) -> Result<(), ControlError> { + if self._runtime.is_some() { + return Err(ControlError::new( + ErrorCode::Internal, + "local-control server is already running", + )); + } + ensure_feature_enabled()?; + if !outside_warp_publication_supported() { + return Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control is disabled until this platform enforces discovery-record ACLs", + )); + } + if !crate::settings::LocalControlSettings::as_ref(ctx).outside_warp_control_enabled() { + return Ok(()); + } + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_io() + .build() + .map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to create local-control runtime", + err.to_string(), + ) + })?; + let listener = runtime + .block_on(tokio::net::TcpListener::bind(SocketAddr::from(( + [127, 0, 0, 1], + 0, + )))) + .map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to bind local-control listener", + err.to_string(), + ) + })?; + let port = listener.local_addr().map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to read local-control listener address", + err.to_string(), + ) + })?; + let control_endpoint = ControlEndpoint::localhost(port.port()); + let record = discovery_record_for_settings(ctx, control_endpoint.clone()); + let instance_id = record.instance_id.clone(); + let bridge_spawner = LocalControlBridge::handle(ctx).update(ctx, |bridge, ctx| { + bridge.set_instance_id(instance_id.clone()); + ctx.spawner() + }); + let registered_instance = RegisteredInstance::register(record)?; + #[cfg(unix)] + let broker_listener = { + let runtime_guard = runtime.enter(); + let listener = bind_credential_broker(registered_instance.record())?; + drop(runtime_guard); + listener + }; + let state = ControlServerState { + bridge_spawner, + instance_id, + expected_host: format!("{}:{}", control_endpoint.host, control_endpoint.port), + credentials: Arc::default(), + }; + let router = Router::new() + .route("/v1/control", post(handle_control_request)) + .with_state(state.clone()); + runtime.spawn(async move { + if let Err(err) = axum::serve(listener, router).await { + log::warn!("local-control listener stopped: {err:#}"); + } + }); + #[cfg(unix)] + runtime.spawn(run_credential_broker(broker_listener, state)); + let endpoint_url = control_endpoint.url(); + self._runtime = Some(runtime); + self.control_endpoint = Some(control_endpoint); + self.registered_instance = Some(registered_instance); + log::info!("local-control server started at {endpoint_url}"); + Ok(()) + } + + fn refresh_discovery_record( + &mut self, + ctx: &mut ModelContext, + ) -> Result<(), ControlError> { + let Some(control_endpoint) = self.control_endpoint.clone() else { + return Ok(()); + }; + let Some(registered_instance) = &mut self.registered_instance else { + return Ok(()); + }; + let mut record = discovery_record_for_settings(ctx, control_endpoint); + record.instance_id = registered_instance.record().instance_id.clone(); + record.credential_broker = registered_instance.record().credential_broker.clone(); + registered_instance.update(record) + } +} + +/// Builds routing metadata without embedding any bearer credential or secret. +/// +/// The endpoint and derived broker reference are published only while the +/// protected outside-Warp setting permits clients to use them. +fn discovery_record_for_settings( + ctx: &ModelContext, + control_endpoint: ControlEndpoint, +) -> InstanceRecord { + let outside_warp_control_enabled = + crate::settings::LocalControlSettings::as_ref(ctx).outside_warp_control_enabled(); + let endpoint = outside_warp_control_enabled.then_some(control_endpoint); + InstanceRecord::for_current_process( + endpoint, + ChannelState::channel().to_string(), + ChannelState::app_id().to_string(), + ChannelState::app_version().map(str::to_owned), + ActionKind::implemented_metadata(), + ) +} + +/// Binds the instance's credential-bootstrap socket and restricts it to the owning user. +/// +/// Any stale socket at the instance-specific path is removed before binding, and +/// the new socket is set to owner-only permissions before it accepts clients. +/// The path came from a validated instance-derived discovery reference, so a +/// record cannot redirect credential requests to an arbitrary socket. +#[cfg(unix)] +fn bind_credential_broker( + record: &InstanceRecord, +) -> Result { + let socket_path = record.broker_socket_path()?; + if socket_path.exists() { + std::fs::remove_file(&socket_path).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to remove stale local-control credential broker socket", + err.to_string(), + ) + })?; + } + let listener = tokio::net::UnixListener::bind(&socket_path).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to bind owner-authenticated local-control credential broker", + err.to_string(), + ) + })?; + std::fs::set_permissions(&socket_path, Permissions::from_mode(0o600)).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to protect local-control credential broker socket", + err.to_string(), + ) + })?; + Ok(listener) +} + +#[cfg(unix)] +/// Accepts same-user credential requests independently from the HTTP listener. +async fn run_credential_broker(listener: tokio::net::UnixListener, state: ControlServerState) { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let state = state.clone(); + tokio::spawn(async move { + if let Err(err) = handle_credential_broker_connection(stream, state).await { + log::warn!("local-control credential broker connection failed: {err:#}"); + } + }); + } +} + +#[cfg(unix)] +/// Authenticates the socket peer before decoding and evaluating its request. +/// +/// This ordering makes the kernel-reported OS user, rather than any field in +/// caller-controlled JSON, the credential broker's client-identity boundary. +async fn handle_credential_broker_connection( + mut stream: tokio::net::UnixStream, + state: ControlServerState, +) -> Result<(), ControlError> { + let response = match ensure_same_user_peer(&stream) { + Ok(()) => { + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).await.map_err(|err| { + ControlError::with_details( + ErrorCode::InvalidRequest, + "failed to read local-control credential request", + err.to_string(), + ) + })?; + match serde_json::from_slice::(&bytes) { + Ok(request) => issue_credential(&state, request) + .await + .and_then(|credential| serialize_credential_broker_response(&credential)), + Err(err) => Err(ControlError::with_details( + ErrorCode::InvalidRequest, + "failed to decode local-control credential request", + err.to_string(), + )), + } + } + Err(error) => Err(error), + }; + let bytes = match response { + Ok(bytes) => bytes, + Err(error) => serialize_credential_broker_response(&ErrorResponseEnvelope::new(error))?, + }; + stream.write_all(&bytes).await.map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to write local-control credential response", + err.to_string(), + ) + }) +} + +#[cfg(unix)] +/// Requires the kernel-reported peer UID to match Warp's effective UID. +/// +/// This excludes other OS users but does not distinguish trusted Warp code from +/// arbitrary processes already running as the same user. +fn ensure_same_user_peer(stream: &tokio::net::UnixStream) -> Result<(), ControlError> { + ensure_peer_uid(stream, unsafe { libc::geteuid() }) +} + +#[cfg(unix)] +/// Verifies a socket peer against an expected UID obtained outside request data. +fn ensure_peer_uid(stream: &tokio::net::UnixStream, expected_uid: u32) -> Result<(), ControlError> { + let peer = stream.peer_cred().map_err(|err| { + ControlError::with_details( + ErrorCode::UnauthorizedLocalClient, + "failed to identify local-control credential broker peer", + err.to_string(), + ) + })?; + if peer.uid() != expected_uid { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control credential broker peer belongs to a different OS user", + )); + } + Ok(()) +} + +#[cfg(unix)] +fn serialize_credential_broker_response( + response: &impl serde::Serialize, +) -> Result, ControlError> { + serde_json::to_vec(response).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control credential response", + err.to_string(), + ) + }) +} + +/// Evaluates current action policy and mints one short-lived exact-action grant. +/// +/// The bearer secret and its grant are retained only in the running instance's +/// process-local map; neither is written back into the discovery registry. +#[cfg(any(unix, test))] +async fn issue_credential( + state: &ControlServerState, + request: CredentialRequest, +) -> Result { + ensure_feature_enabled()?; + ensure_protocol_version(request.protocol_version)?; + let metadata = request.action.metadata(); + if !request.action.is_implemented() { + return Err(ControlError::new( + ErrorCode::UnsupportedAction, + format!( + "{} is not implemented by this local-control bridge", + request.action.as_str() + ), + )); + } + if !metadata + .allowed_invocation_contexts + .contains(&request.invocation_context) + { + return Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + format!( + "{} cannot run from the requested invocation context", + request.action.as_str() + ), + )); + } + request.verify_execution_context_proof()?; + state + .bridge_spawner + .spawn({ + let action = request.action; + let invocation_context = request.invocation_context; + move |_, ctx| ensure_action_allowed(invocation_context, action, ctx) + }) + .await + .map_err(|_| { + ControlError::new( + ErrorCode::BridgeUnavailable, + "local-control app bridge is unavailable", + ) + })??; + let auth_token = AuthToken::generate(); + let grant = CredentialGrant::new( + state.instance_id.clone(), + request.action, + request.invocation_context, + Duration::minutes(5), + ); + let mut credentials = state.credentials.lock().map_err(|_| { + ControlError::new( + ErrorCode::Internal, + "local-control credential broker is unavailable", + ) + })?; + insert_credential( + &mut credentials, + auth_token.secret().to_owned(), + grant.clone(), + ); + Ok(ScopedCredential { + bearer_token: auth_token.secret().to_owned(), + grant, + }) +} + +/// Authenticates and hands one typed HTTP request to the app bridge. +/// +/// Header hardening rejects browser-origin and wrong-endpoint requests. The +/// process-local credential lookup authenticates the transport, after which the +/// bridge revalidates current settings and exact-action authority before +/// resolving targets or dispatching a handler. +async fn handle_control_request( + State(state): State, + headers: HeaderMap, + payload: Bytes, +) -> Response { + if let Err(error) = validate_loopback_headers(&headers, &state.expected_host) { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + if let Err(error) = ensure_feature_enabled() { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + let auth_header = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()); + let auth_token = match AuthToken::from_authorization_header(auth_header) { + Ok(token) => token, + Err(error) => { + return ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + }; + let grant = match state.credentials.lock() { + Ok(mut credentials) => lookup_credential(&mut credentials, &auth_token, &state.instance_id), + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponseEnvelope::new(ControlError::new( + ErrorCode::Internal, + "local-control credential broker is unavailable", + ))), + ) + .into_response(); + } + }; + let grant = match grant { + Ok(grant) => grant, + Err(error) => { + return ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + }; + let request = match serde_json::from_slice::(&payload) { + Ok(request) => request, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponseEnvelope::new(ControlError::with_details( + ErrorCode::InvalidRequest, + "failed to decode local-control request", + err.to_string(), + ))), + ) + .into_response(); + } + }; + let request_id = request.request_id; + let response = match state + .bridge_spawner + .spawn(move |bridge, ctx| bridge.handle_request(request, grant, ctx)) + .await + { + Ok(response) => response, + Err(_) => ResponseEnvelope::error( + request_id, + ControlError::new( + ErrorCode::BridgeUnavailable, + "local-control app bridge is unavailable", + ), + ), + }; + let status = match &response.response { + ControlResponse::Ok { .. } => StatusCode::OK, + ControlResponse::Error { .. } => StatusCode::BAD_REQUEST, + }; + (status, Json(response)).into_response() +} + +#[cfg(any(unix, test))] +fn insert_credential( + credentials: &mut HashMap, + secret: String, + grant: CredentialGrant, +) { + credentials.retain(|_, grant| !grant.is_expired()); + if credentials.len() >= MAX_ACTIVE_CREDENTIALS { + let oldest_secret = credentials + .iter() + .min_by_key(|(_, grant)| grant.issued_at) + .map(|(secret, _)| secret.clone()); + if let Some(oldest_secret) = oldest_secret { + credentials.remove(&oldest_secret); + } + } + credentials.insert(secret, grant); +} + +/// Resolves an unexpired bearer token issued by this exact running instance. +fn lookup_credential( + credentials: &mut HashMap, + auth_token: &AuthToken, + instance_id: &InstanceId, +) -> Result { + if credentials + .get(auth_token.secret()) + .is_some_and(CredentialGrant::is_expired) + { + credentials.remove(auth_token.secret()); + } + let grant = credentials + .get(auth_token.secret()) + .cloned() + .ok_or_else(|| { + ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control credential is invalid", + ) + })?; + grant.verify_for_action(instance_id, grant.action)?; + Ok(grant) +} +fn outside_warp_publication_supported() -> bool { + cfg!(not(target_os = "windows")) +} + +/// Performs browser-origin hardening for local-control endpoints. +/// +/// These checks intentionally reject browser-style `Origin` requests and stale +/// endpoint selections, but they are not an authorization boundary. Scoped +/// bearer credentials and grant validation remain the authority for control +/// requests. +pub(crate) fn validate_loopback_headers( + headers: &HeaderMap, + expected_host: &str, +) -> Result<(), ControlError> { + if headers.contains_key(ORIGIN) { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "browser-origin local-control requests are not allowed", + )); + } + let host = headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Host header is required for local-control requests", + ) + })?; + if host != expected_host { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Host header does not match the selected local-control endpoint", + )); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) use bridge::validate_request_authority; +#[cfg(test)] +pub(crate) use permissions::{ + capabilities, ensure_settings_allow_action, outside_warp_control_enabled_for_settings, +}; +#[cfg(test)] +pub(crate) use resolver::{ + require_active_window_id, resolve_index_from_ids, resolve_title_from_matches, + validate_action_params, validate_tab_create_target, +}; + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs new file mode 100644 index 0000000000..b1fcbc4420 --- /dev/null +++ b/app/src/local_control/mod_tests.rs @@ -0,0 +1,484 @@ +use std::collections::HashMap; + +use ::local_control::auth::{CredentialGrant, CredentialRequest}; +use ::local_control::protocol::{ + Action, ActionKind, PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, + WindowSelector, WindowTarget, +}; +use ::local_control::{ErrorCode, InstanceId, InvocationContext, RequestEnvelope}; +use axum::body::Bytes; +use axum::extract::State; +use axum::http::header::{AUTHORIZATION, HOST, ORIGIN}; +use axum::http::{HeaderMap, HeaderValue}; +use chrono::Duration; +use settings::Setting as _; +use warp_core::features::FeatureFlag; +use warpui::SingletonEntity as _; + +#[cfg(unix)] +use super::ensure_peer_uid; +use super::{ + capabilities, ensure_feature_enabled, ensure_protocol_version, ensure_settings_allow_action, + handle_control_request, insert_credential, issue_credential, lookup_credential, + outside_warp_control_enabled_for_settings, require_active_window_id, resolve_index_from_ids, + resolve_title_from_matches, validate_action_params, validate_loopback_headers, + validate_request_authority, validate_tab_create_target, ControlServerState, LocalControlBridge, + LocalControlServer, MAX_ACTIVE_CREDENTIALS, +}; +use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; + +fn settings_with_mode(mode: LocalControlMode) -> LocalControlSettings { + LocalControlSettings { + local_control_mode: LocalControlModeSetting::new(Some(mode)), + } +} +#[cfg(unix)] +#[tokio::test] +async fn credential_broker_rejects_peer_from_different_user() { + let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair"); + let actual_uid = stream.peer_cred().expect("peer credentials").uid(); + let different_uid = if actual_uid == u32::MAX { + actual_uid - 1 + } else { + actual_uid + 1 + }; + + let err = ensure_peer_uid(&stream, different_uid).expect_err("different user is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} +#[cfg(unix)] +#[tokio::test] +async fn credential_broker_accepts_peer_from_same_user() { + let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair"); + let actual_uid = stream.peer_cred().expect("peer credentials").uid(); + + ensure_peer_uid(&stream, actual_uid).expect("same user is accepted"); +} + +#[test] +fn protocol_version_helper_rejects_unsupported_versions() { + ensure_protocol_version(::local_control::PROTOCOL_VERSION) + .expect("current version is accepted"); + + let err = ensure_protocol_version(::local_control::PROTOCOL_VERSION + 1) + .expect_err("future protocol version is rejected"); + assert_eq!(err.code, ErrorCode::ProtocolVersionUnsupported); +} + +#[test] +fn tab_create_accepts_default_active_and_window_targets() { + validate_tab_create_target(&TargetSelector::default()).expect("default target is accepted"); + + validate_tab_create_target(&TargetSelector { + window: Some(WindowTarget::Active), + tab: Some(TabTarget::Active), + pane: Some(PaneTarget::Active), + }) + .expect("active target is accepted"); + + validate_tab_create_target(&TargetSelector { + window: Some(WindowTarget::Id { + id: WindowSelector("window".to_owned()), + }), + tab: None, + pane: None, + }) + .expect("window id target is accepted"); + + validate_tab_create_target(&TargetSelector { + window: Some(WindowTarget::Index { index: 0 }), + tab: None, + pane: None, + }) + .expect("window index target is accepted"); + + validate_tab_create_target(&TargetSelector { + window: Some(WindowTarget::Title { + title: "window".to_owned(), + }), + tab: None, + pane: None, + }) + .expect("window title target is accepted"); +} + +#[test] +fn tab_create_rejects_concrete_targets() { + let err = validate_tab_create_target(&TargetSelector { + window: None, + tab: Some(TabTarget::Id { + id: TabSelector("tab".to_owned()), + }), + pane: None, + }) + .expect_err("concrete tab target is rejected"); + assert_eq!(err.code, ErrorCode::StaleTarget); + + let err = validate_tab_create_target(&TargetSelector { + window: None, + tab: None, + pane: Some(PaneTarget::Id { + id: PaneSelector("pane".to_owned()), + }), + }) + .expect_err("concrete pane target is rejected"); + assert_eq!(err.code, ErrorCode::StaleTarget); +} + +#[test] +fn tab_create_rejects_unsupported_selector_forms() { + let err = validate_tab_create_target(&TargetSelector { + window: None, + tab: Some(TabTarget::Index { index: 0 }), + pane: None, + }) + .expect_err("indexed tab target is rejected"); + assert_eq!(err.code, ErrorCode::InvalidSelector); +} + +#[test] +fn capabilities_advertises_only_first_slice_core_actions() { + assert_eq!( + capabilities(), + vec![ + ActionKind::InstanceList, + ActionKind::AppPing, + ActionKind::AppVersion, + ActionKind::TabCreate, + ] + ); +} + +#[test] +fn loopback_headers_reject_origin_and_host_mismatch() { + let expected_host = "127.0.0.1:1234"; + let mut headers = HeaderMap::new(); + headers.insert(HOST, HeaderValue::from_static(expected_host)); + + validate_loopback_headers(&headers, expected_host).expect("matching host should be accepted"); + + headers.insert(ORIGIN, HeaderValue::from_static("https://example.com")); + let err = + validate_loopback_headers(&headers, expected_host).expect_err("origin should be rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); + + headers.remove(ORIGIN); + headers.insert(HOST, HeaderValue::from_static("localhost:1234")); + let err = validate_loopback_headers(&headers, expected_host) + .expect_err("host mismatch should be rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); + + let headers = HeaderMap::new(); + let err = validate_loopback_headers(&headers, expected_host) + .expect_err("missing host should be rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + +#[test] +fn outside_warp_discovery_requires_everywhere_mode() { + assert!(!outside_warp_control_enabled_for_settings( + &settings_with_mode(LocalControlMode::Disabled) + )); + assert!(!outside_warp_control_enabled_for_settings( + &settings_with_mode(LocalControlMode::EnabledWithinWarp) + )); + assert!(outside_warp_control_enabled_for_settings( + &settings_with_mode(LocalControlMode::EnabledEverywhere) + )); +} + +#[test] +fn tab_create_requires_active_window() { + let active = warpui::WindowId::from_usize(1); + + assert_eq!( + require_active_window_id(Some(active)).expect("active"), + active + ); + let err = require_active_window_id(None).expect_err("missing active window"); + assert_eq!(err.code, ErrorCode::MissingTarget); +} + +#[test] +fn window_title_resolution_distinguishes_missing_and_ambiguous_targets() { + let missing = resolve_title_from_matches(&[], ActionKind::TabCreate) + .expect_err("zero-match title is missing"); + assert_eq!(missing.code, ErrorCode::MissingTarget); + + let matches = [ + warpui::WindowId::from_usize(1), + warpui::WindowId::from_usize(2), + ]; + let ambiguous = resolve_title_from_matches(&matches, ActionKind::TabCreate) + .expect_err("multi-match title is ambiguous"); + assert_eq!(ambiguous.code, ErrorCode::AmbiguousTarget); +} + +#[test] +fn missing_window_index_returns_missing_target() { + let err = resolve_index_from_ids(std::iter::empty(), 0, ActionKind::TabCreate) + .expect_err("zero-match index is missing"); + assert_eq!(err.code, ErrorCode::MissingTarget); +} + +#[test] +fn feature_flag_disabled_denies_local_control() { + let _flag = FeatureFlag::WarpControlCli.override_enabled(false); + let err = ensure_feature_enabled().expect_err("feature flag disabled"); + assert_eq!(err.code, ErrorCode::LocalControlDisabled); +} +#[test] +fn duplicate_server_start_is_rejected() { + warpui::App::test((), |mut app| async move { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime"); + let server = app.add_model(|_| LocalControlServer { + _runtime: Some(runtime), + control_endpoint: None, + registered_instance: None, + }); + + let err = server + .update(&mut app, |server, ctx| server.start(ctx)) + .expect_err("duplicate start should fail"); + assert_eq!(err.code, ErrorCode::Internal); + + server + .update(&mut app, |server, _| server._runtime.take()) + .expect("existing runtime should remain active") + .shutdown_background(); + }); +} + +#[test] +fn outside_warp_requires_everywhere_mode() { + let settings = settings_with_mode(LocalControlMode::EnabledWithinWarp); + + let err = ensure_settings_allow_action( + &settings, + InvocationContext::OutsideWarp, + ActionKind::TabCreate, + ) + .expect_err("outside-Warp local control is disabled"); + assert_eq!(err.code, ErrorCode::LocalControlDisabled); +} + +#[test] +fn inside_warp_context_is_not_implemented() { + let settings = settings_with_mode(LocalControlMode::EnabledWithinWarp); + + let err = ensure_settings_allow_action( + &settings, + InvocationContext::InsideWarp, + ActionKind::TabCreate, + ) + .expect_err("inside-Warp grants are not implemented"); + assert_eq!(err.code, ErrorCode::ExecutionContextNotAllowed); +} + +#[test] +fn disabled_mode_denies_inside_warp_context() { + let settings = settings_with_mode(LocalControlMode::Disabled); + + let err = ensure_settings_allow_action( + &settings, + InvocationContext::InsideWarp, + ActionKind::TabCreate, + ) + .expect_err("inside-Warp local control is disabled"); + assert_eq!(err.code, ErrorCode::LocalControlDisabled); +} + +#[test] +fn enabled_everywhere_allows_outside_warp_context() { + ensure_settings_allow_action( + &settings_with_mode(LocalControlMode::EnabledEverywhere), + InvocationContext::OutsideWarp, + ActionKind::TabCreate, + ) + .expect("outside-Warp local control is enabled"); +} + +#[test] +fn tab_create_rejects_malformed_params() { + let err = validate_action_params(&Action { + kind: ActionKind::TabCreate, + params: serde_json::json!({ "unexpected": true }), + }) + .expect_err("tab.create params must be empty"); + assert_eq!(err.code, ErrorCode::InvalidParams); + + validate_action_params(&Action { + kind: ActionKind::TabCreate, + params: serde_json::json!({}), + }) + .expect("empty tab.create params are accepted"); +} + +#[test] +fn metadata_actions_reject_malformed_params() { + let err = validate_action_params(&Action { + kind: ActionKind::AppPing, + params: serde_json::json!({ "unexpected": true }), + }) + .expect_err("app.ping params must be empty"); + assert_eq!(err.code, ErrorCode::InvalidParams); +} + +#[test] +fn bridge_checks_grant_before_action_params() { + let instance_id = InstanceId("inst_test".to_owned()); + let grant = CredentialGrant::new( + instance_id.clone(), + ActionKind::AppPing, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ); + let err = validate_request_authority( + &instance_id, + &Action { + kind: ActionKind::AppVersion, + params: serde_json::json!({ "unexpected": true }), + }, + &grant, + ) + .expect_err("wrong-action grant is rejected before params"); + assert_eq!(err.code, ErrorCode::InsufficientPermissions); +} + +#[test] +fn credential_insertion_prunes_expired_and_caps_active_grants() { + let mut credentials = HashMap::new(); + let instance_id = InstanceId("inst_test".to_owned()); + insert_credential( + &mut credentials, + "expired".to_owned(), + CredentialGrant::new( + instance_id.clone(), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(-1), + ), + ); + insert_credential( + &mut credentials, + "active".to_owned(), + CredentialGrant::new( + instance_id.clone(), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ), + ); + assert!(!credentials.contains_key("expired")); + + for index in 0..MAX_ACTIVE_CREDENTIALS { + insert_credential( + &mut credentials, + format!("active-{index}"), + CredentialGrant::new( + instance_id.clone(), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ), + ); + } + assert_eq!(credentials.len(), MAX_ACTIVE_CREDENTIALS); + assert!(credentials.contains_key(&format!("active-{}", MAX_ACTIVE_CREDENTIALS - 1))); +} + +#[test] +fn expired_credential_is_rejected_and_pruned_before_request_decode() { + let mut credentials = HashMap::new(); + let token = ::local_control::AuthToken::from_secret("expired"); + credentials.insert( + token.secret().to_owned(), + CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(-1), + ), + ); + + let err = lookup_credential( + &mut credentials, + &token, + &InstanceId("inst_test".to_owned()), + ) + .expect_err("expired grant is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); + assert!(!credentials.contains_key(token.secret())); +} + +#[test] +fn mode_narrowing_invalidates_existing_outside_warp_grant_and_prevents_new_grants() { + let _flag = FeatureFlag::WarpControlCli.override_enabled(true); + warpui::App::test((), |mut app| async move { + crate::test_util::settings::initialize_settings_for_tests(&mut app); + app.update(|ctx| { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .local_control_mode + .set_value(LocalControlMode::EnabledEverywhere, ctx) + }) + }) + .expect("outside-Warp control should enable"); + + let instance_id = InstanceId("inst_test".to_owned()); + let expected_host = "127.0.0.1:1234".to_owned(); + let bridge = app.add_singleton_model(LocalControlBridge::new); + let state = bridge.update(&mut app, |bridge, ctx| { + bridge.set_instance_id(instance_id.clone()); + ControlServerState { + bridge_spawner: ctx.spawner(), + instance_id: instance_id.clone(), + expected_host: expected_host.clone(), + credentials: Default::default(), + } + }); + let credential = issue_credential( + &state, + CredentialRequest::new(ActionKind::AppPing, InvocationContext::OutsideWarp), + ) + .await + .expect("outside-Warp credential should be issued"); + + app.update(|ctx| { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .local_control_mode + .set_value(LocalControlMode::EnabledWithinWarp, ctx) + }) + }) + .expect("mode should narrow"); + + let mut headers = HeaderMap::new(); + headers.insert( + HOST, + HeaderValue::from_str(&expected_host).expect("valid host"), + ); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&credential.authorization_value()).expect("valid credential"), + ); + let request = RequestEnvelope::new(Action::new(ActionKind::AppPing)); + let response = handle_control_request( + State(state.clone()), + headers, + Bytes::from(serde_json::to_vec(&request).expect("request serializes")), + ) + .await; + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); + + let err = issue_credential( + &state, + CredentialRequest::new(ActionKind::AppPing, InvocationContext::OutsideWarp), + ) + .await + .expect_err("narrowed mode should prevent new outside-Warp grants"); + assert_eq!(err.code, ErrorCode::LocalControlDisabled); + }); +} diff --git a/app/src/local_control/permissions.rs b/app/src/local_control/permissions.rs new file mode 100644 index 0000000000..bc0bc6cd21 --- /dev/null +++ b/app/src/local_control/permissions.rs @@ -0,0 +1,92 @@ +//! Permission checks that map invocation context onto local settings. +use ::local_control::{ActionKind, ControlError, ErrorCode, InvocationContext, PROTOCOL_VERSION}; +use warpui::{ModelContext, SingletonEntity}; + +use crate::features::FeatureFlag; +use crate::local_control::LocalControlBridge; +use crate::settings::LocalControlSettings; + +pub(super) fn warp_control_cli_enabled() -> bool { + FeatureFlag::WarpControlCli.is_enabled() +} + +pub(super) fn ensure_protocol_version(protocol_version: u32) -> Result<(), ControlError> { + if protocol_version == PROTOCOL_VERSION { + return Ok(()); + } + Err(ControlError::new( + ErrorCode::ProtocolVersionUnsupported, + format!("unsupported protocol version {protocol_version}"), + )) +} + +pub(super) fn ensure_feature_enabled() -> Result<(), ControlError> { + if warp_control_cli_enabled() { + return Ok(()); + } + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "Warp control CLI is disabled by feature flag", + )) +} + +#[cfg(test)] +pub(crate) fn outside_warp_control_enabled_for_settings(settings: &LocalControlSettings) -> bool { + settings.outside_warp_control_enabled() +} + +#[cfg(test)] +pub(crate) fn capabilities() -> Vec { + ActionKind::implemented_metadata() + .into_iter() + .map(|metadata| metadata.kind) + .collect() +} + +pub(super) fn ensure_action_allowed( + context: InvocationContext, + action: ActionKind, + ctx: &mut ModelContext, +) -> Result<(), ControlError> { + let settings = LocalControlSettings::as_ref(ctx); + ensure_settings_allow_action(settings, context, action) +} + +pub(crate) fn ensure_settings_allow_action( + settings: &LocalControlSettings, + context: InvocationContext, + action: ActionKind, +) -> Result<(), ControlError> { + match context { + InvocationContext::InsideWarp => { + if !settings.inside_warp_control_enabled() { + return Err(ControlError::new( + ErrorCode::LocalControlDisabled, + format!( + "{} is disabled for inside-Warp local control", + action.as_str() + ), + )); + } + Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + format!( + "{} cannot run from inside-Warp local control until verified terminal proofs are implemented", + action.as_str() + ), + )) + } + InvocationContext::OutsideWarp => { + if !settings.outside_warp_control_enabled() { + return Err(ControlError::new( + ErrorCode::LocalControlDisabled, + format!( + "{} is disabled for outside-Warp local control", + action.as_str() + ), + )); + } + Ok(()) + } + } +} diff --git a/app/src/local_control/resolver.rs b/app/src/local_control/resolver.rs new file mode 100644 index 0000000000..61cfbfadd2 --- /dev/null +++ b/app/src/local_control/resolver.rs @@ -0,0 +1,182 @@ +//! Target and parameter validation for the first local-control action slice. +use ::local_control::protocol::{PaneTarget, TabTarget, TargetSelector, WindowTarget}; +use ::local_control::{ActionKind, ControlError, ErrorCode}; +use warpui::{ModelContext, WindowId}; + +use crate::local_control::LocalControlBridge; +use crate::workspace::Workspace; + +pub(crate) fn validate_tab_create_target(target: &TargetSelector) -> Result<(), ControlError> { + if matches!(target.tab.as_ref(), Some(TabTarget::Id { .. })) { + return Err(ControlError::new( + ErrorCode::StaleTarget, + "tab.create cannot resolve the requested tab id", + )); + } + if !matches!(target.tab.as_ref(), None | Some(TabTarget::Active)) { + return Err(ControlError::new( + ErrorCode::InvalidSelector, + "tab.create does not accept a concrete tab selector", + )); + } + if matches!(target.pane.as_ref(), Some(PaneTarget::Id { .. })) { + return Err(ControlError::new( + ErrorCode::StaleTarget, + "tab.create cannot resolve the requested pane id", + )); + } + if !matches!(target.pane.as_ref(), None | Some(PaneTarget::Active)) { + return Err(ControlError::new( + ErrorCode::InvalidSelector, + "tab.create does not accept a concrete pane selector", + )); + } + Ok(()) +} + +/// Validates action-specific params implemented by this branch stack layer. +/// +/// This is intentionally narrow for the current implementation slice. Later +/// slices add their own params and expand this validation alongside the +/// corresponding action handlers. +pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result<(), ControlError> { + if !action.kind.is_implemented() { + return Ok(()); + } + if action + .params + .as_object() + .is_some_and(serde_json::Map::is_empty) + { + return Ok(()); + } + Err(ControlError::new( + ErrorCode::InvalidParams, + format!( + "{} does not accept parameters in the first implementation slice", + action.kind.as_str() + ), + )) +} + +pub(super) fn target_window_id_for_target( + ctx: &mut ModelContext, + target: &TargetSelector, + action: ActionKind, +) -> Result { + match target.window.as_ref() { + None | Some(WindowTarget::Active) => active_or_single_window_id(ctx, action), + Some(WindowTarget::Id { id }) => ctx + .window_ids() + .find(|window_id| window_id.to_string() == id.0) + .ok_or_else(|| { + ControlError::new( + ErrorCode::StaleTarget, + format!("{} cannot resolve the requested window id", action.as_str()), + ) + }), + Some(WindowTarget::Index { index }) => { + resolve_index_from_ids(ctx.window_ids(), *index, action) + } + Some(WindowTarget::Title { title }) => target_window_id_by_title(ctx, title, action), + } +} + +#[cfg(test)] +pub(crate) fn require_active_window_id( + active_window: Option, +) -> Result { + active_window.ok_or_else(|| { + ControlError::new( + ErrorCode::MissingTarget, + "tab.create requires an active Warp window", + ) + }) +} + +fn active_or_single_window_id( + ctx: &mut ModelContext, + action: ActionKind, +) -> Result { + if let Some(window_id) = ctx.windows().active_window() { + return Ok(window_id); + } + let window_ids = ctx.window_ids().collect::>(); + match window_ids.as_slice() { + [window_id] => Ok(*window_id), + [] => Err(ControlError::new( + ErrorCode::MissingTarget, + format!("{} requires an active Warp window", action.as_str()), + )), + _ => Err(ControlError::new( + ErrorCode::AmbiguousTarget, + format!( + "{} requires an explicit window selector when no Warp window is active", + action.as_str() + ), + )), + } +} + +fn target_window_id_by_title( + ctx: &mut ModelContext, + title: &str, + action: ActionKind, +) -> Result { + let mut matching = Vec::new(); + for window_id in ctx.window_ids().collect::>() { + if window_title(window_id, ctx).as_deref() == Some(title) { + matching.push(window_id); + } + } + resolve_title_from_matches(&matching, action) +} + +pub(crate) fn resolve_index_from_ids( + ids: impl Iterator, + index: u32, + action: ActionKind, +) -> Result { + ids.into_iter().nth(index as usize).ok_or_else(|| { + ControlError::new( + ErrorCode::MissingTarget, + format!( + "{} cannot resolve the requested window index", + action.as_str() + ), + ) + }) +} + +pub(crate) fn resolve_title_from_matches( + matching: &[WindowId], + action: ActionKind, +) -> Result { + match matching { + [window_id] => Ok(*window_id), + [] => Err(ControlError::new( + ErrorCode::MissingTarget, + format!( + "{} cannot resolve the requested window title", + action.as_str() + ), + )), + _ => Err(ControlError::new( + ErrorCode::AmbiguousTarget, + format!("{} resolved multiple windows by title", action.as_str()), + )), + } +} + +fn window_title(window_id: WindowId, ctx: &mut ModelContext) -> Option { + ctx.views_of_type::(window_id) + .and_then(|workspaces| workspaces.into_iter().next()) + .map(|workspace| { + workspace.read(ctx, |workspace, ctx| { + workspace + .active_tab_pane_group() + .as_ref(ctx) + .display_title(ctx) + }) + }) +} diff --git a/app/src/settings/init.rs b/app/src/settings/init.rs index 33df98dca5..a032ed9139 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -16,8 +16,8 @@ use super::{ AISettings, AccessibilitySettings, AliasExpansionSettings, AppEditorSettings, BlockVisibilitySettings, ChangelogSettings, CodeSettings, DebugSettings, EmacsBindingsSettings, FontSettings, FontSettingsChangedEvent, GPUSettings, InputBoxType, InputModeSettings, - InputSettings, PaneSettings, SameLinePromptBlockSettings, ScrollSettings, SelectionSettings, - SshSettings, ThemeSettings, VimBannerSettings, WarpDrivePrivacySettings, + InputSettings, LocalControlSettings, PaneSettings, SameLinePromptBlockSettings, ScrollSettings, + SelectionSettings, SshSettings, ThemeSettings, VimBannerSettings, WarpDrivePrivacySettings, }; use crate::ai::cloud_agent_settings::CloudAgentSettings; use crate::banner::BannerState; @@ -96,6 +96,9 @@ pub fn register_all_settings(ctx: &mut AppContext) { EmacsBindingsSettings::register(ctx); SameLinePromptBlockSettings::register(ctx); SemanticSelection::register(ctx); + if FeatureFlag::WarpControlCli.is_enabled() { + LocalControlSettings::register(ctx); + } #[cfg(any(target_os = "linux", target_os = "freebsd"))] super::LinuxAppConfiguration::register(ctx); diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs new file mode 100644 index 0000000000..d1605b3257 --- /dev/null +++ b/app/src/settings/local_control.rs @@ -0,0 +1,220 @@ +//! Secure local setting that gates local-control invocation contexts. +//! +//! This setting is local-only, kept out of the user-visible settings file, and +//! persisted through Warp's secure storage provider. It is the authoritative +//! enablement bit for local control. +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use settings::macros::define_settings_group; +use settings::{SecureSetting, Setting, SupportedPlatforms, SyncToCloud}; +use warpui::{AppContext, ModelContext}; +use warpui_extras::secure_storage; + +const LOCAL_CONTROL_MODE_STORAGE_KEY: &str = "LocalControlMode"; + +/// User-selected local-control availability. +#[derive( + Clone, + Copy, + Debug, + Default, + Deserialize, + Eq, + PartialEq, + schemars::JsonSchema, + Serialize, + settings_value::SettingsValue, +)] +#[schemars( + description = "Which local-control invocation contexts are allowed.", + rename_all = "snake_case" +)] +pub enum LocalControlMode { + #[default] + Disabled, + EnabledWithinWarp, + EnabledEverywhere, +} + +impl LocalControlMode { + pub const ALL: [Self; 3] = [ + Self::Disabled, + Self::EnabledWithinWarp, + Self::EnabledEverywhere, + ]; + + pub fn allows_inside_warp(self) -> bool { + matches!(self, Self::EnabledWithinWarp | Self::EnabledEverywhere) + } + + pub fn allows_outside_warp(self) -> bool { + matches!(self, Self::EnabledEverywhere) + } + + pub fn as_dropdown_label(self) -> &'static str { + match self { + Self::Disabled => "Disabled", + Self::EnabledWithinWarp => "Enabled within Warp", + Self::EnabledEverywhere => "Enabled everywhere, including outside Warp", + } + } +} + +define_settings_group!(LocalControlSettings, settings: [ + local_control_mode: LocalControlModeSetting, +]); + +/// Setting wrapper for the authoritative local-control mode. +pub struct LocalControlModeSetting { + inner: LocalControlMode, + is_explicitly_set: bool, +} + +impl LocalControlModeSetting { + fn emit_changed( + ctx: &mut ModelContext, + change_event_reason: settings::ChangeEventReason, + ) { + ctx.emit(LocalControlSettingsChangedEvent::LocalControlModeSetting { + change_event_reason, + }); + } +} + +impl SecureSetting for LocalControlModeSetting { + fn write_secure_storage_value( + storage: &dyn secure_storage::SecureStorage, + key: &str, + value: &str, + ) -> Result<(), secure_storage::Error> { + storage.write_value_with_owner_only_fallback(key, value) + } +} +impl Setting for LocalControlModeSetting { + type Group = LocalControlSettings; + type Value = LocalControlMode; + + fn new(value: Option) -> Self { + match value { + Some(value) => Self { + inner: value, + is_explicitly_set: true, + }, + None => Self { + inner: Self::default_value(), + is_explicitly_set: false, + }, + } + } + + fn setting_name() -> &'static str { + "LocalControlModeSetting" + } + + fn storage_key() -> &'static str { + LOCAL_CONTROL_MODE_STORAGE_KEY + } + + fn supported_platforms() -> SupportedPlatforms { + SupportedPlatforms::DESKTOP + } + + fn sync_to_cloud() -> SyncToCloud { + SyncToCloud::Never + } + + fn is_private() -> bool { + true + } + + fn value(&self) -> &Self::Value { + &self.inner + } + + fn clear_value(&mut self, ctx: &mut ModelContext) -> Result<()> { + Self::clear_from_secure_storage(ctx)?; + self.inner = self.validate(Self::default_value()); + self.is_explicitly_set = false; + Self::emit_changed(ctx, settings::ChangeEventReason::Clear); + Ok(()) + } + + fn load_value( + &mut self, + new_value: Self::Value, + explicitly_set: bool, + ctx: &mut ModelContext, + ) -> Result<()> { + let validated = self.validate(new_value); + if self.value() != &validated || self.is_explicitly_set != explicitly_set { + self.inner = validated; + self.is_explicitly_set = explicitly_set; + Self::emit_changed(ctx, settings::ChangeEventReason::LocalChange); + } + Ok(()) + } + + fn set_value_from_cloud_sync( + &mut self, + _: Self::Value, + _: &mut ModelContext, + ) -> Result<()> { + Ok(()) + } + + fn set_value( + &mut self, + new_value: Self::Value, + ctx: &mut ModelContext, + ) -> Result<()> { + let changed_in_storage = Self::write_to_secure_storage(&new_value, ctx)?; + if self.value() != &new_value || changed_in_storage { + self.inner = self.validate(new_value); + self.is_explicitly_set = true; + Self::emit_changed(ctx, settings::ChangeEventReason::LocalChange); + } + Ok(()) + } + + fn default_value() -> Self::Value { + LocalControlMode::Disabled + } + + fn new_from_storage(ctx: &mut AppContext) -> Self { + Self::new(Self::read_from_secure_storage(ctx)) + } + + fn is_supported_on_current_platform(&self) -> bool { + SupportedPlatforms::DESKTOP.matches_current_platform() + } + + fn is_value_explicitly_set(&self) -> bool { + self.is_explicitly_set + } +} + +impl std::ops::Deref for LocalControlModeSetting { + type Target = LocalControlMode; + + fn deref(&self) -> &Self::Target { + self.value() + } +} + +impl LocalControlSettings { + pub fn mode(&self) -> LocalControlMode { + *self.local_control_mode + } + + pub fn inside_warp_control_enabled(&self) -> bool { + self.mode().allows_inside_warp() + } + + pub fn outside_warp_control_enabled(&self) -> bool { + self.mode().allows_outside_warp() + } +} + +#[cfg(test)] +#[path = "local_control_tests.rs"] +mod tests; diff --git a/app/src/settings/local_control_tests.rs b/app/src/settings/local_control_tests.rs new file mode 100644 index 0000000000..6dc9f305eb --- /dev/null +++ b/app/src/settings/local_control_tests.rs @@ -0,0 +1,202 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use settings::{PrivatePreferences, PublicPreferences, Setting as _, SettingsManager, SyncToCloud}; +use warpui::SingletonEntity as _; +use warpui_extras::secure_storage::{self, AppContextExt as _}; +use warpui_extras::user_preferences; + +use super::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; + +#[derive(Default)] +struct InMemorySecureStorage { + values: Mutex>, +} + +impl secure_storage::SecureStorage for InMemorySecureStorage { + fn write_value(&self, key: &str, value: &str) -> Result<(), secure_storage::Error> { + match self.values.lock() { + Ok(mut values) => { + values.insert(key.to_owned(), value.to_owned()); + Ok(()) + } + Err(err) => Err(secure_storage::Error::Unknown(anyhow::anyhow!( + err.to_string() + ))), + } + } + + fn read_value(&self, key: &str) -> Result { + match self.values.lock() { + Ok(values) => values + .get(key) + .cloned() + .ok_or(secure_storage::Error::NotFound), + Err(err) => Err(secure_storage::Error::Unknown(anyhow::anyhow!( + err.to_string() + ))), + } + } + + fn remove_value(&self, key: &str) -> Result<(), secure_storage::Error> { + match self.values.lock() { + Ok(mut values) => { + values.remove(key); + Ok(()) + } + Err(err) => Err(secure_storage::Error::Unknown(anyhow::anyhow!( + err.to_string() + ))), + } + } +} + +fn default_settings() -> LocalControlSettings { + LocalControlSettings { + local_control_mode: LocalControlModeSetting::new(None), + } +} + +#[test] +fn defaults_disable_warp_control() { + let settings = default_settings(); + + assert_eq!(LocalControlMode::default(), LocalControlMode::Disabled); + assert_eq!(settings.mode(), LocalControlMode::Disabled); + assert!(!settings.inside_warp_control_enabled()); + assert!(!settings.outside_warp_control_enabled()); +} + +#[test] +fn mode_is_persisted_to_secure_storage() { + warpui::App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| { + PublicPreferences::new( + Box::::default(), + ) + }); + ctx.add_singleton_model(|_| { + PrivatePreferences::new( + Box::::default(), + ) + }); + ctx.add_singleton_model(|_| SettingsManager::default()); + ctx.add_singleton_model(|_| -> secure_storage::Model { + Box::::default() + }); + LocalControlSettings::register(ctx); + }); + + app.update(|ctx| { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .local_control_mode + .set_value(LocalControlMode::EnabledEverywhere, ctx) + }) + }) + .expect("setting update should succeed"); + + app.read(|ctx| { + let stored = ctx + .secure_storage() + .read_value(LocalControlModeSetting::storage_key()) + .expect("local-control mode should be stored securely"); + let mode = serde_json::from_str::(&stored) + .expect("stored local-control mode should deserialize"); + assert_eq!(mode, LocalControlMode::EnabledEverywhere); + + let private_value = LocalControlModeSetting::preferences_for_setting(ctx) + .read_value(LocalControlModeSetting::storage_key()) + .expect("private preferences should be readable"); + assert!(private_value.is_none()); + }); + }); +} + +#[test] +fn mode_does_not_migrate_from_private_preferences() { + warpui::App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| { + PublicPreferences::new( + Box::::default(), + ) + }); + ctx.add_singleton_model(|_| { + PrivatePreferences::new( + Box::::default(), + ) + }); + ctx.add_singleton_model(|_| SettingsManager::default()); + ctx.add_singleton_model(|_| -> secure_storage::Model { + Box::::default() + }); + LocalControlModeSetting::preferences_for_setting(ctx) + .write_value( + LocalControlModeSetting::storage_key(), + serde_json::to_string(&LocalControlMode::EnabledEverywhere) + .expect("mode serializes"), + ) + .expect("private preference is writable"); + LocalControlSettings::register(ctx); + }); + + app.read(|ctx| { + assert_eq!( + LocalControlSettings::as_ref(ctx).mode(), + LocalControlMode::Disabled + ); + let private_value = LocalControlModeSetting::preferences_for_setting(ctx) + .read_value(LocalControlModeSetting::storage_key()) + .expect("private preference is readable"); + assert!(private_value.is_some()); + }); + }); +} +#[test] +fn mode_is_private_and_never_cloud_synced() { + assert_eq!(LocalControlModeSetting::sync_to_cloud(), SyncToCloud::Never); + assert!(LocalControlModeSetting::is_private()); +} + +#[test] +fn cloud_sync_cannot_enable_local_control() { + warpui::App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| { + PublicPreferences::new( + Box::::default(), + ) + }); + ctx.add_singleton_model(|_| { + PrivatePreferences::new( + Box::::default(), + ) + }); + ctx.add_singleton_model(|_| SettingsManager::default()); + ctx.add_singleton_model(|_| -> secure_storage::Model { + Box::::default() + }); + LocalControlSettings::register(ctx); + }); + + app.update(|ctx| { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .local_control_mode + .set_value_from_cloud_sync(LocalControlMode::EnabledEverywhere, ctx) + }) + }) + .expect("cloud sync update should be ignored without error"); + + app.read(|ctx| { + let settings = LocalControlSettings::as_ref(ctx); + assert_eq!(settings.mode(), LocalControlMode::Disabled); + let stored = ctx + .secure_storage() + .read_value(LocalControlModeSetting::storage_key()); + assert!(matches!(stored, Err(secure_storage::Error::NotFound))); + }); + }); +} diff --git a/app/src/settings/mod.rs b/app/src/settings/mod.rs index 1063ad8a9d..e5202601b8 100644 --- a/app/src/settings/mod.rs +++ b/app/src/settings/mod.rs @@ -20,6 +20,7 @@ mod input; mod input_mode; #[cfg(any(target_os = "linux", target_os = "freebsd"))] mod linux; +mod local_control; pub mod macros; pub mod manager; pub mod native_preference; @@ -54,6 +55,7 @@ pub use input::*; pub use input_mode::*; #[cfg(any(target_os = "linux", target_os = "freebsd"))] pub use linux::*; +pub use local_control::*; pub use native_preference::*; pub use onboarding::*; pub use pane::*; diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index f3d7fec065..c928f58e27 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -18,6 +18,7 @@ use nav::{SettingsNavItem, SettingsUmbrella}; use pathfinder_geometry::vector::Vector2F; use privacy_page::{PrivacyPageView, PrivacyPageViewEvent}; use referrals_page::{ReferralsPageEvent, ReferralsPageView}; +use scripting_page::ScriptingSettingsPageView; use settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates}; use settings_page::{ MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle, @@ -100,6 +101,7 @@ mod privacy; mod privacy_page; mod referrals_page; mod remove_custom_endpoint_confirmation_dialog; +mod scripting_page; mod settings_file_footer; pub(crate) mod settings_page; mod show_blocks_view; @@ -246,6 +248,7 @@ pub enum SettingsSection { Keybindings, Privacy, Referrals, + Scripting, SharedBlocks, Teams, WarpDrive, @@ -285,6 +288,7 @@ impl Display for SettingsSection { SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"), SettingsSection::SharedBlocks => write!(f, "Shared blocks"), SettingsSection::MCPServers => write!(f, "MCP Servers"), + SettingsSection::Scripting => write!(f, "Scripting"), SettingsSection::WarpDrive => write!(f, "Warp Drive"), SettingsSection::WarpAgent => write!(f, "Warp Agent"), SettingsSection::AgentProfiles => write!(f, "Profiles"), @@ -382,6 +386,7 @@ impl FromStr for SettingsSection { "Keyboard shortcuts" => Ok(Self::Keybindings), "Privacy" => Ok(Self::Privacy), "Referrals" => Ok(Self::Referrals), + "Scripting" => Ok(Self::Scripting), "Shared blocks" => Ok(Self::SharedBlocks), "Teams" => Ok(Self::Teams), "Warpify" => Ok(Self::Warpify), @@ -1058,6 +1063,7 @@ macro_rules! update_page { SettingsPageViewHandle::OzCloudAPIKeys(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Privacy(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Referrals(handle) => $ctx.update_view(handle, $update), + SettingsPageViewHandle::Scripting(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::CloudEnvironments(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update), @@ -1206,6 +1212,11 @@ impl SettingsView { ctx.subscribe_to_view(&referrals_page_handle, |me, _, event, ctx| { me.handle_referrals_page_event(event, ctx); }); + let scripting_page_handle = if FeatureFlag::WarpControlCli.is_enabled() { + Some(ctx.add_typed_action_view(ScriptingSettingsPageView::new)) + } else { + None + }; // Warp Drive page let warp_drive_page_handle = @@ -1269,6 +1280,10 @@ impl SettingsView { SettingsPage::new(warp_drive_page_handle), ]; + if let Some(scripting_page_handle) = scripting_page_handle { + settings_pages.push(SettingsPage::new(scripting_page_handle)); + } + settings_pages.extend(vec![ SettingsPage::new(mcp_servers_page_handle), SettingsPage::new(environments_page_handle.clone()), @@ -1311,10 +1326,26 @@ impl SettingsView { SettingsNavItem::Page(SettingsSection::About), ]; + if FeatureFlag::WarpControlCli.is_enabled() { + let shared_blocks_index = nav_items + .iter() + .position(|item| { + matches!(item, SettingsNavItem::Page(SettingsSection::SharedBlocks)) + }) + .unwrap_or(nav_items.len()); + nav_items.insert( + shared_blocks_index, + SettingsNavItem::Page(SettingsSection::Scripting), + ); + } + // Resolve the initial page: map internal backing-page sections to their default subpage. let initial_page = match page { Some(SettingsSection::AI) => SettingsSection::WarpAgent, Some(SettingsSection::Code) => SettingsSection::CodeIndexing, + Some(SettingsSection::Scripting) if !FeatureFlag::WarpControlCli.is_enabled() => { + SettingsSection::Account + } Some(section) if section.is_subpage() => section, other => other.unwrap_or_default(), }; @@ -2057,6 +2088,7 @@ impl SettingsView { SettingsPageViewHandle::Privacy(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::Warpify(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::Referrals(v) => v.as_ref(app).should_render(app), + SettingsPageViewHandle::Scripting(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::AI(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::CloudEnvironments(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::MCPServers(v) => v.as_ref(app).should_render(app), diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs new file mode 100644 index 0000000000..49d4f3a1a9 --- /dev/null +++ b/app/src/settings_view/scripting_page.rs @@ -0,0 +1,173 @@ +//! Settings UI for local scripting and Warp control permissions. +use std::cell::RefCell; +use std::collections::HashMap; + +use settings::Setting as _; +use warpui::elements::{ChildView, Element, MouseStateHandle}; +use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; + +use super::settings_page::{ + render_body_item, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta, + SettingsPageViewHandle, SettingsWidget, +}; +use super::{SettingsSection, ToggleState}; +use crate::appearance::Appearance; +use crate::features::FeatureFlag; +use crate::report_if_error; +use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; +use crate::view_components::{Dropdown, DropdownItem}; + +#[derive(Clone, Debug, PartialEq)] +pub enum ScriptingSettingsPageAction { + SetLocalControlMode(LocalControlMode), +} + +pub struct ScriptingSettingsPageView { + page: PageType, + local_only_icon_tooltip_states: RefCell>, + local_control_mode_dropdown: ViewHandle>, +} + +impl ScriptingSettingsPageView { + pub fn new(ctx: &mut ViewContext) -> Self { + let local_control_mode_dropdown = ctx.add_typed_action_view(|ctx| { + let mut dropdown = Dropdown::new(ctx); + dropdown.set_top_bar_max_width(360.); + dropdown + }); + Self::update_local_control_mode_dropdown(local_control_mode_dropdown.clone(), ctx); + + if FeatureFlag::WarpControlCli.is_enabled() { + ctx.subscribe_to_model(&LocalControlSettings::handle(ctx), |view, _, _, ctx| { + Self::update_local_control_mode_dropdown( + view.local_control_mode_dropdown.clone(), + ctx, + ); + ctx.notify(); + }); + } + + Self { + page: PageType::new_uncategorized( + vec![Box::new(LocalControlModeWidget)], + Some("Scripting"), + ), + local_only_icon_tooltip_states: RefCell::new(HashMap::new()), + local_control_mode_dropdown, + } + } + + fn update_local_control_mode_dropdown( + dropdown: ViewHandle>, + ctx: &mut ViewContext, + ) { + let current_mode = LocalControlSettings::as_ref(ctx).mode(); + dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_items( + LocalControlMode::ALL + .into_iter() + .map(|mode| { + DropdownItem::new( + mode.as_dropdown_label(), + ScriptingSettingsPageAction::SetLocalControlMode(mode), + ) + }) + .collect(), + ctx, + ); + dropdown.set_selected_by_action( + ScriptingSettingsPageAction::SetLocalControlMode(current_mode), + ctx, + ); + }); + } +} + +impl Entity for ScriptingSettingsPageView { + type Event = (); +} + +impl TypedActionView for ScriptingSettingsPageView { + type Action = ScriptingSettingsPageAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + ScriptingSettingsPageAction::SetLocalControlMode(mode) => { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + report_if_error!(settings.local_control_mode.set_value(*mode, ctx)); + }); + ctx.notify(); + } + } + } +} + +impl View for ScriptingSettingsPageView { + fn ui_name() -> &'static str { + "ScriptingSettingsPage" + } + + fn render(&self, app: &AppContext) -> Box { + self.page.render(self, app) + } +} + +impl SettingsPageMeta for ScriptingSettingsPageView { + fn section() -> SettingsSection { + SettingsSection::Scripting + } + + fn should_render(&self, _ctx: &AppContext) -> bool { + cfg!(not(target_family = "wasm")) && FeatureFlag::WarpControlCli.is_enabled() + } + + fn update_filter(&mut self, query: &str, ctx: &mut ViewContext) -> MatchData { + self.page.update_filter(query, ctx) + } + + fn scroll_to_widget(&mut self, widget_id: &'static str) { + self.page.scroll_to_widget(widget_id) + } + + fn clear_highlighted_widget(&mut self) { + self.page.clear_highlighted_widget(); + } +} + +impl From> for SettingsPageViewHandle { + fn from(view_handle: ViewHandle) -> Self { + SettingsPageViewHandle::Scripting(view_handle) + } +} + +struct LocalControlModeWidget; + +impl SettingsWidget for LocalControlModeWidget { + type View = ScriptingSettingsPageView; + + fn search_terms(&self) -> &str { + "scripting warp control automation warpctrl local cli inside warp outside warp external scripts disabled enabled" + } + + fn render( + &self, + view: &Self::View, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + render_body_item::( + "warpctrl CLI".into(), + None, + LocalOnlyIconState::for_setting( + LocalControlModeSetting::storage_key(), + LocalControlModeSetting::sync_to_cloud(), + &mut view.local_only_icon_tooltip_states.borrow_mut(), + app, + ), + ToggleState::Enabled, + appearance, + ChildView::new(&view.local_control_mode_dropdown).finish(), + Some("warpctrl allows for scripting Warp's UI. Use with care.'".to_owned()), + ) + } +} diff --git a/app/src/settings_view/settings_page.rs b/app/src/settings_view/settings_page.rs index a235c872f8..a10e7a6bce 100644 --- a/app/src/settings_view/settings_page.rs +++ b/app/src/settings_view/settings_page.rs @@ -38,6 +38,7 @@ use super::main_page::MainSettingsPageView; use super::mcp_servers_page::MCPServersSettingsPageView; use super::privacy_page::PrivacyPageView; use super::referrals_page::ReferralsPageView; +use super::scripting_page::ScriptingSettingsPageView; use super::show_blocks_view::ShowBlocksView; use super::teams_page::TeamsPageView; use super::warp_drive_page::WarpDriveSettingsPageView; @@ -111,6 +112,7 @@ pub enum SettingsPageViewHandle { Privacy(ViewHandle), Warpify(ViewHandle), Referrals(ViewHandle), + Scripting(ViewHandle), AI(ViewHandle), CloudEnvironments(ViewHandle), BillingAndUsage(ViewHandle), @@ -134,6 +136,7 @@ impl SettingsPageViewHandle { Privacy(view_handle) => ChildView::new(view_handle).finish(), Warpify(view_handle) => ChildView::new(view_handle).finish(), Referrals(view_handle) => ChildView::new(view_handle).finish(), + Scripting(view_handle) => ChildView::new(view_handle).finish(), AI(view_handle) => ChildView::new(view_handle).finish(), CloudEnvironments(view_handle) => ChildView::new(view_handle).finish(), BillingAndUsage(view_handle) => ChildView::new(view_handle).finish(), diff --git a/app/src/test_util/settings.rs b/app/src/test_util/settings.rs index 9e9abc3229..01949f6fdf 100644 --- a/app/src/test_util/settings.rs +++ b/app/src/test_util/settings.rs @@ -35,9 +35,9 @@ pub fn initialize_settings_for_tests_with_mode( init_and_register_user_preferences, AISettings, AccessibilitySettings, AliasExpansionSettings, AppEditorSettings, BlockVisibilitySettings, ChangelogSettings, CloudPreferencesSettings, CodeSettings, DebugSettings, EmacsBindingsSettings, FontSettings, - GPUSettings, InputModeSettings, InputSettings, NativePreferenceSettings, PaneSettings, - SameLinePromptBlockSettings, ScrollSettings, SelectionSettings, SshSettings, ThemeSettings, - VimBannerSettings, + GPUSettings, InputModeSettings, InputSettings, LocalControlSettings, + NativePreferenceSettings, PaneSettings, SameLinePromptBlockSettings, ScrollSettings, + SelectionSettings, SshSettings, ThemeSettings, VimBannerSettings, }; use crate::terminal::general_settings::GeneralSettings; use crate::terminal::keys_settings::KeysSettings; @@ -57,6 +57,10 @@ pub fn initialize_settings_for_tests_with_mode( app.update(init_and_register_user_preferences); app.add_singleton_model(|_ctx| SettingsManager::default()); app.add_singleton_model(WarpConfig::mock); + app.update(|ctx| { + // Register a no-op secure storage provider for testing. + warpui_extras::secure_storage::register_noop("test", ctx); + }); AccessibilitySettings::register(app); app.update(AISettings::register_and_subscribe_to_events); @@ -84,6 +88,9 @@ pub fn initialize_settings_for_tests_with_mode( InputSettings::register(app); KeysSettings::register(app); LigatureSettings::register(app); + if warp_core::features::FeatureFlag::WarpControlCli.is_enabled() { + LocalControlSettings::register(app); + } #[cfg(any(target_os = "linux", target_os = "freebsd"))] { @@ -114,9 +121,6 @@ pub fn initialize_settings_for_tests_with_mode( SemanticSelection::register(app); app.update(|ctx| { - // Register a no-op secure storage provider for testing. - warpui_extras::secure_storage::register_noop("test", ctx); - // Add settings models that are backed by secure storage, not user preferences. ctx.add_singleton_model(ai::api_keys::ApiKeyManager::new); }); diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 59c9def458..93b66cf10b 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -15,7 +15,7 @@ pub(crate) mod right_panel; mod startup_directory; #[cfg(test)] #[path = "view_tests.rs"] -mod tests; +pub(crate) mod tests; mod vertical_tabs; #[cfg(target_family = "wasm")] mod wasm_view; diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 7459fc60fc..048fa5610d 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -88,7 +88,7 @@ use crate::{ experiments, workspace, AgentNotificationsModel, GlobalResourceHandlesProvider, ObjectActions, }; -fn initialize_app(app: &mut App) { +pub(crate) fn initialize_app(app: &mut App) { initialize_settings_for_tests(app); // Add the necessary singleton models to the App @@ -238,7 +238,7 @@ fn initialize_app(app: &mut App) { app.update(workspace::init); } -fn mock_workspace(app: &mut App) -> ViewHandle { +pub(crate) fn mock_workspace(app: &mut App) -> ViewHandle { let global_resource_handles = GlobalResourceHandles::mock(app); let active_window_id = app.read(|ctx| ctx.windows().active_window()); let (_, workspace) = app.add_window(WindowStyle::NotStealFocus, |ctx| { diff --git a/crates/local_control/Cargo.toml b/crates/local_control/Cargo.toml new file mode 100644 index 0000000000..d37fb8c16a --- /dev/null +++ b/crates/local_control/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "local_control" +edition = "2024" +description = "Shared protocol and discovery primitives for Warp local control" +authors.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +base64.workspace = true +chrono.workspace = true +rand.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +uuid.workspace = true +[target.'cfg(not(target_family = "wasm"))'.dependencies] +reqwest.workspace = true + +[target.'cfg(unix)'.dependencies] +libc.workspace = true +[target.'cfg(windows)'.dependencies] +command.workspace = true + +[dev-dependencies] +command.workspace = true +tempfile.workspace = true diff --git a/crates/local_control/src/auth.rs b/crates/local_control/src/auth.rs new file mode 100644 index 0000000000..25d13db3b9 --- /dev/null +++ b/crates/local_control/src/auth.rs @@ -0,0 +1,231 @@ +//! Credential request, issuance, and validation types for local control. +use base64::Engine as _; +use chrono::{DateTime, Duration, Utc}; +use rand::RngCore as _; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::discovery::InstanceId; +use crate::protocol::{ + ActionKind, ControlError, ErrorCode, ExecutionContextProof, InvocationContext, +}; + +/// Bearer token used to authorize a single scoped local-control credential. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthToken(String); + +impl AuthToken { + /// Generates a bearer secret from 32 bytes of operating-system CSPRNG output. + /// + /// Local-control bearer tokens are authentication material, so they use + /// `OsRng` instead of a deterministic or fast userspace PRNG. + pub fn generate() -> Self { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + Self(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) + } + + pub fn from_secret(secret: impl Into) -> Self { + Self(secret.into()) + } + + pub fn secret(&self) -> &str { + &self.0 + } + + pub fn authorization_value(&self) -> String { + format!("Bearer {}", self.0) + } + + pub fn from_authorization_header(value: Option<&str>) -> Result { + let Some(value) = value else { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Authorization header is required", + )); + }; + let Some(token) = value.strip_prefix("Bearer ") else { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Authorization header must use the Bearer scheme", + )); + }; + Ok(Self::from_secret(token)) + } + + pub fn verify_authorization_header(&self, value: Option<&str>) -> Result<(), ControlError> { + let token = Self::from_authorization_header(value)?; + if token != *self { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Authorization token is invalid", + )); + } + Ok(()) + } +} + +/// Request for a short-lived credential scoped to one action and invocation context. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialRequest { + pub protocol_version: u32, + pub request_id: Uuid, + pub action: ActionKind, + pub invocation_context: InvocationContext, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_context_proof: Option, +} + +impl CredentialRequest { + pub fn new(action: ActionKind, invocation_context: InvocationContext) -> Self { + Self { + protocol_version: crate::protocol::PROTOCOL_VERSION, + request_id: Uuid::new_v4(), + action, + invocation_context, + execution_context_proof: None, + } + } + + /// Verifies whether the caller may claim its requested invocation context. + /// + /// External callers do not receive elevated trust from this proof and are + /// allowed only when the selected Warp instance enables outside-Warp + /// control. Inside-Warp callers must eventually present an app-issued, + /// session-bound `VerifiedWarpTerminal` proof; until that broker path lands, + /// this foundation branch rejects inside-Warp credential requests rather + /// than trusting a caller-declared label or spoofable environment variable. + pub fn verify_execution_context_proof(&self) -> Result<(), ControlError> { + match (&self.invocation_context, &self.execution_context_proof) { + (InvocationContext::InsideWarp, _) => Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + "inside-Warp credentials require an app-issued verified Warp terminal proof", + )), + ( + InvocationContext::OutsideWarp, + None | Some(ExecutionContextProof::ExternalClient), + ) => Ok(()), + ( + InvocationContext::OutsideWarp, + Some(ExecutionContextProof::VerifiedWarpTerminal { .. }), + ) => Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + "external clients cannot use a Warp terminal execution proof", + )), + } + } +} + +/// Client-facing credential response containing a bearer secret and its grant metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScopedCredential { + pub bearer_token: String, + pub grant: CredentialGrant, +} + +impl ScopedCredential { + pub fn authorization_value(&self) -> String { + format!("Bearer {}", self.bearer_token) + } +} + +/// Authorization grant issued by the localhost server running inside Warp for a +/// single action. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialGrant { + pub credential_id: String, + pub instance_id: InstanceId, + pub action: ActionKind, + pub invocation_context: InvocationContext, + pub authenticated_user: AuthenticatedUserGrant, + pub issued_at: DateTime, + pub expires_at: DateTime, +} + +/// Authenticated user context attached to a credential grant when required. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthenticatedUserGrant { + pub required: bool, + pub subject: Option, +} + +impl CredentialGrant { + pub fn new( + instance_id: InstanceId, + action: ActionKind, + invocation_context: InvocationContext, + ttl: Duration, + ) -> Self { + let issued_at = Utc::now(); + let metadata = action.metadata(); + Self { + credential_id: format!("cred_{}", Uuid::new_v4().simple()), + instance_id, + action, + invocation_context, + authenticated_user: AuthenticatedUserGrant { + required: metadata.authenticated_user.required, + subject: None, + }, + issued_at, + expires_at: issued_at + ttl, + } + } + + pub fn is_expired(&self) -> bool { + Utc::now() >= self.expires_at + } + + pub fn verify_for_action( + &self, + instance_id: &InstanceId, + action: ActionKind, + ) -> Result<(), ControlError> { + if self.is_expired() { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control credential has expired", + )); + } + if &self.instance_id != instance_id { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control credential belongs to a different Warp instance", + )); + } + if self.action != action { + return Err(ControlError::new( + ErrorCode::InsufficientPermissions, + format!( + "credential for {} cannot invoke {}", + self.action.as_str(), + action.as_str() + ), + )); + } + let metadata = action.metadata(); + if metadata.requires_authenticated_user && self.authenticated_user.subject.is_none() { + return Err(ControlError::new( + ErrorCode::AuthenticatedUserRequired, + format!("{} requires an authenticated Warp user", action.as_str()), + )); + } + if !metadata + .allowed_invocation_contexts + .contains(&self.invocation_context) + { + return Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + format!( + "{} cannot run from the credential invocation context", + action.as_str() + ), + )); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "auth_tests.rs"] +mod tests; diff --git a/crates/local_control/src/auth_tests.rs b/crates/local_control/src/auth_tests.rs new file mode 100644 index 0000000000..0a75703d2c --- /dev/null +++ b/crates/local_control/src/auth_tests.rs @@ -0,0 +1,143 @@ +use chrono::Duration; + +use super::*; +use crate::discovery::InstanceId; + +#[test] +fn rejects_missing_authorization_header() { + let token = AuthToken::from_secret("secret"); + let err = token + .verify_authorization_header(None) + .expect_err("rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} +#[test] +fn rejects_malformed_authorization_header() { + let token = AuthToken::from_secret("secret"); + let err = token + .verify_authorization_header(Some("Basic secret")) + .expect_err("rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + +#[test] +fn rejects_wrong_bearer_token() { + let token = AuthToken::from_secret("secret"); + let err = token + .verify_authorization_header(Some("Bearer wrong")) + .expect_err("rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + +#[test] +fn accepts_matching_bearer_token() { + let token = AuthToken::from_secret("secret"); + token + .verify_authorization_header(Some("Bearer secret")) + .expect("accepted"); +} + +#[test] +fn scoped_credential_allows_only_granted_action() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ); + grant + .verify_for_action(&grant.instance_id, ActionKind::TabCreate) + .expect("tab.create grant is accepted"); + let err = grant + .verify_for_action(&grant.instance_id, ActionKind::WindowCreate) + .expect_err("other actions are rejected"); + assert_eq!(err.code, ErrorCode::InsufficientPermissions); +} + +#[test] +fn scoped_credential_rejects_different_instance() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ); + let err = grant + .verify_for_action(&InstanceId("inst_other".to_owned()), ActionKind::TabCreate) + .expect_err("other instance is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} +#[test] +fn scoped_credential_rejects_expired_grant() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(-1), + ); + + let err = grant + .verify_for_action(&grant.instance_id, ActionKind::TabCreate) + .expect_err("expired grant is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + +#[test] +fn scoped_credential_carries_authenticated_user_metadata() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ); + assert!(!grant.authenticated_user.required); + assert!(grant.authenticated_user.subject.is_none()); +} + +#[test] +fn authenticated_user_actions_require_subject() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::DriveInspect, + InvocationContext::InsideWarp, + Duration::minutes(5), + ); + assert!(grant.authenticated_user.required); + let err = grant + .verify_for_action(&grant.instance_id, ActionKind::DriveInspect) + .expect_err("authenticated-user actions require a subject"); + assert_eq!(err.code, ErrorCode::AuthenticatedUserRequired); +} + +#[test] +fn credential_request_rejects_unverified_inside_warp_context() { + let request = CredentialRequest::new(ActionKind::TabCreate, InvocationContext::InsideWarp); + let err = request + .verify_execution_context_proof() + .expect_err("missing proof is rejected"); + assert_eq!(err.code, ErrorCode::ExecutionContextNotAllowed); +} + +#[test] +fn credential_request_rejects_placeholder_inside_warp_terminal_proof() { + let mut request = CredentialRequest::new(ActionKind::TabCreate, InvocationContext::InsideWarp); + request.execution_context_proof = Some(ExecutionContextProof::VerifiedWarpTerminal { + proof_id: "proof".to_owned(), + }); + let err = request + .verify_execution_context_proof() + .expect_err("placeholder proof is rejected until broker support exists"); + assert_eq!(err.code, ErrorCode::ExecutionContextNotAllowed); +} + +#[test] +fn credential_request_rejects_terminal_proof_for_external_client() { + let mut request = CredentialRequest::new(ActionKind::TabCreate, InvocationContext::OutsideWarp); + request.execution_context_proof = Some(ExecutionContextProof::VerifiedWarpTerminal { + proof_id: "proof".to_owned(), + }); + let err = request + .verify_execution_context_proof() + .expect_err("terminal proof is rejected for external context"); + assert_eq!(err.code, ErrorCode::ExecutionContextNotAllowed); +} diff --git a/crates/local_control/src/catalog.rs b/crates/local_control/src/catalog.rs new file mode 100644 index 0000000000..ff6b8d1e9d --- /dev/null +++ b/crates/local_control/src/catalog.rs @@ -0,0 +1,415 @@ +//! Action catalog and metadata used for discovery, permissions, and CLI support. +use serde::{Deserialize, Serialize}; + +pub const PROTOCOL_VERSION: u32 = 1; + +/// Runtime context from which a control request was initiated. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationContext { + InsideWarp, + OutsideWarp, +} + +/// Future proof shape for distinguishing verified Warp terminals from external clients. +/// +/// `VerifiedWarpTerminal` is currently a protocol placeholder only. The +/// foundation implementation rejects inside-Warp credential requests until the +/// app-issued terminal-session proof broker is implemented. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ExecutionContextProof { + VerifiedWarpTerminal { proof_id: String }, + ExternalClient, +} + +/// Whether an action requires an authenticated Warp user context. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthenticatedUserRequirement { + pub required: bool, +} + +/// Level of Warp hierarchy or orthogonal product noun an action targets. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetScope { + Instance, + Window, + Tab, + Pane, + Session, + Block, + Input, + History, + Settings, + Appearance, + Surface, + File, + DriveObject, + Auth, + Keybinding, + Action, + Capability, +} + +/// Whether an action has an app-side implementation in this stack layer. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionImplementationStatus { + Implemented, + Stub, +} + +/// Typed parameter contract for a catalog action. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionParameterSpec { + None, + ActionName, + BindingName, + BooleanValue, + ColorValue, + Direction, + DriveObjectCreate, + DriveObjectId, + DriveObjectInsert, + DriveObjectList, + DriveObjectUpdate, + FileOpen, + InputMode, + Key, + KeyValue, + Limit, + Namespace, + PageQuery, + Query, + Rename, + Resize, + TabActivate, + TabClose, + TabCreate, + Text, + ThemeName, + WorkflowRun, +} + +/// Typed result contract for a catalog action. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionResultSpec { + Acknowledgement, + ActiveTarget, + AppearanceState, + AuthStatus, + CapabilityList, + CapabilityMetadata, + Content, + DriveObjectList, + DriveObjectMetadata, + FileList, + InstanceList, + InstanceMetadata, + KeybindingList, + KeybindingMetadata, + SettingList, + SettingValue, + TargetList, + TargetMetadata, + ThemeList, + ThemeState, +} + +/// Discoverable metadata describing one local-control action. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActionMetadata { + pub kind: ActionKind, + /// Stable public action identifier exposed through discovery, help, and wire + /// payloads, such as `tab.create`. + pub name: String, + pub implementation_status: ActionImplementationStatus, + pub requires_authenticated_user: bool, + pub authenticated_user: AuthenticatedUserRequirement, + pub allowed_invocation_contexts: Vec, + pub target_scope: TargetScope, + pub parameter_spec: ActionParameterSpec, + pub result_spec: ActionResultSpec, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum InvocationContextSpec { + InsideWarpOnly, + OutsideWarpOnly, + Any, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct ActionSpec { + name: &'static str, + implementation_status: ActionImplementationStatus, + requires_authenticated_user: bool, + invocation_contexts: InvocationContextSpec, + target_scope: TargetScope, + parameter_spec: ActionParameterSpec, + result_spec: ActionResultSpec, +} + +macro_rules! define_action_catalog { + ($( + $group:ident { + $( + $variant:ident => { + name: $name:literal, + status: $status:ident, + authenticated_user: $authenticated_user:literal, + contexts: $contexts:ident, + target: $target:ident, + params: $params:ident, + result: $result:ident $(,)? + } + ),+ $(,)? + } + )+ $(,)?) => { + /// Stable protocol name for every approved `warpctrl` action. + /// + /// These names are user-visible as CLI/API action identifiers, so they + /// should be treated as stable public contract strings. + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum ActionKind { + $( + $( + #[serde(rename = $name)] + $variant, + )+ + )+ + } + + impl ActionKind { + pub const ALL: &[Self] = &[ + $( + $(Self::$variant,)+ + )+ + ]; + + pub fn as_str(self) -> &'static str { + self.spec().name + } + + pub fn metadata(self) -> ActionMetadata { + let spec = self.spec(); + ActionMetadata { + kind: self, + name: spec.name.to_owned(), + implementation_status: spec.implementation_status, + requires_authenticated_user: spec.requires_authenticated_user, + authenticated_user: AuthenticatedUserRequirement { + required: spec.requires_authenticated_user, + }, + allowed_invocation_contexts: self.allowed_invocation_contexts(), + target_scope: spec.target_scope, + parameter_spec: spec.parameter_spec, + result_spec: spec.result_spec, + } + } + + pub fn implemented_metadata() -> Vec { + Self::ALL + .iter() + .copied() + .map(Self::metadata) + .filter(|metadata| { + metadata.implementation_status == ActionImplementationStatus::Implemented + }) + .collect() + } + + pub fn is_implemented(self) -> bool { + self.spec().implementation_status == ActionImplementationStatus::Implemented + } + + fn spec(self) -> ActionSpec { + match self { + $( + $(Self::$variant => ActionSpec { + name: $name, + implementation_status: ActionImplementationStatus::$status, + requires_authenticated_user: $authenticated_user, + invocation_contexts: InvocationContextSpec::$contexts, + target_scope: TargetScope::$target, + parameter_spec: ActionParameterSpec::$params, + result_spec: ActionResultSpec::$result, + },)+ + )+ + } + } + + fn allowed_invocation_contexts(self) -> Vec { + match self.spec().invocation_contexts { + InvocationContextSpec::InsideWarpOnly => vec![InvocationContext::InsideWarp], + InvocationContextSpec::OutsideWarpOnly => vec![InvocationContext::OutsideWarp], + InvocationContextSpec::Any => vec![ + InvocationContext::InsideWarp, + InvocationContext::OutsideWarp, + ], + } + } + + } + }; +} + +define_action_catalog! { + instance { + InstanceList => { name: "instance.list", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, target: Instance, params: None, result: InstanceList }, + InstanceInspect => { name: "instance.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Instance, params: None, result: InstanceMetadata }, + } + + app { + AppPing => { name: "app.ping", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, target: Instance, params: None, result: InstanceMetadata }, + AppVersion => { name: "app.version", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, target: Instance, params: None, result: InstanceMetadata }, + AppActive => { name: "app.active", status: Stub, authenticated_user: false, contexts: Any, target: Instance, params: None, result: ActiveTarget }, + AppFocus => { name: "app.focus", status: Stub, authenticated_user: false, contexts: Any, target: Instance, params: None, result: Acknowledgement }, + } + + auth { + AuthStatus => { name: "auth.status", status: Stub, authenticated_user: false, contexts: Any, target: Auth, params: None, result: AuthStatus }, + AuthLogin => { name: "auth.login", status: Stub, authenticated_user: false, contexts: Any, target: Auth, params: None, result: Acknowledgement }, + } + + capability { + CapabilityList => { name: "capability.list", status: Stub, authenticated_user: false, contexts: Any, target: Capability, params: None, result: CapabilityList }, + CapabilityInspect => { name: "capability.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Capability, params: ActionName, result: CapabilityMetadata }, + } + + window { + WindowList => { name: "window.list", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: None, result: TargetList }, + WindowInspect => { name: "window.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: None, result: TargetMetadata }, + WindowCreate => { name: "window.create", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: TabCreate, result: Acknowledgement }, + WindowFocus => { name: "window.focus", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: None, result: Acknowledgement }, + WindowClose => { name: "window.close", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: None, result: Acknowledgement }, + } + + tab { + TabList => { name: "tab.list", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: None, result: TargetList }, + TabInspect => { name: "tab.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: None, result: TargetMetadata }, + TabCreate => { name: "tab.create", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, target: Tab, params: None, result: Acknowledgement }, + TabActivate => { name: "tab.activate", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: TabActivate, result: Acknowledgement }, + TabMove => { name: "tab.move", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: Direction, result: Acknowledgement }, + TabClose => { name: "tab.close", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: TabClose, result: Acknowledgement }, + TabRename => { name: "tab.rename", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: Rename, result: Acknowledgement }, + TabResetName => { name: "tab.reset_name", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: None, result: Acknowledgement }, + TabColorSet => { name: "tab.color.set", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: ColorValue, result: Acknowledgement }, + TabColorClear => { name: "tab.color.clear", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: None, result: Acknowledgement }, + } + + pane { + PaneList => { name: "pane.list", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: TargetList }, + PaneInspect => { name: "pane.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: TargetMetadata }, + PaneSplit => { name: "pane.split", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: Direction, result: Acknowledgement }, + PaneFocus => { name: "pane.focus", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, + PaneNavigate => { name: "pane.navigate", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: Direction, result: Acknowledgement }, + PaneResize => { name: "pane.resize", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: Resize, result: Acknowledgement }, + PaneMaximize => { name: "pane.maximize", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, + PaneUnmaximize => { name: "pane.unmaximize", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, + PaneClose => { name: "pane.close", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, + PaneRename => { name: "pane.rename", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: Rename, result: Acknowledgement }, + PaneResetName => { name: "pane.reset_name", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, + } + + session { + SessionList => { name: "session.list", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: TargetList }, + SessionInspect => { name: "session.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: TargetMetadata }, + SessionActivate => { name: "session.activate", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: Acknowledgement }, + SessionPrevious => { name: "session.previous", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: Acknowledgement }, + SessionNext => { name: "session.next", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: Acknowledgement }, + SessionReopenClosed => { name: "session.reopen_closed", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: Acknowledgement }, + } + + block { + BlockList => { name: "block.list", status: Stub, authenticated_user: false, contexts: Any, target: Block, params: Limit, result: TargetList }, + BlockInspect => { name: "block.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Block, params: None, result: Content }, + BlockOutput => { name: "block.output", status: Stub, authenticated_user: false, contexts: Any, target: Block, params: None, result: Content }, + } + + input { + InputGet => { name: "input.get", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: None, result: Content }, + InputInsert => { name: "input.insert", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: Text, result: Acknowledgement }, + InputReplace => { name: "input.replace", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: Text, result: Acknowledgement }, + InputClear => { name: "input.clear", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: None, result: Acknowledgement }, + InputModeSet => { name: "input.mode.set", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: InputMode, result: Acknowledgement }, + InputRun => { name: "input.run", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: Input, params: Text, result: Acknowledgement }, + } + + history { + HistoryList => { name: "history.list", status: Stub, authenticated_user: false, contexts: Any, target: History, params: Limit, result: Content }, + } + + theme { + ThemeList => { name: "theme.list", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: ThemeList }, + ThemeGet => { name: "theme.get", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: ThemeState }, + ThemeSet => { name: "theme.set", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: ThemeName, result: Acknowledgement }, + ThemeSystemSet => { name: "theme.system.set", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: BooleanValue, result: Acknowledgement }, + ThemeLightSet => { name: "theme.light.set", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: ThemeName, result: Acknowledgement }, + ThemeDarkSet => { name: "theme.dark.set", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: ThemeName, result: Acknowledgement }, + } + + appearance { + AppearanceGet => { name: "appearance.get", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: AppearanceState }, + AppearanceFontSizeIncrease => { name: "appearance.font_size.increase", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceFontSizeDecrease => { name: "appearance.font_size.decrease", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceFontSizeReset => { name: "appearance.font_size.reset", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceZoomIncrease => { name: "appearance.zoom.increase", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceZoomDecrease => { name: "appearance.zoom.decrease", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceZoomReset => { name: "appearance.zoom.reset", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + } + + setting { + SettingList => { name: "setting.list", status: Stub, authenticated_user: false, contexts: Any, target: Settings, params: Namespace, result: SettingList }, + SettingGet => { name: "setting.get", status: Stub, authenticated_user: false, contexts: Any, target: Settings, params: Key, result: SettingValue }, + SettingSet => { name: "setting.set", status: Stub, authenticated_user: false, contexts: Any, target: Settings, params: KeyValue, result: Acknowledgement }, + SettingToggle => { name: "setting.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Settings, params: Key, result: Acknowledgement }, + } + + keybinding { + KeybindingList => { name: "keybinding.list", status: Stub, authenticated_user: false, contexts: Any, target: Keybinding, params: None, result: KeybindingList }, + KeybindingGet => { name: "keybinding.get", status: Stub, authenticated_user: false, contexts: Any, target: Keybinding, params: BindingName, result: KeybindingMetadata }, + } + + action { + ActionList => { name: "action.list", status: Stub, authenticated_user: false, contexts: Any, target: Action, params: None, result: CapabilityList }, + ActionInspect => { name: "action.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Action, params: ActionName, result: CapabilityMetadata }, + } + + surface { + SurfaceSettingsOpen => { name: "surface.settings.open", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: PageQuery, result: Acknowledgement }, + SurfaceCommandPaletteOpen => { name: "surface.command_palette.open", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: Query, result: Acknowledgement }, + SurfaceCommandSearchOpen => { name: "surface.command_search.open", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: Query, result: Acknowledgement }, + SurfaceWarpDriveOpen => { name: "surface.warp_drive.open", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceWarpDriveToggle => { name: "surface.warp_drive.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceResourceCenterToggle => { name: "surface.resource_center.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceAiAssistantToggle => { name: "surface.ai_assistant.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceCodeReviewToggle => { name: "surface.code_review.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceLeftPanelToggle => { name: "surface.left_panel.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceRightPanelToggle => { name: "surface.right_panel.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceVerticalTabsToggle => { name: "surface.vertical_tabs.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + } + + file { + FileList => { name: "file.list", status: Stub, authenticated_user: false, contexts: Any, target: File, params: None, result: FileList }, + FileOpen => { name: "file.open", status: Stub, authenticated_user: false, contexts: Any, target: File, params: FileOpen, result: Acknowledgement }, + } + + drive { + DriveList => { name: "drive.list", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectList, result: DriveObjectList }, + DriveInspect => { name: "drive.inspect", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: DriveObjectMetadata }, + DriveOpen => { name: "drive.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveNotebookOpen => { name: "drive.notebook.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveEnvVarCollectionOpen => { name: "drive.env_var_collection.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveObjectShareOpen => { name: "drive.object.share.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveObjectCreate => { name: "drive.object.create", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectCreate, result: Acknowledgement }, + DriveObjectUpdate => { name: "drive.object.update", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectUpdate, result: Acknowledgement }, + DriveObjectDelete => { name: "drive.object.delete", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveObjectInsert => { name: "drive.object.insert", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectInsert, result: Acknowledgement }, + DriveObjectShareToTeam => { name: "drive.object.share_to_team", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveWorkflowRun => { name: "drive.workflow.run", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: WorkflowRun, result: Acknowledgement }, + } +} diff --git a/crates/local_control/src/client.rs b/crates/local_control/src/client.rs new file mode 100644 index 0000000000..fc591d0738 --- /dev/null +++ b/crates/local_control/src/client.rs @@ -0,0 +1,237 @@ +//! Blocking client helpers used by the standalone `warpctrl` CLI. +//! +//! Authentication is a two-transport flow: +//! +//! 1. Discovery supplies instance metadata, an exact `127.0.0.1` control +//! endpoint, and an instance-bound credential-broker socket reference. It +//! never supplies a bearer credential. +//! 2. Before using either reference, the client validates that the endpoint is +//! loopback and that the broker filename is derived from the selected +//! instance ID. +//! 3. The client requests a credential for one action and invocation context +//! over the owner-only broker socket. On Unix, the server authenticates the +//! connecting process through kernel-reported peer credentials before +//! issuing a short-lived, action-scoped credential. +//! 4. The client keeps that credential in memory and presents it as a bearer +//! token only to the selected instance's loopback HTTP endpoint. The running +//! Warp app revalidates the credential, current settings, action scope, and +//! request before dispatch. +//! +//! Client-side validation prevents accidental use of inconsistent discovery +//! authority, but it is not the authorization boundary. The broker and running +//! app enforce authorization, and credentials must never be written to +//! discovery records, logs, or command output. +#[cfg(unix)] +use std::io::{Read as _, Write as _}; +#[cfg(unix)] +use std::net::Shutdown; +#[cfg(unix)] +use std::os::unix::net::UnixStream; +#[cfg(unix)] +use std::path::Path; + +use crate::auth::{CredentialRequest, ScopedCredential}; +use crate::discovery::InstanceRecord; +use crate::protocol::{ + Action, ActionKind, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, + InvocationContext, RequestEnvelope, ResponseEnvelope, +}; + +/// Requests an action-scoped credential and sends one authenticated control request. +#[cfg(not(target_family = "wasm"))] +pub fn send_request( + instance: &InstanceRecord, + request: &RequestEnvelope, +) -> Result { + instance.validate_local_control_authority()?; + let credential = request_credential( + instance, + request.action.kind, + InvocationContext::OutsideWarp, + )?; + let endpoint = instance.endpoint.as_ref().ok_or_else(|| { + ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control endpoint is disabled for this instance", + ) + })?; + let client = reqwest::blocking::Client::new(); + let response = client + .post(endpoint.url()) + .header("Authorization", credential.authorization_value()) + .json(request) + .send() + .map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to send local-control request", + err.to_string(), + ) + })?; + let status = response.status(); + let text = response.text().map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to read local-control response", + err.to_string(), + ) + })?; + if let Ok(envelope) = serde_json::from_str::(&text) { + if let ControlResponse::Error { error } = &envelope.response { + return Err(error.clone()); + } + return Ok(envelope); + } + if let Ok(envelope) = serde_json::from_str::(&text) { + return Err(envelope.error); + } + Err(ControlError::with_details( + ErrorCode::TransportUnavailable, + format!("local-control request failed with HTTP {status}"), + text, + )) +} + +/// Fails closed on platforms without a native local-control HTTP transport. +#[cfg(target_family = "wasm")] +pub fn send_request( + instance: &InstanceRecord, + request: &RequestEnvelope, +) -> Result { + request_credential( + instance, + request.action.kind, + InvocationContext::OutsideWarp, + )?; + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control requires a native HTTP transport", + )) +} +#[cfg(unix)] +/// Resolves the selected instance's validated broker path and requests a credential. +fn request_credential_over_owner_ipc( + instance: &InstanceRecord, + request: &CredentialRequest, +) -> Result { + let path = instance.broker_socket_path()?; + request_credential_over_socket(&path, request) +} + +#[cfg(unix)] +/// Exchanges one credential request and response over an owner-authenticated socket. +/// +/// Shutting down the write half delimits the JSON request so the broker can +/// read it to EOF before returning either a scoped credential or a structured +/// error response. +fn request_credential_over_socket( + path: &Path, + request: &CredentialRequest, +) -> Result { + let mut stream = UnixStream::connect(path).map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to connect to the owner-authenticated local-control credential broker", + err.to_string(), + ) + })?; + let request = serde_json::to_vec(request).map_err(|err| { + ControlError::with_details( + ErrorCode::InvalidRequest, + "failed to serialize local-control credential request", + err.to_string(), + ) + })?; + stream.write_all(&request).map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to write local-control credential request", + err.to_string(), + ) + })?; + stream.shutdown(Shutdown::Write).map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to finish local-control credential request", + err.to_string(), + ) + })?; + let mut response = String::new(); + stream.read_to_string(&mut response).map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to read local-control credential response", + err.to_string(), + ) + })?; + Ok(response) +} + +#[cfg(not(unix))] +/// Fails closed on platforms without an owner-authenticated broker transport. +fn request_credential_over_owner_ipc( + _instance: &InstanceRecord, + _request: &CredentialRequest, +) -> Result { + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control requires an owner-authenticated credential broker", + )) +} + +/// Requests and decodes a short-lived credential for one action and invocation context. +pub fn request_credential( + instance: &InstanceRecord, + action: crate::protocol::ActionKind, + invocation_context: InvocationContext, +) -> Result { + instance.validate_local_control_authority()?; + let request = CredentialRequest::new(action, invocation_context); + let text = request_credential_over_owner_ipc(instance, &request)?; + if let Ok(credential) = serde_json::from_str::(&text) { + return Ok(credential); + } + if let Ok(envelope) = serde_json::from_str::(&text) { + return Err(envelope.error); + } + Err(ControlError::with_details( + ErrorCode::TransportUnavailable, + "local-control credential broker returned an invalid response", + text, + )) +} + +/// Authenticates an app-ping request and verifies the selected instance is live. +pub fn probe_instance(instance: &InstanceRecord) -> Result<(), ControlError> { + let response = send_request( + instance, + &RequestEnvelope::new(Action::new(ActionKind::AppPing)), + )?; + validate_probe_response(instance, response) +} + +/// Rejects a health response that does not prove the selected instance identity. +fn validate_probe_response( + instance: &InstanceRecord, + response: ResponseEnvelope, +) -> Result<(), ControlError> { + let ControlResponse::Ok { data } = response.response else { + return Err(ControlError::new( + ErrorCode::TransportUnavailable, + "local-control health probe returned an error response", + )); + }; + if data.get("instance_id").and_then(serde_json::Value::as_str) + != Some(instance.instance_id.0.as_str()) + { + return Err(ControlError::new( + ErrorCode::TransportUnavailable, + "local-control health probe returned a different instance identity", + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/crates/local_control/src/client_tests.rs b/crates/local_control/src/client_tests.rs new file mode 100644 index 0000000000..538056cd01 --- /dev/null +++ b/crates/local_control/src/client_tests.rs @@ -0,0 +1,79 @@ +#[cfg(unix)] +use std::io::{Read as _, Write as _}; + +use chrono::Utc; +use uuid::Uuid; + +use super::*; +#[cfg(unix)] +use crate::auth::CredentialGrant; +use crate::discovery::{ControlEndpoint, CredentialBrokerReference, InstanceId}; +#[cfg(unix)] +#[test] +fn credential_client_exchanges_request_over_broker_socket() { + let dir = tempfile::tempdir().expect("temp dir"); + let socket_path = dir.path().join("broker.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket_path).expect("broker binds"); + let grant = CredentialGrant::new( + InstanceId("inst_expected".to_owned()), + ActionKind::AppPing, + InvocationContext::OutsideWarp, + chrono::Duration::minutes(5), + ); + let credential = ScopedCredential { + bearer_token: "scoped-token".to_owned(), + grant, + }; + let expected_request = + CredentialRequest::new(ActionKind::AppPing, InvocationContext::OutsideWarp); + let server_request = expected_request.clone(); + let server_credential = credential.clone(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("broker accepts"); + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .expect("broker reads request"); + let request = serde_json::from_slice::(&bytes).expect("request decodes"); + assert_eq!(request, server_request); + serde_json::to_writer(&mut stream, &server_credential).expect("broker writes credential"); + stream.flush().expect("broker flushes credential"); + }); + + let response = request_credential_over_socket(&socket_path, &expected_request) + .expect("credential exchange succeeds"); + server.join().expect("broker server completes"); + assert_eq!( + serde_json::from_str::(&response).expect("response decodes"), + credential + ); +} + +#[test] +fn probe_rejects_mismatched_instance_identity() { + let instance = InstanceRecord { + protocol_version: crate::PROTOCOL_VERSION, + instance_id: InstanceId("inst_expected".to_owned()), + pid: std::process::id(), + channel: "local".to_owned(), + app_id: "dev.warp.WarpLocal".to_owned(), + app_version: None, + started_at: Utc::now(), + executable_path: None, + endpoint: Some(ControlEndpoint::localhost(4000)), + credential_broker: Some(CredentialBrokerReference { + socket_path: "inst_expected.broker.sock".into(), + }), + outside_warp_control_enabled: true, + actions: vec![ActionKind::AppPing.metadata()], + }; + let err = validate_probe_response( + &instance, + ResponseEnvelope::ok( + Uuid::new_v4(), + serde_json::json!({ "instance_id": "inst_other" }), + ), + ) + .expect_err("mismatched live identity is rejected"); + assert_eq!(err.code, ErrorCode::TransportUnavailable); +} diff --git a/crates/local_control/src/discovery.rs b/crates/local_control/src/discovery.rs new file mode 100644 index 0000000000..1c484832d7 --- /dev/null +++ b/crates/local_control/src/discovery.rs @@ -0,0 +1,402 @@ +//! Private filesystem registry for discovering running local Warp instances. +//! +//! This module answers “which compatible instances are available, and where +//! can a client begin authentication?” It does not listen for control requests +//! and does not grant control authority. `app/src/local_control/mod.rs` owns the +//! running app-side listeners and uses these types to publish their routing +//! metadata. +//! +//! An enabled instance publishes an owner-only JSON record containing +//! instance/build metadata, implemented actions, its exact loopback HTTP +//! endpoint, and the filename of its instance-bound credential-broker socket. +//! The client reads that record, connects to the Unix socket to request a +//! short-lived credential for one exact action, and then presents the credential +//! to the HTTP endpoint. Discovery records never contain bearer tokens or +//! reusable credentials. +//! +//! Before following a record, clients require the endpoint host to be exactly +//! `127.0.0.1` and the broker filename to be derived from the instance ID. A +//! discovery scan also rejects incompatible records, prunes dead PIDs, and +//! performs an authenticated `app.ping` probe. When outside-Warp control is +//! disabled, records contain neither an endpoint nor a broker reference. +//! +//! The owner-only directory, records, and broker sockets protect against other +//! OS users. The broker's kernel-reported peer-UID check is the authoritative +//! same-user check before credential issuance. Neither mechanism distinguishes +//! trusted Warp code from arbitrary software already running as that user. +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +#[cfg(windows)] +use command::blocking::Command; +use serde::{Deserialize, Serialize}; + +use crate::protocol::{ActionMetadata, ControlError, ErrorCode, PROTOCOL_VERSION}; + +const DISCOVERY_DIR_ENV: &str = "WARP_LOCAL_CONTROL_DISCOVERY_DIR"; +const BROKER_SOCKET_SUFFIX: &str = ".broker.sock"; + +/// Stable identifier for one running Warp instance. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct InstanceId(pub String); + +impl InstanceId { + pub fn new() -> Self { + Self(format!("inst_{}", uuid::Uuid::new_v4().simple())) + } +} + +impl Default for InstanceId { + fn default() -> Self { + Self::new() + } +} + +/// Exact loopback HTTP route used after a client obtains a broker-issued credential. +/// +/// Publishing this endpoint lets clients route requests; it does not authorize +/// them to invoke actions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlEndpoint { + pub host: String, + pub port: u16, +} + +impl ControlEndpoint { + pub fn localhost(port: u16) -> Self { + Self { + host: "127.0.0.1".to_owned(), + port, + } + } + + pub fn url(&self) -> String { + format!("http://{}:{}/v1/control", self.host, self.port) + } +} + +/// Discovery reference to the owner-authenticated socket that issues credentials. +/// +/// Enabled records publish the instance-derived filename, not an arbitrary +/// socket path or a credential. Clients validate the filename and resolve it +/// inside the owner-only discovery directory before connecting. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialBrokerReference { + pub socket_path: PathBuf, +} + +/// Filesystem-published routing metadata for a running Warp app process. +/// +/// An enabled record connects the three stages of the protocol: filesystem +/// discovery, Unix-socket credential issuance, and authenticated loopback HTTP +/// dispatch. The optional endpoint and broker reference are present together or +/// absent together, so a disabled record cannot accidentally publish a usable +/// partial control route. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstanceRecord { + pub protocol_version: u32, + pub instance_id: InstanceId, + pub pid: u32, + pub channel: String, + pub app_id: String, + pub app_version: Option, + pub started_at: DateTime, + pub executable_path: Option, + pub endpoint: Option, + pub credential_broker: Option, + pub outside_warp_control_enabled: bool, + pub actions: Vec, +} + +impl InstanceRecord { + pub fn for_current_process( + endpoint: Option, + channel: impl Into, + app_id: impl Into, + app_version: Option, + actions: Vec, + ) -> Self { + let instance_id = InstanceId::new(); + let credential_broker = endpoint.as_ref().map(|_| CredentialBrokerReference { + socket_path: broker_socket_filename(&instance_id), + }); + Self { + protocol_version: PROTOCOL_VERSION, + instance_id, + pid: std::process::id(), + channel: channel.into(), + app_id: app_id.into(), + app_version, + started_at: Utc::now(), + executable_path: std::env::current_exe().ok(), + outside_warp_control_enabled: endpoint.is_some(), + credential_broker, + endpoint, + actions, + } + } + + /// Rejects records that could redirect a client away from the selected instance. + /// + /// This validates routing metadata rather than granting authority: an + /// enabled record must name exactly loopback and the broker filename derived + /// from its instance ID. The broker and app bridge still authenticate and + /// authorize the eventual request. + pub fn validate_local_control_authority(&self) -> Result<(), ControlError> { + match ( + self.outside_warp_control_enabled, + &self.endpoint, + &self.credential_broker, + ) { + (false, None, None) => Ok(()), + (true, Some(endpoint), Some(credential_broker)) + if endpoint.host == "127.0.0.1" + && credential_broker.socket_path + == broker_socket_filename(&self.instance_id) => + { + Ok(()) + } + _ => Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control discovery record contains unsafe or inconsistent endpoint authority", + )), + } + } + + /// Resolves the validated broker filename inside the private discovery directory. + pub fn broker_socket_path(&self) -> Result { + self.validate_local_control_authority()?; + let credential_broker = self.credential_broker.as_ref().ok_or_else(|| { + ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control credential broker is disabled for this instance", + ) + })?; + Ok(discovery_dir().join(&credential_broker.socket_path)) + } +} + +/// RAII registration for one app-owned discovery record and broker socket. +/// +/// The registration publishes routing metadata for the lifetime of the running +/// server. Dropping it removes the record and socket on graceful shutdown; +/// discovery scans prune dead-PID records left behind by crashes. +pub struct RegisteredInstance { + record: InstanceRecord, + path: PathBuf, + broker_socket_path: Option, +} + +impl RegisteredInstance { + /// Publishes a record in the protected per-user registry. + pub fn register(record: InstanceRecord) -> Result { + let dir = discovery_dir(); + fs::create_dir_all(&dir).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to create local-control discovery directory", + err.to_string(), + ) + })?; + set_private_dir_permissions(&dir)?; + let path = record_path(&dir, &record.instance_id); + let broker_socket_path = record + .credential_broker + .as_ref() + .map(|credential_broker| dir.join(&credential_broker.socket_path)); + write_record(&path, &record)?; + Ok(Self { + record, + path, + broker_socket_path, + }) + } + + pub fn record(&self) -> &InstanceRecord { + &self.record + } + + pub fn update(&mut self, record: InstanceRecord) -> Result<(), ControlError> { + let path = record_path( + self.path.parent().unwrap_or_else(|| Path::new(".")), + &record.instance_id, + ); + write_record(&path, &record)?; + if path != self.path { + let _ = fs::remove_file(&self.path); + self.path = path; + } + self.record = record; + Ok(()) + } +} + +fn write_record(path: &Path, record: &InstanceRecord) -> Result<(), ControlError> { + let bytes = serde_json::to_vec_pretty(record).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control discovery record", + err.to_string(), + ) + })?; + fs::write(path, bytes).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to write local-control discovery record", + err.to_string(), + ) + })?; + set_private_permissions(path)?; + Ok(()) +} + +impl Drop for RegisteredInstance { + // Drop-time cleanup is the best-effort fast path for graceful shutdown. + // `list_instances_from_dir` is the robust cleanup path: it treats records + // whose PID is no longer alive as stale, removes them, and ignores malformed + // or unreadable records so a crash can leave at most a temporary zombie + // reference that is pruned on the next discovery scan. + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + if let Some(path) = &self.broker_socket_path { + let _ = fs::remove_file(path); + } + } +} + +/// Returns the private registry shared by app publishers and local clients. +pub fn discovery_dir() -> PathBuf { + if let Some(path) = std::env::var_os(DISCOVERY_DIR_ENV) { + return PathBuf::from(path); + } + if let Some(path) = std::env::var_os("XDG_RUNTIME_DIR") { + return PathBuf::from(path).join("warp").join("local-control"); + } + let home = std::env::var_os("HOME").unwrap_or_else(|| ".".into()); + PathBuf::from(home).join(".warp").join("local-control") +} + +/// Returns compatible live instances that pass an authenticated app ping. +/// +/// The ping follows the normal broker-to-HTTP flow and verifies the responding +/// app's instance ID, so a live PID and parseable record alone are insufficient. +pub fn list_instances() -> Vec { + list_instances_from_dir(&discovery_dir()) + .into_iter() + .filter(|record| crate::client::probe_instance(record).is_ok()) + .collect() +} + +/// Parses structurally valid candidate records and prunes records with dead PIDs. +/// +/// This lower-level scan does not contact the advertised endpoint; callers that +/// need invokable instances should use [`list_instances`] so candidates also +/// pass the authenticated probe. +pub fn list_instances_from_dir(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut records = Vec::new(); + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + let record = match serde_json::from_str::(&contents) { + Ok(r) => r, + Err(_) => continue, + }; + if record.protocol_version != PROTOCOL_VERSION { + continue; + } + if record.validate_local_control_authority().is_err() { + continue; + } + if !is_pid_alive(record.pid) { + let _ = fs::remove_file(&path); + continue; + } + records.push(record); + } + records.sort_by_key(|record| record.started_at); + records +} + +#[cfg(unix)] +fn is_pid_alive(pid: u32) -> bool { + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } +} + +#[cfg(windows)] +fn is_pid_alive(pid: u32) -> bool { + Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/NH"]) + .output() + .map(|o| !String::from_utf8_lossy(&o.stdout).contains("No tasks")) + .unwrap_or(true) +} +#[cfg(all(not(unix), not(windows)))] +fn is_pid_alive(_: u32) -> bool { + false +} + +fn record_path(dir: &Path, instance_id: &InstanceId) -> PathBuf { + dir.join(format!("{}.json", instance_id.0)) +} +fn broker_socket_filename(instance_id: &InstanceId) -> PathBuf { + PathBuf::from(format!("{}{BROKER_SOCKET_SUFFIX}", instance_id.0)) +} + +#[cfg(unix)] +fn set_private_dir_permissions(path: &Path) -> Result<(), ControlError> { + let mut permissions = fs::metadata(path) + .map_err(|err| permissions_error("read local-control discovery directory", err))? + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(path, permissions) + .map_err(|err| permissions_error("protect local-control discovery directory", err)) +} + +#[cfg(not(unix))] +fn set_private_dir_permissions(_path: &Path) -> Result<(), ControlError> { + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "local-control discovery publication is disabled until this platform enforces record ACLs", + )) +} + +#[cfg(unix)] +fn set_private_permissions(path: &Path) -> Result<(), ControlError> { + let mut permissions = fs::metadata(path) + .map_err(|err| permissions_error("read local-control discovery record", err))? + .permissions(); + permissions.set_mode(0o600); + fs::set_permissions(path, permissions) + .map_err(|err| permissions_error("protect local-control discovery record", err)) +} + +#[cfg(not(unix))] +fn set_private_permissions(_path: &Path) -> Result<(), ControlError> { + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "local-control discovery publication is disabled until this platform enforces record ACLs", + )) +} + +#[cfg(unix)] +fn permissions_error(operation: &str, error: std::io::Error) -> ControlError { + ControlError::with_details( + ErrorCode::Internal, + format!("failed to {operation}"), + error.to_string(), + ) +} + +#[cfg(test)] +#[path = "discovery_tests.rs"] +mod tests; diff --git a/crates/local_control/src/discovery_tests.rs b/crates/local_control/src/discovery_tests.rs new file mode 100644 index 0000000000..fa99d0db2f --- /dev/null +++ b/crates/local_control/src/discovery_tests.rs @@ -0,0 +1,274 @@ +use std::fs; +use std::path::Path; + +#[cfg(unix)] +use command::blocking::Command; + +use super::*; + +#[test] +fn control_endpoint_composes_loopback_control_route() { + assert_eq!( + ControlEndpoint::localhost(4000).url(), + "http://127.0.0.1:4000/v1/control" + ); +} +#[test] +fn broker_socket_reference_is_bound_to_instance_identity() { + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + + assert_eq!( + record + .credential_broker + .expect("credential broker") + .socket_path, + PathBuf::from(format!("{}.broker.sock", record.instance_id.0)) + ); +} + +#[test] +fn registered_instance_round_trips_discovery_record() { + let dir = tempfile::tempdir().expect("temp dir"); + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let _registered = RegisteredInstance::register_in_dir_for_test(record.clone(), dir.path()) + .expect("registered"); + let records = list_instances_from_dir(dir.path()); + assert_eq!(records, vec![record]); +} + +#[test] +fn incompatible_protocol_record_is_ignored() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + record.protocol_version = PROTOCOL_VERSION + 1; + let _registered = + RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); + + assert!(list_instances_from_dir(dir.path()).is_empty()); +} +#[cfg(unix)] +#[test] +fn stale_process_record_is_pruned() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut child = Command::new("true") + .spawn() + .expect("short-lived process starts"); + let pid = child.id(); + child.wait().expect("short-lived process exits"); + let mut record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + record.pid = pid; + let registered = + RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); + + assert!(list_instances_from_dir(dir.path()).is_empty()); + assert!(!registered.path.exists()); +} +#[cfg(unix)] +#[test] +fn multiple_live_process_records_are_discovered() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut first_process = Command::new("sleep") + .arg("10") + .spawn() + .expect("first process starts"); + let mut second_process = Command::new("sleep") + .arg("10") + .spawn() + .expect("second process starts"); + let mut first_record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + first_record.pid = first_process.id(); + let mut second_record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4001)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + second_record.pid = second_process.id(); + let first_id = first_record.instance_id.clone(); + let second_id = second_record.instance_id.clone(); + let _first = RegisteredInstance::register_in_dir_for_test(first_record, dir.path()) + .expect("first registered"); + let _second = RegisteredInstance::register_in_dir_for_test(second_record, dir.path()) + .expect("second registered"); + + let ids = list_instances_from_dir(dir.path()) + .into_iter() + .map(|record| record.instance_id) + .collect::>(); + assert_eq!(ids.len(), 2); + assert!(ids.contains(&first_id)); + assert!(ids.contains(&second_id)); + + first_process.kill().expect("first process stops"); + first_process.wait().expect("first process reaped"); + second_process.kill().expect("second process stops"); + second_process.wait().expect("second process reaped"); +} + +#[test] +fn serialized_discovery_record_does_not_contain_raw_credential_material() { + let raw_secret = "raw-secret-token-material"; + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let serialized = serde_json::to_string_pretty(&record).expect("serialize"); + assert!(!serialized.contains(raw_secret)); + assert!(!serialized.contains("auth_token")); + assert!(!serialized.contains("bearer_token")); +} + +#[test] +fn disabled_outside_warp_record_does_not_expose_actionable_authority() { + let record = InstanceRecord::for_current_process( + None, + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + assert!(!record.outside_warp_control_enabled); + assert!(record.endpoint.is_none()); + assert!(record.credential_broker.is_none()); +} + +#[test] +fn rejects_unsafe_or_divergent_discovery_authority() { + let mut record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + record + .validate_local_control_authority() + .expect("matching 127.0.0.1 endpoints are accepted"); + + record.endpoint.as_mut().expect("endpoint").host = "localhost".to_owned(); + let err = record + .validate_local_control_authority() + .expect_err("localhost alias is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); + + record.endpoint = Some(ControlEndpoint::localhost(4000)); + record + .credential_broker + .as_mut() + .expect("credential broker") + .socket_path = "different.broker.sock".into(); + let err = record + .validate_local_control_authority() + .expect_err("divergent broker socket is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + +#[cfg(unix)] +#[test] +fn discovery_directory_is_owner_only_on_unix() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let _registered = + RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); + let mode = fs::metadata(dir.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); +} + +#[cfg(unix)] +#[test] +fn discovery_record_is_owner_only_on_unix() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let registered = + RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); + let mode = fs::metadata(®istered.path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); +} + +impl RegisteredInstance { + fn register_in_dir_for_test(record: InstanceRecord, dir: &Path) -> Result { + fs::create_dir_all(dir).expect("create dir"); + #[cfg(unix)] + set_private_dir_permissions(dir)?; + let path = record_path(dir, &record.instance_id); + let bytes = serde_json::to_vec_pretty(&record).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control discovery test record", + err.to_string(), + ) + })?; + fs::write(&path, bytes).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to write local-control discovery test record", + err.to_string(), + ) + })?; + #[cfg(unix)] + set_private_permissions(&path)?; + Ok(Self { + record, + path, + broker_socket_path: None, + }) + } +} diff --git a/crates/local_control/src/lib.rs b/crates/local_control/src/lib.rs new file mode 100644 index 0000000000..6ba4205e6b --- /dev/null +++ b/crates/local_control/src/lib.rs @@ -0,0 +1,29 @@ +//! Shared protocol, discovery, authentication, and client types for local Warp control. +//! +//! The `local_control` crate is intentionally UI-agnostic so the Warp app and +//! `warpctrl` CLI can share the same wire envelopes, action catalog, discovery +//! records, selectors, and credential validation rules. +pub mod auth; +pub mod catalog; +pub mod client; +pub mod discovery; +pub mod protocol; +pub mod selection; +pub mod selectors; + +pub use auth::{ + AuthToken, AuthenticatedUserGrant, CredentialGrant, CredentialRequest, ScopedCredential, +}; +pub use catalog::{ + ActionImplementationStatus, ActionKind, ActionMetadata, AuthenticatedUserRequirement, + InvocationContext, TargetScope, +}; +pub use discovery::{ + ControlEndpoint, CredentialBrokerReference, InstanceId, InstanceRecord, RegisteredInstance, + discovery_dir, +}; +pub use protocol::{ + Action, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, ExecutionContextProof, + PROTOCOL_VERSION, RequestEnvelope, ResponseEnvelope, +}; +pub use selectors::{PaneSelector, TabSelector, TargetSelector, WindowSelector}; diff --git a/crates/local_control/src/protocol.rs b/crates/local_control/src/protocol.rs new file mode 100644 index 0000000000..0ad3592ec9 --- /dev/null +++ b/crates/local_control/src/protocol.rs @@ -0,0 +1,403 @@ +//! Wire protocol envelopes and error types for Warp local control. +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub use crate::catalog::{ + ActionImplementationStatus, ActionKind, ActionMetadata, ActionParameterSpec, ActionResultSpec, + AuthenticatedUserRequirement, ExecutionContextProof, InvocationContext, PROTOCOL_VERSION, + TargetScope, +}; +pub use crate::selectors::{ + PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, WindowTarget, +}; + +/// Opaque Drive object identifier supplied by Warp metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DriveObjectId(pub String); + +/// Public Warp Drive object families addressed by the control protocol. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DriveObjectType { + Workflow, + Notebook, + EnvVarCollection, + Prompt, + Folder, + AiFact, + AiRule, + McpServer, + McpServerCollection, + Space, + Trash, +} + +/// Common layout direction values accepted by pane and tab mutations. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + Left, + Right, + Up, + Down, + Previous, + Next, +} + +/// Input mode values accepted by `input.mode.set`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputMode { + Terminal, + Agent, +} + +/// Output flavor for block output reads. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BlockOutputFormat { + Plain, + Ansi, + Json, +} + +/// Tab type accepted by `tab.create`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TabType { + Terminal, + Agent, + CloudAgent, + Default, +} + +/// Typed parameter payloads for public catalog actions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ActionParams { + None, + ActionName { + action: String, + }, + BindingName { + binding_name: String, + }, + BooleanValue { + value: bool, + }, + ColorValue { + color: String, + }, + Direction { + direction: Direction, + }, + DriveObjectCreate(DriveObjectCreateParams), + DriveObjectId { + id: DriveObjectId, + }, + DriveObjectInsert(DriveObjectInsertParams), + DriveObjectList { + object_type: DriveObjectType, + }, + DriveObjectUpdate(DriveObjectUpdateParams), + FileOpen(FileOpenParams), + InputMode { + mode: InputMode, + }, + Key { + key: String, + }, + KeyValue { + key: String, + value: serde_json::Value, + }, + Limit { + limit: Option, + }, + Namespace { + namespace: Option, + }, + PageQuery { + page: Option, + query: Option, + }, + Query { + query: Option, + }, + Rename { + title: String, + }, + Resize { + direction: Direction, + amount: Option, + }, + TabActivate { + mode: TabActivationMode, + }, + TabClose { + mode: TabCloseMode, + }, + TabCreate(TabCreateParams), + Text { + text: String, + }, + ThemeName { + theme_name: String, + }, + WorkflowRun(WorkflowRunParams), +} + +/// Parameters for `tab.create` and `window.create` shell/profile options. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct TabCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shell: Option, +} + +/// Parameters for opening a file in Warp's app/editor state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileOpenParams { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option, + #[serde(default)] + pub new_tab: bool, +} + +/// Parameters for Drive object creation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DriveObjectCreateParams { + pub object_type: DriveObjectType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_file: Option, +} + +/// Parameters for Drive object updates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DriveObjectUpdateParams { + pub id: DriveObjectId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_file: Option, +} + +/// Parameters for inserting an existing Drive object into a target surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DriveObjectInsertParams { + pub id: DriveObjectId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +/// Parameters for running an approved Warp Drive workflow. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowRunParams { + pub id: DriveObjectId, + #[serde(default)] + pub args: Vec, +} + +/// Name/value argument passed to an approved workflow run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowArgument { + pub name: String, + pub value: String, +} + +/// Mode accepted by `tab.activate`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TabActivationMode { + Target, + Previous, + Next, + Last, +} + +/// Mode accepted by `tab.close`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TabCloseMode { + Target, + Active, + Others, + RightOf, +} + +/// Typed success payloads for catalog actions that need stable structured data. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ControlResult { + Acknowledgement { action: ActionKind }, + Metadata { data: serde_json::Value }, + Content { data: serde_json::Value }, +} + +/// Top-level request sent by a local-control client to a Warp instance. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RequestEnvelope { + pub protocol_version: u32, + pub request_id: Uuid, + #[serde(default)] + pub target: TargetSelector, + pub action: Action, +} + +impl RequestEnvelope { + pub fn new(action: Action) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + request_id: Uuid::new_v4(), + target: TargetSelector::default(), + action, + } + } +} + +/// Requested action and action-specific JSON parameters. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Action { + pub kind: ActionKind, + #[serde(default)] + pub params: serde_json::Value, +} + +impl Action { + pub fn new(kind: ActionKind) -> Self { + Self { + kind, + params: serde_json::Value::Object(Default::default()), + } + } +} + +/// Top-level response returned by a Warp instance for a control request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ResponseEnvelope { + pub protocol_version: u32, + pub request_id: Uuid, + pub response: ControlResponse, +} + +impl ResponseEnvelope { + pub fn ok(request_id: Uuid, data: serde_json::Value) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + request_id, + response: ControlResponse::Ok { data }, + } + } + + pub fn error(request_id: Uuid, error: ControlError) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + request_id, + response: ControlResponse::Error { error }, + } + } +} + +/// Success or error payload for a control response. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ControlResponse { + Ok { data: serde_json::Value }, + Error { error: ControlError }, +} + +/// Error envelope used when a request cannot be decoded into a full request envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorResponseEnvelope { + pub protocol_version: u32, + pub error: ControlError, +} + +impl ErrorResponseEnvelope { + pub fn new(error: ControlError) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + error, + } + } +} + +/// Structured error returned by local-control protocol and transport layers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[error("{code}: {message}")] +pub struct ControlError { + pub code: ErrorCode, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ControlError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + pub fn with_details( + code: ErrorCode, + message: impl Into, + details: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + details: Some(details.into()), + } + } +} + +/// Stable error code surfaced to CLI clients and automation. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + LocalControlDisabled, + UnauthorizedLocalClient, + InsufficientPermissions, + AuthenticatedUserRequired, + AuthenticatedUserUnavailable, + ExecutionContextNotAllowed, + ProtocolVersionUnsupported, + InvalidRequest, + InvalidSelector, + InvalidParams, + NoInstance, + AmbiguousInstance, + AmbiguousTarget, + StaleTarget, + TargetStateConflict, + MissingTarget, + TransportUnavailable, + BridgeUnavailable, + UnsupportedAction, + NotAllowlisted, + Internal, +} + +impl std::fmt::Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = serde_json::to_value(self).map_err(|_| std::fmt::Error)?; + let Some(value) = value.as_str() else { + return Err(std::fmt::Error); + }; + f.write_str(value) + } +} + +#[cfg(test)] +#[path = "protocol_tests.rs"] +mod tests; diff --git a/crates/local_control/src/protocol_tests.rs b/crates/local_control/src/protocol_tests.rs new file mode 100644 index 0000000000..096da70513 --- /dev/null +++ b/crates/local_control/src/protocol_tests.rs @@ -0,0 +1,151 @@ +use super::*; + +#[test] +fn request_envelope_serializes_stable_action_names() { + let request = RequestEnvelope::new(Action::new(ActionKind::WindowFocus)); + let value = serde_json::to_value(&request).expect("request serializes"); + assert_eq!(value["protocol_version"], PROTOCOL_VERSION); + assert_eq!(value["action"]["kind"], "window.focus"); +} + +#[test] +fn response_error_serializes_machine_code() { + let response = ResponseEnvelope::error( + Uuid::nil(), + ControlError::new(ErrorCode::UnauthorizedLocalClient, "bad token"), + ); + let value = serde_json::to_value(&response).expect("response serializes"); + assert_eq!(value["response"]["status"], "error"); + assert_eq!( + value["response"]["error"]["code"], + "unauthorized_local_client" + ); +} + +#[test] +fn ambiguous_target_error_code_is_stable() { + let value = serde_json::to_value(ErrorCode::AmbiguousTarget).expect("code serializes"); + assert_eq!(value, serde_json::json!("ambiguous_target")); +} + +#[test] +fn malformed_action_name_is_not_deserialized() { + let action = serde_json::from_value::(serde_json::json!("tab.create.extra")); + assert!(action.is_err()); +} + +#[test] +fn non_allowlisted_action_names_are_not_deserialized() { + for action in [ + "file.write", + "file.delete", + "auth.api_key.set", + "auth.api_key.status", + "auth.api_key.revoke", + ] { + assert!(serde_json::from_value::(serde_json::json!(action)).is_err()); + } +} + +#[test] +fn tab_create_metadata_is_first_slice_logged_out_safe_action() { + let metadata = ActionKind::TabCreate.metadata(); + assert_eq!( + metadata.implementation_status, + ActionImplementationStatus::Implemented + ); + assert!(!metadata.requires_authenticated_user); + assert!(!metadata.authenticated_user.required); + assert_eq!( + metadata.allowed_invocation_contexts, + vec![InvocationContext::OutsideWarp] + ); + assert_eq!(metadata.target_scope, TargetScope::Tab); +} + +#[test] +fn core_smoke_metadata_has_explicit_instance_policy() { + for action in [ + ActionKind::InstanceList, + ActionKind::AppPing, + ActionKind::AppVersion, + ] { + let metadata = action.metadata(); + assert_eq!( + metadata.implementation_status, + ActionImplementationStatus::Implemented + ); + assert!(!metadata.authenticated_user.required); + assert_eq!( + metadata.allowed_invocation_contexts, + vec![InvocationContext::OutsideWarp] + ); + assert_eq!(metadata.target_scope, TargetScope::Instance); + } +} + +#[test] +fn implemented_catalog_is_exactly_the_first_slice() { + let actions = ActionKind::implemented_metadata() + .into_iter() + .map(|metadata| metadata.kind) + .collect::>(); + assert_eq!( + actions, + vec![ + ActionKind::InstanceList, + ActionKind::AppPing, + ActionKind::AppVersion, + ActionKind::TabCreate, + ] + ); +} + +#[test] +fn action_metadata_serializes_action_policy() { + let metadata = ActionKind::TabCreate.metadata(); + let value = serde_json::to_value(metadata).expect("metadata serializes"); + assert_eq!(value["name"], "tab.create"); + assert_eq!(value["implementation_status"], "implemented"); + assert_eq!( + value["authenticated_user"]["required"], + serde_json::json!(false) + ); + assert_eq!( + value["allowed_invocation_contexts"], + serde_json::json!(["outside_warp"]) + ); + assert_eq!(value["target_scope"], "tab"); +} + +#[test] +fn logged_out_safe_stub_actions_can_advertise_external_context() { + let metadata = ActionKind::WindowCreate.metadata(); + assert_eq!( + metadata.implementation_status, + ActionImplementationStatus::Stub + ); + assert!(!metadata.authenticated_user.required); + assert!( + metadata + .allowed_invocation_contexts + .contains(&InvocationContext::OutsideWarp) + ); +} + +#[test] +fn authenticated_actions_are_warp_terminal_only_in_the_contract() { + for action in [ + ActionKind::DriveInspect, + ActionKind::DriveObjectCreate, + ActionKind::DriveWorkflowRun, + ActionKind::InputRun, + ] { + let metadata = action.metadata(); + assert!(metadata.authenticated_user.required); + assert_eq!( + metadata.allowed_invocation_contexts, + vec![InvocationContext::InsideWarp] + ); + } +} diff --git a/crates/local_control/src/selection.rs b/crates/local_control/src/selection.rs new file mode 100644 index 0000000000..b090f86b12 --- /dev/null +++ b/crates/local_control/src/selection.rs @@ -0,0 +1,58 @@ +//! Instance selection helpers for local-control clients. +use crate::discovery::{InstanceId, InstanceRecord}; +use crate::protocol::{ControlError, ErrorCode}; + +/// CLI-level selector for choosing one discovered Warp instance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InstanceSelector { + Active, + Id(InstanceId), + Pid(u32), +} + +pub fn select_instance( + records: &[InstanceRecord], + selector: &InstanceSelector, +) -> Result { + match selector { + InstanceSelector::Active => select_active(records), + InstanceSelector::Id(instance_id) => records + .iter() + .find(|record| &record.instance_id == instance_id) + .cloned() + .ok_or_else(|| { + ControlError::new( + ErrorCode::NoInstance, + format!("no Warp instance with id {}", instance_id.0), + ) + }), + InstanceSelector::Pid(pid) => records + .iter() + .find(|record| record.pid == *pid) + .cloned() + .ok_or_else(|| { + ControlError::new( + ErrorCode::NoInstance, + format!("no Warp instance with pid {pid}"), + ) + }), + } +} + +fn select_active(records: &[InstanceRecord]) -> Result { + match records { + [] => Err(ControlError::new( + ErrorCode::NoInstance, + "no local Warp control instances were discovered", + )), + [record] => Ok(record.clone()), + _ => Err(ControlError::new( + ErrorCode::AmbiguousInstance, + "multiple local Warp control instances were discovered; pass --instance", + )), + } +} + +#[cfg(test)] +#[path = "selection_tests.rs"] +mod tests; diff --git a/crates/local_control/src/selection_tests.rs b/crates/local_control/src/selection_tests.rs new file mode 100644 index 0000000000..9399ac06fe --- /dev/null +++ b/crates/local_control/src/selection_tests.rs @@ -0,0 +1,45 @@ +use chrono::Utc; + +use super::*; +use crate::discovery::{ControlEndpoint, CredentialBrokerReference}; +use crate::protocol::{ActionKind, PROTOCOL_VERSION}; + +fn record(id: &str, pid: u32) -> InstanceRecord { + InstanceRecord { + protocol_version: PROTOCOL_VERSION, + instance_id: InstanceId(id.to_owned()), + pid, + channel: "local".to_owned(), + app_id: "dev.warp.WarpLocal".to_owned(), + app_version: None, + started_at: Utc::now(), + executable_path: None, + endpoint: Some(ControlEndpoint::localhost(4000)), + credential_broker: Some(CredentialBrokerReference { + socket_path: format!("{id}.broker.sock").into(), + }), + outside_warp_control_enabled: true, + actions: vec![ActionKind::TabCreate.metadata()], + } +} + +#[test] +fn selects_instance_by_id() { + let records = vec![record("one", 1), record("two", 2)]; + let selected = select_instance(&records, &InstanceSelector::Id(InstanceId("two".into()))) + .expect("selected"); + assert_eq!(selected.pid, 2); +} + +#[test] +fn active_selector_rejects_ambiguity() { + let records = vec![record("one", 1), record("two", 2)]; + let err = select_instance(&records, &InstanceSelector::Active).expect_err("ambiguous"); + assert_eq!(err.code, ErrorCode::AmbiguousInstance); +} + +#[test] +fn active_selector_rejects_no_instances() { + let err = select_instance(&[], &InstanceSelector::Active).expect_err("no instance"); + assert_eq!(err.code, ErrorCode::NoInstance); +} diff --git a/crates/local_control/src/selectors.rs b/crates/local_control/src/selectors.rs new file mode 100644 index 0000000000..e76ba95a3f --- /dev/null +++ b/crates/local_control/src/selectors.rs @@ -0,0 +1,58 @@ +//! Serializable selectors for targeting windows, tabs, and panes. +use serde::{Deserialize, Serialize}; + +/// Opaque window identifier supplied by Warp metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct WindowSelector(pub String); + +/// Opaque tab identifier supplied by Warp metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TabSelector(pub String); + +/// Opaque pane identifier supplied by Warp metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PaneSelector(pub String); + +/// Hierarchical target for actions that operate on a specific Warp surface. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct TargetSelector { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane: Option, +} + +/// Window-level target selector. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WindowTarget { + Active, + Id { id: WindowSelector }, + Index { index: u32 }, + Title { title: String }, +} + +/// Tab-level target selector. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TabTarget { + Active, + Id { id: TabSelector }, + Index { index: u32 }, + Title { title: String }, +} + +/// Pane-level target selector. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PaneTarget { + Active, + Id { id: PaneSelector }, + Index { index: u32 }, +} diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 65de75dce0..f1c9bf363b 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -63,6 +63,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use warp_features::FeatureFlag; use warpui_core::{AppContext, Entity, ModelContext}; +use warpui_extras::secure_storage::{self, AppContextExt as _}; use warpui_extras::user_preferences::UserPreferences; /// A newtype wrapper for the public preferences backend. @@ -556,6 +557,89 @@ pub trait Setting { fn is_value_explicitly_set(&self) -> bool; } +/// Shared persistence operations for typed settings backed by secure storage. +/// +/// Implementors remain responsible for routing their [`Setting`] lifecycle +/// methods through this trait and for keeping the setting private and +/// non-synced when the value must not be exposed through ordinary settings +/// storage. +pub trait SecureSetting: Setting { + /// Writes this setting's serialized value through its selected secure-storage path. + fn write_secure_storage_value( + storage: &dyn secure_storage::SecureStorage, + key: &str, + value: &str, + ) -> Result<(), secure_storage::Error> { + storage.write_value(key, value) + } + /// Reads and deserializes this setting from secure storage. + /// + /// Missing, unreadable, or malformed values return `None`, allowing the + /// setting to fail closed to its default value. + fn read_from_secure_storage(ctx: &AppContext) -> Option { + let value = match ctx.secure_storage().read_value(Self::storage_key()) { + Ok(value) => value, + Err(secure_storage::Error::NotFound) => return None, + Err(err) => { + log::error!( + "Failed to read {} from secure storage: {err:#}", + Self::setting_name() + ); + return None; + } + }; + match serde_json::from_str(&value) { + Ok(value) => Some(value), + Err(err) => { + log::error!( + "Failed to deserialize {} from secure storage: {err:#}", + Self::setting_name() + ); + None + } + } + } + + /// Persists this setting to secure storage if its typed value changed. + fn write_to_secure_storage(new_value: &Self::Value, ctx: &AppContext) -> Result { + let stored_value_matches = match ctx.secure_storage().read_value(Self::storage_key()) { + Ok(stored) => serde_json::from_str::(&stored) + .is_ok_and(|stored| stored == *new_value), + Err(secure_storage::Error::NotFound) => false, + Err(err) => { + return Err(anyhow::anyhow!(err)).context(format!( + "Failed to read existing {} from secure storage", + Self::setting_name() + )); + } + }; + if stored_value_matches { + return Ok(false); + } + let serialized = serde_json::to_string(new_value).context(format!( + "Failed to serialize {} for secure storage", + Self::setting_name() + ))?; + Self::write_secure_storage_value(ctx.secure_storage(), Self::storage_key(), &serialized) + .context(format!( + "Failed to write {} to secure storage", + Self::setting_name() + ))?; + Ok(true) + } + + /// Removes this setting from secure storage. + fn clear_from_secure_storage(ctx: &AppContext) -> Result<()> { + match ctx.secure_storage().remove_value(Self::storage_key()) { + Ok(()) | Err(secure_storage::Error::NotFound) => Ok(()), + Err(err) => Err(anyhow::anyhow!(err)).context(format!( + "Failed to clear {} from secure storage", + Self::setting_name() + )), + } + } +} + /// A trait for settings that can be toggled between two values. pub trait ToggleableSetting: Setting { /// Toggles the value of the setting and persists it to storage, returning diff --git a/crates/warp_cli/Cargo.toml b/crates/warp_cli/Cargo.toml index ca404baadd..9a642be108 100644 --- a/crates/warp_cli/Cargo.toml +++ b/crates/warp_cli/Cargo.toml @@ -13,6 +13,7 @@ cfg-if = { workspace = true } humantime.workspace = true jaq-all.workspace = true serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true url = { workspace = true, features = ["serde"] } uuid = { workspace = true } warp_core = { path = "../warp_core" } @@ -20,6 +21,7 @@ color-print = "0.3" warp_util = { path = "../warp_util" } clap_complete = "4.5.58" anyhow.workspace = true +local_control.workspace = true [target.'cfg(windows)'.dependencies] windows = { workspace = true, features = [ @@ -34,5 +36,6 @@ plugin_host = [] integration_tests = [] api_key_authentication = [] + [dev-dependencies] serial_test = "0.8.0" diff --git a/crates/warp_cli/src/lib.rs b/crates/warp_cli/src/lib.rs index ffd7e33f2a..37b2c6d313 100644 --- a/crates/warp_cli/src/lib.rs +++ b/crates/warp_cli/src/lib.rs @@ -29,6 +29,7 @@ pub mod federate; pub mod harness_support; pub mod integration; pub mod json_filter; +pub mod local_control; pub mod mcp; pub mod model; pub mod provider; diff --git a/crates/warp_cli/src/local_control/commands.rs b/crates/warp_cli/src/local_control/commands.rs new file mode 100644 index 0000000000..41eab45168 --- /dev/null +++ b/crates/warp_cli/src/local_control/commands.rs @@ -0,0 +1,171 @@ +//! Implementations for user-facing `warpctrl` command groups. +use local_control::protocol::{ + Action, ActionKind, ActionMetadata, ControlError, ErrorCode, RequestEnvelope, +}; +use local_control::selection::select_instance; +use serde::Serialize; + +use crate::agent::OutputFormat; +use crate::local_control::output::{write_json, write_json_line}; +use crate::local_control::selectors::instance_selector; +use crate::local_control::{AppCommand, InstanceCommand, TabCommand, TargetArgs}; + +/// Display-oriented projection of a discoverable Warp instance. +#[derive(Serialize)] +struct InstanceSummary { + instance_id: String, + pid: u32, + channel: String, + app_id: String, + app_version: Option, + started_at: String, + endpoint: Option, + outside_warp_control_enabled: bool, + actions: Vec, +} + +impl From for InstanceSummary { + fn from(record: local_control::discovery::InstanceRecord) -> Self { + Self { + instance_id: record.instance_id.0, + pid: record.pid, + channel: record.channel, + app_id: record.app_id, + app_version: record.app_version, + started_at: record.started_at.to_rfc3339(), + endpoint: record.endpoint, + outside_warp_control_enabled: record.outside_warp_control_enabled, + actions: record.actions, + } + } +} + +fn render_human_readable(action: ActionKind, data: &serde_json::Value) -> String { + match action { + ActionKind::AppPing => format!( + "Warp instance {} is reachable (protocol version {})", + value_or_unknown(data, "instance_id"), + value_or_unknown(data, "protocol_version") + ), + ActionKind::AppVersion => format!( + "Warp instance {}\nchannel: {}\napp_id: {}\nprotocol_version: {}", + value_or_unknown(data, "instance_id"), + value_or_unknown(data, "channel"), + value_or_unknown(data, "app_id"), + value_or_unknown(data, "protocol_version") + ), + ActionKind::TabCreate => format!( + "Created tab {} in window {} (active index {}, tab count {})", + nested_value_or_unknown(data, &["tab", "id"]), + nested_value_or_unknown(data, &["window", "id"]), + nested_value_or_unknown(data, &["tab", "active_index"]), + nested_value_or_unknown(data, &["tab", "count"]) + ), + _ => serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string()), + } +} + +fn value_or_unknown(data: &serde_json::Value, key: &str) -> String { + nested_value_or_unknown(data, &[key]) +} + +fn nested_value_or_unknown(data: &serde_json::Value, path: &[&str]) -> String { + let value = path + .iter() + .try_fold(data, |value, key| value.get(*key)) + .unwrap_or(&serde_json::Value::Null); + match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Null => "".to_owned(), + value => value.to_string(), + } +} + +#[cfg(test)] +pub(crate) fn render_human_readable_for_test( + action: ActionKind, + data: &serde_json::Value, +) -> String { + render_human_readable(action, data) +} + +pub(super) fn run_instance_command( + command: InstanceCommand, + output_format: OutputFormat, +) -> Result<(), ControlError> { + match command { + InstanceCommand::List => { + let summaries = local_control::discovery::list_instances() + .into_iter() + .map(InstanceSummary::from) + .collect::>(); + match output_format { + OutputFormat::Json => write_json(&summaries), + OutputFormat::Ndjson => { + for summary in summaries { + write_json_line(&summary)?; + } + Ok(()) + } + OutputFormat::Pretty | OutputFormat::Text => { + for summary in summaries { + let endpoint = summary + .endpoint + .as_ref() + .map(|endpoint| format!("{}:{}", endpoint.host, endpoint.port)) + .unwrap_or_else(|| "outside_warp_disabled".to_owned()); + println!( + "{}\tpid={}\t{}\t{}", + summary.instance_id, summary.pid, summary.channel, endpoint + ); + } + Ok(()) + } + } + } + } +} + +pub(super) fn run_app_command( + command: AppCommand, + output_format: OutputFormat, +) -> Result<(), ControlError> { + match command { + AppCommand::Ping(args) => run_action(args, ActionKind::AppPing, output_format), + AppCommand::Version(args) => run_action(args, ActionKind::AppVersion, output_format), + } +} +pub(super) fn run_tab_command( + command: TabCommand, + output_format: OutputFormat, +) -> Result<(), ControlError> { + match command { + TabCommand::Create(args) => run_action(args, ActionKind::TabCreate, output_format), + } +} + +fn run_action( + args: TargetArgs, + action: ActionKind, + output_format: OutputFormat, +) -> Result<(), ControlError> { + let records = local_control::discovery::list_instances(); + let selector = instance_selector(args); + let instance = select_instance(&records, &selector)?; + let request = RequestEnvelope::new(Action::new(action)); + let response = local_control::client::send_request(&instance, &request)?; + let local_control::protocol::ControlResponse::Ok { data } = response.response else { + return Err(ControlError::new( + ErrorCode::Internal, + "local-control request failed without an error payload", + )); + }; + match output_format { + OutputFormat::Json => write_json(&data), + OutputFormat::Ndjson => write_json_line(&data), + OutputFormat::Pretty | OutputFormat::Text => { + println!("{}", render_human_readable(action, &data)); + Ok(()) + } + } +} diff --git a/crates/warp_cli/src/local_control/completions.rs b/crates/warp_cli/src/local_control/completions.rs new file mode 100644 index 0000000000..2664e2520a --- /dev/null +++ b/crates/warp_cli/src/local_control/completions.rs @@ -0,0 +1,32 @@ +//! Shell completion generation for `warpctrl`. +use clap_complete::aot::{Shell, generate}; +use local_control::protocol::{ControlError, ErrorCode}; + +use crate::local_control::ControlArgs; + +pub(super) fn generate_completions_to_stdout(shell: Option) -> Result<(), ControlError> { + let shell = shell.or_else(Shell::from_env).ok_or_else(|| { + ControlError::new( + ErrorCode::InvalidParams, + "could not determine shell from environment; provide a shell argument", + ) + })?; + let mut cmd = ControlArgs::clap_command(); + let bin_name = crate::binary_name().unwrap_or_else(|| "warpctrl".to_owned()); + generate(shell, &mut cmd, bin_name, &mut std::io::stdout()); + Ok(()) +} + +#[cfg(test)] +pub(crate) fn generate_completion_string(shell: Shell) -> Result { + let mut cmd = ControlArgs::clap_command(); + let mut output = Vec::new(); + generate(shell, &mut cmd, "warpctrl", &mut output); + String::from_utf8(output).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to render local-control completions", + err.to_string(), + ) + }) +} diff --git a/crates/warp_cli/src/local_control/mod.rs b/crates/warp_cli/src/local_control/mod.rs new file mode 100644 index 0000000000..425638a847 --- /dev/null +++ b/crates/warp_cli/src/local_control/mod.rs @@ -0,0 +1,223 @@ +//! Command-line interface for controlling a running local Warp app. +mod commands; +mod completions; +mod output; +mod selectors; +use std::ffi::OsString; +use std::process::ExitCode; + +use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand}; +use clap_complete::aot::Shell; +use commands::{run_app_command, run_instance_command, run_tab_command}; +use completions::generate_completions_to_stdout; +use output::write_control_error; + +use crate::agent::OutputFormat; +/// Hidden flag used by the channel-specific Warp app binary to enter `warpctrl` mode. +pub const CONTROL_MODE_FLAG: &str = "--warpctrl"; + +/// Parsed top-level arguments for `warpctrl`. +#[derive(Debug, Parser)] +#[command( + name = "warpctrl", + display_name = "warpctrl", + about = "Control a running local Warp app instance" +)] +pub struct ControlArgs { + /// Set the output format. + #[arg( + long = "output-format", + global = true, + value_enum, + default_value_t = OutputFormat::Pretty, + env = "WARP_OUTPUT_FORMAT" + )] + pub output_format: OutputFormat, + + #[command(subcommand)] + pub command: ControlCommand, +} + +impl ControlArgs { + pub fn from_env() -> Self { + let bin_name = crate::binary_name().unwrap_or_else(|| "warpctrl".to_owned()); + Self::try_parse_from_args(std::env::args_os(), bin_name).unwrap_or_else(|err| err.exit()) + } + + pub fn from_control_mode_env() -> Option { + Self::try_parse_control_mode_from(std::env::args_os()) + .map(|result| result.unwrap_or_else(|err| err.exit())) + } + + pub fn try_parse_control_mode_from(args: I) -> Option> + where + I: IntoIterator, + T: Into, + { + let mut stripped_args = vec![OsString::from("warpctrl")]; + let mut found_control_mode = false; + + for arg in args { + let arg = arg.into(); + if !found_control_mode { + if arg.to_str() == Some(CONTROL_MODE_FLAG) { + found_control_mode = true; + } + continue; + } + stripped_args.push(arg); + } + + found_control_mode.then(|| Self::try_parse_from_args(stripped_args, "warpctrl")) + } + + pub fn clap_command() -> clap::Command { + let bin_name = crate::binary_name().unwrap_or_else(|| "warpctrl".to_owned()); + Self::clap_command_for_bin_name(bin_name) + } + + fn try_parse_from_args(args: I, bin_name: impl Into) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + let matches = Self::clap_command_for_bin_name(bin_name).try_get_matches_from(args)?; + Self::from_arg_matches(&matches) + } + + fn clap_command_for_bin_name(bin_name: impl Into) -> clap::Command { + let bin_name = bin_name.into(); + ::command() + .version(crate::version_string()) + .bin_name(bin_name.clone()) + .after_help(color_print::cformat!( + r#"Examples: + + $ {bin_name} instance list + + $ {bin_name} tab create + +Learn more: +* Use {bin_name} help to learn more about each command +"# + )) + } +} + +/// Top-level `warpctrl` command groups. +#[derive(Debug, Clone, Subcommand)] +pub enum ControlCommand { + /// Inspect local Warp app instances. + #[command(subcommand)] + Instance(InstanceCommand), + /// Inspect a selected local Warp app. + #[command(subcommand)] + App(AppCommand), + + /// Control local Warp tabs. + #[command(subcommand)] + Tab(TabCommand), + + /// Generate shell completions for your shell to stdout. + /// + /// For bash, add the following to ~/.bashrc: + /// source <(path/to/warpctrl completions bash) + /// + /// For zsh, add the following to ~/.zshrc: + /// source <(path/to/warpctrl completions zsh) + /// + /// For fish, add the following to ~/.config/fish/config.fish: + /// path/to/warpctrl completions fish | source + /// + /// For Powershell, add the following to $PROFILE: + /// path\to\warpctrl completions powershell | Out-String | Invoke-Expression + /// + /// If no shell is provided, this defaults to the shell that Warp was run from. + #[command(verbatim_doc_comment)] + Completions { + /// Shell to generate completions for. + #[arg(value_enum)] + shell: Option, + }, +} + +/// Commands that inspect locally discoverable Warp instances. +#[derive(Debug, Clone, Subcommand)] +pub enum InstanceCommand { + /// List locally discoverable Warp instances. + List, +} + +/// Commands that inspect the selected Warp app instance. +#[derive(Debug, Clone, Subcommand)] +pub enum AppCommand { + /// Check that the selected local Warp app responds. + Ping(TargetArgs), + + /// Print protocol and build identity metadata for the selected local Warp app. + Version(TargetArgs), +} + +/// Commands that control tabs in the selected Warp app instance. +#[derive(Debug, Clone, Subcommand)] +pub enum TabCommand { + /// Create a new terminal tab in the active window. + Create(TargetArgs), +} + +/// Common flags for selecting which running Warp instance receives a command. +#[derive(Debug, Clone, Args, Default)] +pub struct TargetArgs { + /// Target a specific local Warp instance id from `warp instance list`. + #[arg(long = "instance")] + pub instance: Option, + + /// Target a specific local Warp process id. + #[arg(long = "pid", conflicts_with = "instance")] + pub pid: Option, +} + +pub fn run(args: ControlArgs) -> ExitCode { + ExitCode::from(run_exit_code(args)) +} + +pub fn run_and_exit(args: ControlArgs) -> ! { + std::process::exit(i32::from(run_exit_code(args))) +} + +fn run_exit_code(args: ControlArgs) -> u8 { + let output_format = args.output_format; + match run_inner(args) { + Ok(()) => 0, + Err(error) => { + if let Err(write_error) = write_control_error(&error, output_format) { + eprintln!( + "error: failed to render local-control error: {}", + write_error.message + ); + } + 1 + } + } +} + +fn run_inner(args: ControlArgs) -> Result<(), local_control::protocol::ControlError> { + let output_format = args.output_format; + match args.command { + ControlCommand::Instance(command) => run_instance_command(command, output_format), + ControlCommand::App(command) => run_app_command(command, output_format), + ControlCommand::Tab(command) => run_tab_command(command, output_format), + ControlCommand::Completions { shell } => generate_completions_to_stdout(shell), + } +} + +#[cfg(test)] +pub(crate) use commands::render_human_readable_for_test; +#[cfg(test)] +pub(crate) use completions::generate_completion_string; +#[cfg(test)] +pub(crate) use output::ErrorSummary; + +#[cfg(test)] +#[path = "../local_control_tests.rs"] +mod tests; diff --git a/crates/warp_cli/src/local_control/output.rs b/crates/warp_cli/src/local_control/output.rs new file mode 100644 index 0000000000..de70846039 --- /dev/null +++ b/crates/warp_cli/src/local_control/output.rs @@ -0,0 +1,53 @@ +//! Output rendering helpers for `warpctrl`. +use std::io::Write as _; + +use local_control::protocol::{ControlError, ErrorCode}; +use serde::Serialize; + +use crate::agent::OutputFormat; + +/// JSON/NDJSON error payload emitted by `warpctrl`. +#[derive(Serialize)] +pub(crate) struct ErrorSummary<'a> { + pub ok: bool, + pub error: &'a ControlError, +} + +pub(super) fn write_control_error( + error: &ControlError, + output_format: OutputFormat, +) -> Result<(), ControlError> { + match output_format { + OutputFormat::Json => write_json(&ErrorSummary { ok: false, error }), + OutputFormat::Ndjson => write_json_line(&ErrorSummary { ok: false, error }), + OutputFormat::Pretty | OutputFormat::Text => { + eprintln!("error: {}: {}", error.code, error.message); + if let Some(details) = &error.details { + eprintln!("details: {details}"); + } + Ok(()) + } + } +} + +pub(super) fn write_json(value: &impl Serialize) -> Result<(), ControlError> { + let stdout = std::io::stdout(); + let mut lock = stdout.lock(); + serde_json::to_writer_pretty(&mut lock, value).map_err(write_error)?; + writeln!(&mut lock).map_err(write_error)?; + Ok(()) +} +pub(super) fn write_json_line(value: &impl Serialize) -> Result<(), ControlError> { + let stdout = std::io::stdout(); + let mut lock = stdout.lock(); + serde_json::to_writer(&mut lock, value).map_err(write_error)?; + writeln!(&mut lock).map_err(write_error)?; + Ok(()) +} +fn write_error(error: impl std::error::Error) -> ControlError { + ControlError::with_details( + ErrorCode::Internal, + "failed to write local-control output", + error.to_string(), + ) +} diff --git a/crates/warp_cli/src/local_control/selectors.rs b/crates/warp_cli/src/local_control/selectors.rs new file mode 100644 index 0000000000..8633c2cc31 --- /dev/null +++ b/crates/warp_cli/src/local_control/selectors.rs @@ -0,0 +1,14 @@ +//! CLI argument conversion into shared local-control selectors. +use local_control::selection::InstanceSelector; + +use crate::local_control::TargetArgs; + +pub(super) fn instance_selector(args: TargetArgs) -> InstanceSelector { + if let Some(instance_id) = args.instance { + return InstanceSelector::Id(local_control::discovery::InstanceId(instance_id)); + } + if let Some(pid) = args.pid { + return InstanceSelector::Pid(pid); + } + InstanceSelector::Active +} diff --git a/crates/warp_cli/src/local_control_tests.rs b/crates/warp_cli/src/local_control_tests.rs new file mode 100644 index 0000000000..db993b7650 --- /dev/null +++ b/crates/warp_cli/src/local_control_tests.rs @@ -0,0 +1,225 @@ +use std::ffi::OsString; + +use clap::Parser as _; +use clap_complete::aot::Shell; +use local_control::protocol::{ControlError, ErrorCode}; +use serde_json::json; +use serial_test::serial; + +use super::*; + +const DISCOVERY_DIR_ENV: &str = "WARP_LOCAL_CONTROL_DISCOVERY_DIR"; + +fn set_discovery_dir(path: &std::path::Path) -> Option { + let previous = std::env::var_os(DISCOVERY_DIR_ENV); + unsafe { std::env::set_var(DISCOVERY_DIR_ENV, path) }; + previous +} + +fn restore_discovery_dir(previous: Option) { + match previous { + Some(value) => unsafe { std::env::set_var(DISCOVERY_DIR_ENV, value) }, + None => unsafe { std::env::remove_var(DISCOVERY_DIR_ENV) }, + } +} +#[test] +fn parses_first_slice_tab_create() { + let args = ControlArgs::try_parse_from(["warpctrl", "tab", "create", "--instance", "inst_123"]) + .expect("tab create parses"); + let ControlCommand::Tab(TabCommand::Create(target)) = args.command else { + panic!("expected tab create command"); + }; + assert_eq!(target.instance.as_deref(), Some("inst_123")); +} +#[test] +fn rejects_conflicting_instance_selectors() { + let err = ControlArgs::try_parse_from([ + "warpctrl", + "tab", + "create", + "--instance", + "inst_123", + "--pid", + "123", + ]) + .expect_err("instance and pid conflict"); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); +} +#[test] +fn parses_pid_instance_selector() { + let args = ControlArgs::try_parse_from(["warpctrl", "app", "ping", "--pid", "123"]) + .expect("pid selector parses"); + let ControlCommand::App(AppCommand::Ping(target)) = args.command else { + panic!("expected app ping command"); + }; + assert_eq!(target.pid, Some(123)); +} + +#[test] +fn parses_first_slice_instance_list() { + let args = ControlArgs::try_parse_from(["warpctrl", "instance", "list"]) + .expect("instance list parses"); + assert!(matches!( + args.command, + ControlCommand::Instance(InstanceCommand::List) + )); +} + +#[test] +fn parses_first_slice_app_smoke_metadata_commands() { + assert!(ControlArgs::try_parse_from(["warpctrl", "app", "ping"]).is_ok()); + assert!(ControlArgs::try_parse_from(["warpctrl", "app", "version"]).is_ok()); +} + +#[test] +fn every_implemented_catalog_action_has_a_parser_route() { + let routes = [ + ( + local_control::protocol::ActionKind::InstanceList, + vec!["warpctrl", "instance", "list"], + ), + ( + local_control::protocol::ActionKind::AppPing, + vec!["warpctrl", "app", "ping"], + ), + ( + local_control::protocol::ActionKind::AppVersion, + vec!["warpctrl", "app", "version"], + ), + ( + local_control::protocol::ActionKind::TabCreate, + vec!["warpctrl", "tab", "create"], + ), + ]; + let implemented = local_control::protocol::ActionKind::implemented_metadata() + .into_iter() + .map(|metadata| metadata.kind) + .collect::>(); + + assert_eq!( + implemented, + routes.iter().map(|(action, _)| *action).collect::>() + ); + for (_, args) in routes { + ControlArgs::try_parse_from(args).expect("implemented action parser route exists"); + } +} +#[test] +fn parses_control_mode_args_after_hidden_flag() { + let args = ControlArgs::try_parse_control_mode_from([ + "warp", + "--warpctrl", + "tab", + "create", + "--instance", + "inst_123", + ]) + .expect("control mode flag is present") + .expect("control mode args parse"); + let ControlCommand::Tab(TabCommand::Create(target)) = args.command else { + panic!("expected tab create command"); + }; + assert_eq!(target.instance.as_deref(), Some("inst_123")); +} + +#[test] +fn ignores_args_without_control_mode_flag() { + assert!(ControlArgs::try_parse_control_mode_from(["warp", "tab", "create"]).is_none()); +} + +#[test] +fn parses_completion_generation_command() { + let args = ControlArgs::try_parse_from(["warpctrl", "completions", "bash"]) + .expect("completions parses"); + assert!(matches!( + args.command, + ControlCommand::Completions { + shell: Some(Shell::Bash) + } + )); +} + +#[test] +fn rejects_future_catalog_commands_not_in_first_slice() { + assert!(ControlArgs::try_parse_from(["warpctrl", "window", "list"]).is_err()); + assert!(ControlArgs::try_parse_from(["warpctrl", "tab", "list"]).is_err()); + assert!(ControlArgs::try_parse_from(["warpctrl", "setting", "list"]).is_err()); +} + +#[test] +fn generated_bash_completions_include_first_slice_commands() { + let completions = + generate_completion_string(Shell::Bash).expect("bash completions render to UTF-8"); + assert!(completions.contains("instance")); + assert!(completions.contains("tab")); + assert!(completions.contains("completions")); +} + +#[test] +fn structured_error_output_uses_stable_error_code() { + let error = ControlError::new(ErrorCode::NoInstance, "no local Warp control instances"); + let value = serde_json::to_value(ErrorSummary { + ok: false, + error: &error, + }) + .expect("error summary serializes"); + assert_eq!(value["ok"], json!(false)); + assert_eq!(value["error"]["code"], json!("no_instance")); + assert_eq!( + value["error"]["message"], + json!("no local Warp control instances") + ); +} + +#[test] +fn renders_human_readable_tab_create_output() { + let rendered = render_human_readable_for_test( + local_control::protocol::ActionKind::TabCreate, + &json!({ + "tab": { + "id": "tab_123", + "active_index": 2, + "count": 3 + }, + "window": { + "id": "window_123" + } + }), + ); + assert_eq!( + rendered, + "Created tab tab_123 in window window_123 (active index 2, tab count 3)" + ); +} + +#[test] +#[serial] +fn instance_list_without_discovery_records_succeeds() { + let dir = std::env::temp_dir().join(format!( + "warpctrl-empty-discovery-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&dir).expect("temp discovery dir is created"); + let previous = set_discovery_dir(&dir); + let args = ControlArgs::try_parse_from(["warpctrl", "instance", "list"]) + .expect("instance list parses"); + let result = run_inner(args); + restore_discovery_dir(previous); + result.expect("empty instance list succeeds"); +} +#[test] +#[serial] +fn tab_create_without_discovery_records_reports_no_instance() { + let dir = std::env::temp_dir().join(format!( + "warpctrl-empty-discovery-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&dir).expect("temp discovery dir is created"); + let previous = set_discovery_dir(&dir); + let args = + ControlArgs::try_parse_from(["warpctrl", "--output-format", "json", "tab", "create"]) + .expect("tab create parses"); + let error = run_inner(args).expect_err("missing instance is rejected"); + restore_discovery_dir(previous); + assert_eq!(error.code, ErrorCode::NoInstance); +} diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index c590776e31..bc79fca454 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -794,6 +794,9 @@ pub enum FeatureFlag { /// Enables tab configs — user-definable TOML templates for launching custom tab layouts. TabConfigs, + /// Enables Warp local control through the standalone warpctrl CLI. + WarpControlCli, + /// When enabled, free-tier users are blocked from AI features (no-AI experiment arm). FreeUserNoAi, @@ -990,7 +993,9 @@ impl FeatureFlag { // Allow calling this in integration tests because we sometimes use it in the app // during flows that integration tests cover. if cfg!(test) && cfg!(not(feature = "integration_tests")) { - panic!("Tried to globally enable {self:?} in a test. Use FeatureFlag::{self:?}.override_enabled instead"); + panic!( + "Tried to globally enable {self:?} in a test. Use FeatureFlag::{self:?}.override_enabled instead" + ); } FLAG_STATES[self as usize].store(enabled, Ordering::Relaxed); } @@ -1033,15 +1038,25 @@ impl FeatureFlag { BlocklistMarkdownImages => { Some("Enables rendering markdown images inline in AI block list responses.") } - CloudEnvironments => Some("Enables creating and managing Warp Environments via the CLI."), - CreateEnvironmentSlashCommand => Some("Enables the /create environment slash command for setting up Warp Environments with custom configurations."), + CloudEnvironments => { + Some("Enables creating and managing Warp Environments via the CLI.") + } + CreateEnvironmentSlashCommand => Some( + "Enables the /create environment slash command for setting up Warp Environments with custom configurations.", + ), GlobalSearch => Some("Enables global search in the left panel"), BlocklistMarkdownTableRendering => { Some("Enables rendering markdown tables inline in AI block list responses.") } - MarkdownTables => Some("Enables rendering and interaction support for markdown tables in notebooks."), - SettingsFile => Some("Enables configuring Warp via a user-editable `settings.toml` file, with hot reload and error reporting for invalid values."), - GitOperationsInCodeReview => Some("Enables commit, push, and create-PR actions directly from the code review panel."), + MarkdownTables => { + Some("Enables rendering and interaction support for markdown tables in notebooks.") + } + SettingsFile => Some( + "Enables configuring Warp via a user-editable `settings.toml` file, with hot reload and error reporting for invalid values.", + ), + GitOperationsInCodeReview => Some( + "Enables commit, push, and create-PR actions directly from the code review panel.", + ), _ => None, } } diff --git a/crates/warpui_extras/src/secure_storage/linux.rs b/crates/warpui_extras/src/secure_storage/linux.rs index 56f0cb5d1a..adb130f0cc 100644 --- a/crates/warpui_extras/src/secure_storage/linux.rs +++ b/crates/warpui_extras/src/secure_storage/linux.rs @@ -2,6 +2,9 @@ use std::cell::OnceCell; use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write as _; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; use std::path::PathBuf; use anyhow::{anyhow, Context}; @@ -227,10 +230,43 @@ impl SecureStorage { let fallback_file = self.fallback_file(key)?; let encrypted = self.fallback_encrypt(value)?; - std::fs::write(fallback_file, encrypted).map_err(|err| Error::Unknown(err.into())) } + fn write_owner_only_fallback_value(&self, key: &str, value: &str) -> Result<(), Error> { + let fallback_file = self.fallback_file(key)?; + + let encrypted = self.fallback_encrypt(value)?; + let Some(fallback_dir) = fallback_file.parent() else { + return Err(Error::Unknown(anyhow!( + "Invalid fallback secure-storage directory" + ))); + }; + std::fs::create_dir_all(fallback_dir).map_err(|err| Error::Unknown(err.into()))?; + let mut dir_permissions = std::fs::metadata(fallback_dir) + .map_err(|err| Error::Unknown(err.into()))? + .permissions(); + dir_permissions.set_mode(0o700); + std::fs::set_permissions(fallback_dir, dir_permissions) + .map_err(|err| Error::Unknown(err.into()))?; + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&fallback_file) + .map_err(|err| Error::Unknown(err.into()))?; + file.write_all(&encrypted) + .map_err(|err| Error::Unknown(err.into()))?; + let mut file_permissions = file + .metadata() + .map_err(|err| Error::Unknown(err.into()))? + .permissions(); + file_permissions.set_mode(0o600); + file.set_permissions(file_permissions) + .map_err(|err| Error::Unknown(err.into())) + } + fn read_fallback_value(&self, key: &str) -> Result { let fallback_file = self.fallback_file(key)?; @@ -260,6 +296,17 @@ impl super::SecureStorage for SecureStorage { Err(_) => self.write_fallback_value(key, value), } } + fn write_value_with_owner_only_fallback(&self, key: &str, value: &str) -> Result<(), Error> { + let secret_result = self.write_secret_value(key, value); + + match secret_result { + Ok(_) => { + let _ = self.delete_fallback_value(key); + Ok(()) + } + Err(_) => self.write_owner_only_fallback_value(key, value), + } + } fn read_value(&self, key: &str) -> Result { let secret_result = self.with_item(key, |item| { diff --git a/crates/warpui_extras/src/secure_storage/linux_tests.rs b/crates/warpui_extras/src/secure_storage/linux_tests.rs index fa57610f7a..3de2e4b45e 100644 --- a/crates/warpui_extras/src/secure_storage/linux_tests.rs +++ b/crates/warpui_extras/src/secure_storage/linux_tests.rs @@ -43,3 +43,37 @@ fn test_decrypt_fails_on_malformed_data() { ); } } + +#[test] +fn fallback_value_is_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let fallback_dir = temp_dir.path().join("secure-storage"); + let storage = SecureStorage::new_with_fallback("darmok", fallback_dir.clone()); + storage + .write_owner_only_fallback_value("key", "value") + .expect("fallback write"); + let dir_mode = std::fs::metadata(&fallback_dir) + .expect("directory metadata") + .permissions() + .mode() + & 0o777; + let file_mode = std::fs::metadata(storage.fallback_file("key").expect("fallback file")) + .expect("file metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(dir_mode, 0o700); + assert_eq!(file_mode, 0o600); +} + +#[test] +fn default_fallback_does_not_create_missing_directory() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let fallback_dir = temp_dir.path().join("secure-storage"); + let storage = SecureStorage::new_with_fallback("darmok", fallback_dir.clone()); + + assert!(storage.write_fallback_value("key", "value").is_err()); + assert!(!fallback_dir.exists()); +} diff --git a/crates/warpui_extras/src/secure_storage/mod.rs b/crates/warpui_extras/src/secure_storage/mod.rs index de71c81b6f..3e1a052ffb 100644 --- a/crates/warpui_extras/src/secure_storage/mod.rs +++ b/crates/warpui_extras/src/secure_storage/mod.rs @@ -93,6 +93,14 @@ pub fn register_with_dir( pub trait SecureStorage { /// Writes a value at the given key. fn write_value(&self, key: &str, value: &str) -> Result<(), Error>; + /// Writes a value while requiring any file fallback to be owner-only. + /// + /// Platforms without a file fallback use their normal secure-storage write + /// path. Callers should opt into this only when they require the stronger + /// fallback behavior because it may create or change fallback permissions. + fn write_value_with_owner_only_fallback(&self, key: &str, value: &str) -> Result<(), Error> { + self.write_value(key, value) + } /// Reads the value stored at the given key. fn read_value(&self, key: &str) -> Result; diff --git a/script/linux/bundle b/script/linux/bundle index 26a7d7ae34..0caeed04e0 100755 --- a/script/linux/bundle +++ b/script/linux/bundle @@ -22,6 +22,7 @@ trap cleanup EXIT # By default we build dev bundles. RELEASE_CHANNEL="dev" FEATURES="release_bundle,crash_reporting" +EXTRA_FEATURES="" PACKAGES=( appimage ) BUILD="true" BUILD_ARCH="$(uname -m)" @@ -75,10 +76,19 @@ while (( "$#" )); do PACKAGES=( $(IFS=, ; echo $2) ) shift 2 ;; + --features) + if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then + EXTRA_FEATURES="$2" + shift 2 + else + echo "Error: Argument for $1 is missing" >&2 + exit 1 + fi + ;; --artifact) if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then - if [[ "$2" != "app" && "$2" != "cli" ]]; then - echo "Error: --artifact must be either 'app' or 'cli', got '$2'" >&2 + if [[ "$2" != "app" && "$2" != "cli" && "$2" != "warpctrl" ]]; then + echo "Error: --artifact must be 'app', 'cli', or 'warpctrl', got '$2'" >&2 exit 1 fi ARTIFACT="$2" @@ -118,13 +128,13 @@ elif [[ $RELEASE_CHANNEL = "local" || $RELEASE_CHANNEL = "dev" ]]; then # For dev bundles, we want to enable debug assertions to # catch violations that would otherwise silently pass in # a normal release build (e.g. in stable). - if [[ "$ARTIFACT" == "cli" ]]; then + if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then CARGO_PROFILE="release-cli-debug_assertions" else CARGO_PROFILE="release-lto-debug_assertions" fi else - if [[ "$ARTIFACT" == "cli" ]]; then + if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then CARGO_PROFILE="release-cli" else CARGO_PROFILE="release-lto" @@ -189,11 +199,17 @@ if [[ "$ARTIFACT" == "cli" ]]; then if [[ $RELEASE_CHANNEL != "oss" ]]; then BINARY_NAME="${BINARY_NAME/warp/oz}" fi +elif [[ "$ARTIFACT" == "warpctrl" ]]; then + BINARY_NAME="warpctrl" + PACKAGES=() fi # Artifact-specific configuration -if [[ "$ARTIFACT" == "cli" ]]; then +if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then FEATURES="$FEATURES,standalone" + if [[ "$ARTIFACT" == "warpctrl" ]]; then + FEATURES="$FEATURES,warp_control_cli" + fi elif [[ "$ARTIFACT" == "app" ]]; then FEATURES="$FEATURES,gui" if [[ "$RELEASE_CHANNEL" == "local" || "$RELEASE_CHANNEL" == "dev" ]]; then @@ -204,6 +220,9 @@ elif [[ "$ARTIFACT" == "app" ]]; then FEATURES="$FEATURES,nld_classifier_v1,nld_heuristic_v1" fi fi +if [[ -n "$EXTRA_FEATURES" ]]; then + FEATURES="$FEATURES,$EXTRA_FEATURES" +fi BUNDLE_ID="dev.warp.$APP_NAME" EXECUTABLE_PATH="$CARGO_TARGET_OUTPUT_DIR/$WARP_BIN" @@ -243,8 +262,25 @@ else echo 'Skipping `cargo build` step due to --skip-build argument' fi +if [[ "$ARTIFACT" == "warpctrl" ]]; then + echo "Copying control-mode binary into $OUT_DIR/$WARP_BIN" + cp "$EXECUTABLE_PATH" "$OUT_DIR/$WARP_BIN" + WARPCTRL_SCRIPT_PATH="$OUT_DIR/warpctrl" + echo "Creating warpctrl wrapper script at $WARPCTRL_SCRIPT_PATH" + cat > "$WARPCTRL_SCRIPT_PATH" << EOF +#!/usr/bin/env bash +script_dir="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)" +exec "\$script_dir/$WARP_BIN" --warpctrl "\$@" +EOF + chmod +x "$WARPCTRL_SCRIPT_PATH" +fi +BINARY_PATH="$EXECUTABLE_PATH" +if [[ "$ARTIFACT" == "warpctrl" ]]; then + BINARY_PATH="$WARPCTRL_SCRIPT_PATH" +fi + # Prepare bundled resources for CLI builds. -if [[ "$ARTIFACT" == "cli" ]]; then +if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then echo "Preparing CLI resources directory" BUNDLED_RESOURCES_DIR="$OUT_DIR/resources" "$WORKSPACE_ROOT_DIR/script/prepare_bundled_resources" "$BUNDLED_RESOURCES_DIR" "$RELEASE_CHANNEL" "$CARGO_PROFILE" @@ -256,7 +292,7 @@ fi # as the directory containing all built packages. if [ "${GITHUB_ACTIONS}" == "true" ]; then echo "::echo::on" - echo "executable_path=$EXECUTABLE_PATH" >> "$GITHUB_OUTPUT" + echo "executable_path=$BINARY_PATH" >> "$GITHUB_OUTPUT" echo "debug_executable_path=$DEBUG_EXECUTABLE_PATH" >> "$GITHUB_OUTPUT" echo "packages_dir=$OUT_DIR" >> "$GITHUB_OUTPUT" echo "bundled_resources_dir=${BUNDLED_RESOURCES_DIR:-}" >> "$GITHUB_OUTPUT" diff --git a/script/linux/test_bundle_warpctrl b/script/linux/test_bundle_warpctrl new file mode 100755 index 0000000000..9764708542 --- /dev/null +++ b/script/linux/test_bundle_warpctrl @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -e + +workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +temp_dir="$(mktemp -d)" +trap 'rm -rf "$temp_dir"' EXIT + +mkdir -p "$temp_dir/bin" +cat > "$temp_dir/bin/cargo" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$CARGO_ARGS_FILE" +EOF +chmod +x "$temp_dir/bin/cargo" + +PATH="$temp_dir/bin:$PATH" \ + CARGO_ARGS_FILE="$temp_dir/cargo-args" \ + CARGO_TARGET_DIR="$temp_dir/target" \ + "$workspace_root/script/linux/bundle" \ + --check-only \ + --artifact warpctrl \ + --features smoke_feature + +grep -qx -- '--features' "$temp_dir/cargo-args" +grep -qx -- 'release_bundle,crash_reporting,agent_mode_debug,jemalloc_pprof,standalone,warp_control_cli,smoke_feature' "$temp_dir/cargo-args" + +profile_dir="$temp_dir/target/release-cli-debug_assertions" +mkdir -p "$profile_dir" +cat > "$profile_dir/dev" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$FORWARDED_ARGS_FILE" +EOF +chmod +x "$profile_dir/dev" + +CARGO_TARGET_DIR="$temp_dir/target" \ + NO_LICENSES=1 \ + SKIP_SETTINGS_SCHEMA=1 \ + "$workspace_root/script/linux/bundle" \ + --skip-build \ + --artifact warpctrl + +FORWARDED_ARGS_FILE="$temp_dir/forwarded-args" \ + "$profile_dir/bundle/linux/warpctrl" tab create --instance "inst 123" + +cat > "$temp_dir/expected-forwarded-args" <<'EOF' +--warpctrl +tab +create +--instance +inst 123 +EOF +cmp "$temp_dir/expected-forwarded-args" "$temp_dir/forwarded-args" diff --git a/script/macos/bundle b/script/macos/bundle index 1fc56f8257..f1ee751965 100755 --- a/script/macos/bundle +++ b/script/macos/bundle @@ -223,8 +223,8 @@ while (( "$#" )); do ;; --artifact) if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then - if [[ "$2" != "app" && "$2" != "cli" ]]; then - echo "Error: --artifact must be either 'app' or 'cli', got '$2'" >&2 + if [[ "$2" != "app" && "$2" != "cli" && "$2" != "warpctrl" ]]; then + echo "Error: --artifact must be 'app', 'cli', or 'warpctrl', got '$2'" >&2 exit 1 fi ARTIFACT="$2" @@ -250,13 +250,13 @@ elif [[ $RELEASE_CHANNEL = "local" || $RELEASE_CHANNEL = "dev" ]]; then # For dev bundles, we want to enable debug assertions to # catch violations that would otherwise silently pass in # a normal release build (e.g. in stable). - if [[ "$ARTIFACT" == "cli" ]]; then + if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then CARGO_PROFILE="release-cli-debug_assertions" else CARGO_PROFILE="release-lto-debug_assertions" fi else - if [[ "$ARTIFACT" == "cli" ]]; then + if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then CARGO_PROFILE="release-cli" else CARGO_PROFILE="release-lto" @@ -347,7 +347,7 @@ else fi # Set artifact-specific configuration. -if [[ "$ARTIFACT" == cli ]]; then +if [[ "$ARTIFACT" == cli || "$ARTIFACT" == warpctrl ]]; then UNIVERSAL_BINARY=false OPEN_AFTER_BUNDLE=false FEATURES="$FEATURES,standalone" @@ -372,7 +372,7 @@ fi # using the same feature flags and profile that we would be using in production. if [[ "$CHECK_ONLY" == "true" ]]; then cargo check --profile "$CARGO_PROFILE" --bin "$WARP_BIN" --target "$DEFAULT_TARGET" --features "$FEATURES" - if [[ $UNIVERSAL_BINARY = true && "$ARTIFACT" != "cli" ]]; then + if [[ $UNIVERSAL_BINARY = true && "$ARTIFACT" != "cli" && "$ARTIFACT" != "warpctrl" ]]; then cargo check --profile "$CARGO_PROFILE" --bin "$WARP_BIN" --target "$ADDITIONAL_TARGET" --features "$FEATURES" fi exit 0 @@ -560,10 +560,22 @@ EOF # Make the script executable chmod +x "$CLI_SCRIPT_PATH" + if [[ ",$FEATURES," =~ ",warp_control_cli," ]]; then + WARPCTRL_SCRIPT_PATH="$BUNDLED_RESOURCES_DIR/bin/warpctrl" + echo "Creating warpctrl wrapper script..." + cat > "$WARPCTRL_SCRIPT_PATH" << 'EOF' +#!/bin/bash +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec -a "$0" "$script_dir/../../MacOS/WARP_BIN_PLACEHOLDER" --warpctrl "$@" +EOF + sed -i '' "s/WARP_BIN_PLACEHOLDER/$WARP_BIN/" "$WARPCTRL_SCRIPT_PATH" + chmod +x "$WARPCTRL_SCRIPT_PATH" + fi + # Store the built artifact locations for GitHub Actions outputs. BINARY_PATH="target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN" DMG_PATH="$OUT_DIR/$FINAL_DMG_NAME" -elif [[ "$ARTIFACT" == "cli" ]]; then +elif [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then if [[ $BUILD_BINARY == true ]]; then # Create Info.plist before building the app, since it's embedded at build time. # Apple's codesigning tools will detect Info.plist files in the same directory as an executable. @@ -590,6 +602,22 @@ elif [[ "$ARTIFACT" == "cli" ]]; then echo "Copying binary into $OUT_DIR/$WARP_BIN" cp "target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN" "$OUT_DIR/$WARP_BIN" + if [[ "$ARTIFACT" == "warpctrl" ]]; then + if [[ ! ",$FEATURES," =~ ",warp_control_cli," ]]; then + echo "warpctrl artifact requires the warp_control_cli feature" >&2 + exit 1 + fi + WARPCTRL_SCRIPT_PATH="$OUT_DIR/warpctrl" + echo "Creating warpctrl wrapper script at $WARPCTRL_SCRIPT_PATH" + cat > "$WARPCTRL_SCRIPT_PATH" << 'EOF' +#!/bin/bash +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec -a "$0" "$script_dir/WARP_BIN_PLACEHOLDER" --warpctrl "$@" +EOF + sed -i '' "s/WARP_BIN_PLACEHOLDER/$WARP_BIN/" "$WARPCTRL_SCRIPT_PATH" + chmod +x "$WARPCTRL_SCRIPT_PATH" + fi + if [[ -n "$TARGET_ARCH" && -e "target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN.dSYM" ]]; then echo "Copying .dSYM into $OUT_DIR/$WARP_BIN.dSYM" cp -HR "target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN.dSYM" "$OUT_DIR/" @@ -601,7 +629,11 @@ elif [[ "$ARTIFACT" == "cli" ]]; then # Set the primary binary path to output. - BINARY_PATH="$OUT_DIR/$WARP_BIN" + if [[ "$ARTIFACT" == "warpctrl" ]]; then + BINARY_PATH="$OUT_DIR/warpctrl" + else + BINARY_PATH="$OUT_DIR/$WARP_BIN" + fi else echo "Unsupported artifact: $ARTIFACT" >&2 exit 1 @@ -668,7 +700,7 @@ if [[ $SELFSIGN = true ]]; then if [[ "$ARTIFACT" == app ]]; then echo "Self-signing $BUNDLE_DIR/$WARP_APP_NAME.app with ${SIGNING_CERT}..." codesign --force --deep --options runtime --sign "$SIGNING_CERT" "$BUNDLE_DIR/$WARP_APP_NAME.app" --entitlements script/Debug-Entitlements.plist - elif [[ "$ARTIFACT" == cli ]]; then + elif [[ "$ARTIFACT" == cli || "$ARTIFACT" == warpctrl ]]; then echo "Self-signing $OUT_DIR/$WARP_BIN with ${SIGNING_CERT}..." codesign --force --options runtime --sign "$SIGNING_CERT" "$OUT_DIR/$WARP_BIN" --entitlements script/Debug-Entitlements.plist fi @@ -677,7 +709,7 @@ elif [[ $CODESIGN = true ]]; then echo "Codesigning $BUNDLE_DIR/$WARP_APP_NAME.app..." # Use --deep so we sign bundled frameworks as well codesign --deep -f -o runtime --timestamp -s "$APPLE_TEAM_ID" "$BUNDLE_DIR/$WARP_APP_NAME.app" --entitlements script/Entitlements.plist - elif [[ "$ARTIFACT" == cli ]]; then + elif [[ "$ARTIFACT" == cli || "$ARTIFACT" == warpctrl ]]; then echo "Codesigning $OUT_DIR/$WARP_BIN..." codesign -f -o runtime --timestamp -s "$APPLE_TEAM_ID" "$OUT_DIR/$WARP_BIN" --entitlements script/Entitlements.plist @@ -788,7 +820,7 @@ if [[ $CODESIGN = true ]]; then echo "Verifying notarization ticket..." if [[ "$ARTIFACT" = app ]]; then xcrun stapler validate "$DMG_DIR/$DMG_NAME" - elif [[ "$ARTIFACT" = cli ]]; then + elif [[ "$ARTIFACT" = cli || "$ARTIFACT" = warpctrl ]]; then spctl -a -t open --context context:primary-signature -vv "$OUT_DIR/$WARP_BIN" fi fi diff --git a/script/test_warpctrl_early_dispatch b/script/test_warpctrl_early_dispatch new file mode 100755 index 0000000000..646a293981 --- /dev/null +++ b/script/test_warpctrl_early_dispatch @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -e + +workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +discovery_dir="$(mktemp -d)" +trap 'rm -rf "$discovery_dir"' EXIT + +python3 - "$workspace_root" "$discovery_dir" <<'PY' +import os +import pathlib +import subprocess +import sys + +workspace_root = pathlib.Path(sys.argv[1]) +discovery_dir = sys.argv[2] +environment = os.environ.copy() +environment["WARP_LOCAL_CONTROL_DISCOVERY_DIR"] = discovery_dir +result = subprocess.run( + [ + "cargo", + "run", + "-p", + "warp", + "--bin", + "warp", + "--features", + "warp_control_cli", + "--", + "--warpctrl", + "--output-format", + "json", + "instance", + "list", + ], + cwd=workspace_root, + env=environment, + capture_output=True, + text=True, + timeout=180, +) +if result.returncode != 0: + sys.stderr.write(result.stderr) + sys.stderr.write(result.stdout) + raise SystemExit(result.returncode) +if result.stdout.strip() != "[]": + sys.stderr.write(result.stderr) + sys.stderr.write(f"unexpected warpctrl output: {result.stdout!r}\n") + raise SystemExit(1) +PY diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md new file mode 100644 index 0000000000..487246e7d7 --- /dev/null +++ b/specs/warp-control-cli/PRODUCT.md @@ -0,0 +1,469 @@ +# Summary +Warp should ship an allowlisted local control CLI command, provisionally named `warpctrl`, that acts as an agent control plane for operating Warp itself. `warpctrl` is exposed as an Oz-style wrapper script that invokes the existing channel-specific Warp binary in control mode rather than as a separate standalone binary. `warpctrl` lets agents and developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, terminal blocks, appearance, settings, Warp Drive views, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. +## Problem +Warp already has rich interactive actions, but they are primarily reachable through UI, keybindings, menus, or deeplinks. Agents can use native tools for files, code, shell commands, MCP calls, and many context reads, but they cannot reliably operate Warp's own product surfaces: arranging the user's workspace, focusing the correct pane, opening Warp Drive objects, presenting settings, or recovering from ambiguous UI state. Developers also cannot reliably compose those same actions into shell scripts, demos, automation, or agent workflows, and there is no general local protocol for addressing a specific running Warp instance, window, pane, session, terminal block, Warp Drive object, or other uniquely named Warp entity. +## Goals / Non-goals +Goals: +- Provide a first-class, scriptable `warpctrl` command for controlling running Warp app processes. +- Make Warp's own UI and app state available to agents through a typed, permissioned control plane instead of brittle screen automation or arbitrary internal dispatch. +- Keep CLI startup lightweight by avoiding GUI-app startup or full terminal initialization for routine control commands. +- Keep the surface allowlisted and finite instead of exposing arbitrary internal actions. +- Make targeting explicit and deterministic across multiple Warp processes, windows, tabs, panes, terminal sessions, terminal blocks, Warp Drive objects, files, command surfaces, and other uniquely addressable Warp nouns. +- Support both ergonomic active-target defaults and precise selectors for automation. +- Define a complete protocol/catalog up front, while shipping the implementation incrementally. +Non-goals: +- Replacing the Oz CLI or mixing cloud-agent management into this CLI. +- Exposing every internal app action, debug action, developer-only helper, or privileged state mutation. +- Treating the CLI as a general RPC escape hatch into Warp internals. +- Replacing native agent tools for code editing, file operations, shell execution, web/MCP calls, or attached conversation/block context when those tools already solve the task better. +- Requiring developers or automation to directly invoke the Warp app executable path for ordinary control commands; the packaged `warpctrl` wrapper should hide that implementation detail. +- Requiring the first implementation slice to ship every action in the catalog. +## Primary user stories +These stories define the most compelling product uses for `warpctrl`. The command catalog below is intentionally broader, but the product should prioritize surfaces that agents cannot already operate well through native tools. +1. **Agent workspace orchestration.** When a user asks an agent to work on a task, the agent can inspect the current Warp state, create or reuse an appropriate window/tab layout, split panes, name and focus targets, open relevant Warp surfaces, and leave the workspace in a readable task-shaped state for the user. The agent should continue to use native tools for code edits, file reads/writes, shell execution, MCP calls, and other work that does not require operating Warp's UI or local-control authorization model. +2. **Existing-session debugging and repair.** When a user asks for help with an existing Warp session, the agent can understand Warp-specific UI and session structure before acting: which instance/window/tab/pane/session is active, whether the relevant pane still exists, whether the correct surface is focused, which panels or settings pages are open, and which selector should be used for follow-up actions. The story should focus on UI/session structure, focus, panels, settings, and deterministic targeting; native agent context tools should remain the preferred way to read attached blocks, conversations, and other content when they are available. +3. **Warp Drive creation, navigation, and sharing.** When an agent notices reusable knowledge during normal work, it can help the user turn that knowledge into a Warp Drive object, open it for review, and guide sharing with the right scope. This includes workflows from repeated command sequences, notebooks from task writeups, prompts/rules/facts from user or project preferences, environment variable collections, MCP setup objects, folders, and spaces. Existing object navigation remains important, but creation and sharing are first-class because reusable team knowledge cannot be used until users are guided into creating it. +4. **Deterministic demos and walkthroughs.** When a user, teammate, or go-to-market workflow needs a reliable Warp demo, an agent or script can put Warp into a known presentation state: theme, zoom, windows, tabs, panes, focused targets, panels, command palette/search, and Warp Drive surfaces. The walkthrough can then advance using structured target IDs and recover from stale or missing targets instead of relying on screen coordinates, manual setup, or brittle UI automation. +5. **Personalization, onboarding, and preference migration.** When a user wants Warp to feel familiar, an agent can inspect user-approved settings from tools such as VS Code, iTerm, Ghostty, or shell configuration, propose Warp equivalents, apply allowlisted changes through `warpctrl`, and report unsupported mappings explicitly instead of guessing. The same flow can support team onboarding presets, presentation preferences, accessibility-related settings, themes, font and zoom, keybindings, notifications, and panels. +Human power-user scripting is a secondary beneficiary of the same design. Scripts get reliable JSON, target selectors, structured errors, and exact-action credentials because the API is strong enough for agents, but the primary product narrative remains agent-led operation of Warp itself. +Persistent settings changes, Warp Drive creation or sharing, cross-app preference migration, terminal command execution, and other actions with durable or external effects must be visibly reviewable or require explicit action-specific authorization. `warpctrl` should support full typed control over time, but each command must be progressively unlocked through exact-action grants, deterministic target resolution, Agent Profile policy, Scripting settings, authenticated-user requirements, and action-specific approval rather than broad unchecked authority. +## Behavior +1. The Warp control CLI operates only on running local Warp app processes. If no compatible Warp process is available, the CLI exits non-zero with a clear “no running Warp instance found” error. +2. The CLI exposes only explicitly allowlisted actions. Protocol-level unknown action names, unsupported local-control parameter combinations, or requests for non-allowlisted capabilities fail with structured local-control errors; they are never forwarded to arbitrary internal dispatch. Clap parser usage errors, such as an unknown CLI subcommand or invalid flag syntax, may use the parser's normal CLI error behavior unless a later branch explicitly wraps them. +3. Every successful mutating request identifies: + - The Warp process instance that executed it. + - The resolved target, when the action addresses a window, tab, pane, terminal session, terminal block, file, Warp Drive object, surface, or other targetable noun. + - A success payload suitable for JSON output. +4. Every protocol or runtime local-control failure identifies: + - A stable machine-readable error code. + - A human-readable explanation. + - Any selector that was ambiguous, missing, stale, unsupported, or invalid. +5. The CLI supports human-readable output by default and JSON output for scripts. JSON output has stable field names and is available for discovery commands, read commands, successful mutations, and protocol or runtime local-control failures. +6. The CLI supports process discovery and instance selection: + - `warpctrl instance list` returns all reachable local Warp app processes that support the protocol. + - Each process has an opaque `instance_id`, a channel/build identity, and enough display metadata for a developer to choose it. + - If exactly one compatible process is available, commands may target it implicitly. + - If multiple compatible processes are available, the CLI may select a single clearly active/frontmost instance when that state is unambiguous; otherwise it fails and asks the developer to pass an explicit instance selector. + - Developers can explicitly choose an instance by opaque instance ID. Channel or PID filters may be offered as convenience filters, but opaque instance ID is the canonical selector. +7. The CLI supports introspection for target discovery: + - `warpctrl window list` + - `warpctrl tab list` + - `warpctrl pane list` + - `warpctrl session list` + - `warpctrl block list` + - `warpctrl drive list` + - `warpctrl app active` + These commands return opaque protocol-facing IDs and enough metadata for subsequent commands without requiring knowledge of internal Warp identifiers. +8. The target selector model is hierarchical: + - Instance selector resolves a running Warp process. + - Window selector resolves within the instance. + - Tab selector resolves within the window. + - Pane selector resolves within the tab or active pane group context. + - Session selector resolves within the pane when the pane hosts terminal session state. + - Block selector resolves within the terminal session when the command is block-scoped. + Non-hierarchical selectors such as file paths, Warp Drive objects, and global app surfaces still resolve inside the selected instance and must not silently borrow lower-level pane/session defaults unless the action definition explicitly requires that scope. +9. Every selector family supports an ergonomic `active` form when that concept exists: + - Active instance, if unambiguous. + - Active window in the selected instance. + - Active tab in the selected window. + - Active pane in the selected tab. + - Active session in the selected pane. + - Active or selected terminal block in the selected session when a current block is unambiguous. + For window-scoped mutations, an omitted or active window selector may fall back to the sole existing window when no active window is reported, because that target is still unambiguous. If there are no windows, the request fails with `missing_target`; if multiple windows exist and none is active, it fails with `ambiguous_target`. +10. Every selector family supports explicit opaque IDs returned by introspection. Selector families may also support scoped indices, titles/names, or paths where those concepts are already user-visible, but IDs remain the preferred automation surface. + - Window selectors support `active`, opaque window IDs, window indices from `window list`, and exact window titles for interactive use. + - Tab selectors support `active`, opaque tab IDs, tab indices scoped to the resolved window, and exact tab titles for interactive use. + - Pane selectors support `active`, opaque pane IDs, and pane indices scoped to the resolved tab or pane group. + - Session selectors support `active`, opaque session IDs, and session indices scoped to the resolved pane when sessions are user-visible as an ordered list. + - Block selectors support `active`, opaque block IDs, and block indices scoped to the resolved terminal session when blocks are user-visible as an ordered list. A block command may also support read-only filters such as command text, status, time range, or “last completed” for interactive lookup, but those filters must fail on ambiguity and resolve to concrete block IDs before reading output. + - File selectors use paths, plus optional line/column coordinates where the command supports opening a location. + - Warp Drive selectors use opaque object IDs, with optional type-scoped exact name/path lookups for interactive use. Type scopes must include the user-facing object families Warp exposes today: spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries when trash operations are supported. +11. “Active session” means the currently selected terminal session for the resolved pane/window context. If the selected target does not contain a terminal session, session-scoped actions fail rather than silently redirecting elsewhere. +12. When a command omits lower-level selectors, it resolves them from the chosen higher-level context using active defaults. Example: a pane split command with only `--instance` uses that instance’s active window, active tab, and active pane. +13. When an explicitly supplied target disappears between discovery and execution, the request fails with a stale-target error. The CLI must not silently choose a different tab, pane, or session. +14. The protocol is command-oriented, not open-ended state mutation. Each action has a named command, validated parameters, and defined target scope. +15. The complete allowlisted action catalog should be organized around stable public nouns rather than internal view/action names. The target taxonomy includes instances, windows, tabs, panes, terminal sessions, terminal blocks, input buffers, command history entries, file/path intents, Warp Drive spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, settings, themes, keybindings, command surfaces such as the command palette and command search, panels/surfaces such as Warp Drive, resource center, AI assistant, code review, left/right panels, and vertical tabs, plus action/capability metadata. The initial implementation may expose only a subset, but new command families should extend this noun taxonomy instead of inventing unrelated selector conventions. +16. Discovery and read-only state actions: + - List instances. + - Get protocol and build identity metadata for one instance. + - List windows, tabs, panes, and sessions. + - Get the currently active instance/window/tab/pane/session chain when available. + - Inspect enough target metadata to let a script decide what to address next. +17. Window actions: + - Create a new window. + - Focus a target window. + - Close a target window. +18. Tab actions: + - Create a new terminal tab. + - Create a new agent tab where that is already a user-visible app capability. + - Activate a target tab. + - Activate previous, next, or last tab. + - Move a target tab left or right. + - Rename or reset a tab title. + - Set or clear active-tab color where that is already supported in the UI. + - Close the active tab, a target tab, other tabs, or tabs to the right of a target tab. +19. Pane actions: + - Split a target pane left, right, up, or down. + - Optionally choose the shell/session profile for split operations when that already maps to user-facing behavior. + - Focus a target pane. + - Navigate focus left, right, up, or down among panes. + - Close a target pane. + - Toggle maximize for a target pane. + - Resize pane dividers left, right, up, or down when that is supported by the app. +20. Session and terminal-input actions: + - Cycle to the previous or next session where the app exposes session cycling. + - Insert text into the active input without executing it. + - Replace the active input buffer. + - Clear the active input buffer where that matches existing user behavior. + - Switch input mode between terminal and agent modes only where that mode switch is already user-visible and valid for the selected target. + Input staging commands must not submit terminal input or press Enter. The separate `input run` execution action may submit a command only in the later execution-underlying branch, after authenticated scripting identity, an exact `input.run` grant, approval or configured policy, audit coverage, and explicit target resolution are implemented. Accepted-command submission and agent-prompt submission remain future protocol concepts that require separate product/security review. +21. Appearance actions: + - List available themes. + - Set the current fixed theme. + - Toggle or set “follow system theme.” + - Set the light and dark themes used when following the system theme. + - Increase, decrease, or reset font size. + - Increase, decrease, or reset UI zoom. + - Set other allowlisted appearance controls only when they correspond to stable user-facing controls. +22. Settings actions: + - Read allowlisted user-facing settings. + - Set allowlisted settings to validated values. + - Toggle allowlisted boolean settings. + - Reject attempts to mutate private, debug-only, unsafe, derived, or unsupported settings. + - Return a stable error when a named setting exists internally but is not part of the public local-control allowlist. +23. The settings allowlist should initially cover settings families that are already plainly user-facing and valuable for scripting: + - Theme/system-theme configuration. + - Font/zoom-related controls. + - Notifications. + - Syntax highlighting and error-underlining toggles. + - Accessibility verbosity where exposed to users. + - Selected panel/layout toggles when the user-facing behavior is already stable. + Additional settings families can be added only by extending the allowlist. +24. Panel and surface actions: + - Open the general settings surface. + - Open a specific settings page or settings search result. + - Open or toggle the command palette with an optional initial query where the app already supports query seeding. + - Open or toggle command search where that is already user-visible. + - Toggle or open the left panel, Warp Drive surface, right panel, resource center, AI assistant panel, code review panel, and vertical tabs panel where valid. +25. File/path intent actions may be included when they already mirror existing user-visible deep-link behavior: + - Open a path in a new tab or window. + - Open a repository picker or repo path flow where the current app already supports it. + These should remain allowlisted intent actions rather than arbitrary filesystem RPCs. +26. The following actions are explicitly excluded from the public allowlist even when internal implementations exist: + - Crash, panic, heap-dump, token-copying, debug-reset, and similar developer/debug helpers. + - Arbitrary auth manipulation outside the explicit authenticated-scripting flows. + - Arbitrary cloud object mutation or broad Warp Drive CRUD outside the typed Drive actions in this spec. + - Arbitrary internal view dispatch by string. + - Arbitrary setting names outside the allowlist. + - Accepted-command submission and agent-prompt submission until they receive a separate product/security review. + Terminal command execution and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user, approval, targeting, and audit requirements than ordinary UI actions. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are excluded from the public `warpctrl` catalog; file/path support is limited to app-state intents such as opening a path in Warp and metadata reads of files already open in Warp. +27. CLI command names should be noun-oriented and discoverable. During the provisional wrapper-script phase, the control CLI should expose a `warpctrl ...` command surface: + - `warpctrl instance list` + - `warpctrl app ping` + - `warpctrl app version` + - `warpctrl app active` + - `warpctrl tab create` + - `warpctrl tab rename --window-id --tab-id "Build logs"` + - `warpctrl tab rename --window active --tab-index 0 "Build logs"` + - `warpctrl window close --window-title "Scratch"` + - `warpctrl pane split --direction right` + - `warpctrl pane split --instance --window active --pane active --direction right` + - `warpctrl input replace --session-id "cargo check"` + - `warpctrl block output --pane-id --block-id --plain` + - `warpctrl theme set "Warp Dark"` + - `warpctrl setting set appearance.themes.system_theme true` + - `warpctrl input insert "cargo check" --replace` + Channelized install names or aliases may vary during packaging. If the product later converges on `warp ...`, update packaging, shell completions, and operator docs together. +28. The wire protocol mirrors the CLI model. A mutating request contains: + - An action name from the allowlist. + - A structured target selector. + - Validated parameters. + A response contains: + - Success/failure status. + - Resolved instance and target metadata. + - Result data or structured error data. +29. The protocol is versioned. Clients must be able to determine whether a running Warp process supports the protocol version and action they intend to call. +30. Multiple running Warp processes are a supported normal case, not an error state. A local stable build and local dev build, or multiple supported local app instances, can coexist; the CLI provides deterministic discovery and addressing rather than assuming one global server. +31. Requests should be scoped to local-user control of the running app, with separate enforcement for actions that require a true logged-in Warp user. A command that fails local authentication, local authorization, execution-context checks, or authenticated-user checks reports that condition explicitly and does not degrade into a less-specific request. +32. If a selected action is valid in general but impossible in the current UI state, the CLI reports a state-specific failure. Examples include: + - Splitting a pane that no longer exists. + - Issuing a session-scoped action against a non-terminal pane. + - Focusing a window that has closed. + - Setting a theme that is not available in that instance. +33. The first `warpctrl` implementation slice should ship the smallest end-to-end vertical slice that proves: + - The current implementation supports outside-Warp local-control requests only; verified inside-Warp requests are specified for future work and rejected until the app-issued terminal proof broker exists. + - The authoritative local-control mode is read only from protected storage, never imported from ordinary or private preferences, and defaults to disabled when no valid protected value is available. + - Process discovery and target resolution work. + - The wrapper-script command can reach a running local Warp process through the existing Warp binary's early control-mode dispatch without launching or initializing the GUI app. + - `warpctrl tab create` creates a new terminal tab in the selected running instance. + - The command returns a structured success or failure payload suitable for human-readable and JSON output. + The first slice should include the minimum health/introspection commands needed to discover a running instance and exercise `tab.create`. +34. Follow-up PRs should fill out the remaining catalog in parallelizable groups once the protocol, discovery model, target resolution, error model, `tab.create` action path, and wrapper-script `warpctrl` packaging shape have been validated by the first slice. +35. The protocol transport should be designed so that the default target is localhost but the CLI can be extended in the future to target remote URLs (e.g., a Warp instance on another machine or a hosted control endpoint). This is not in scope for the first implementation but should not be precluded by the architecture. +## API command surface +The public `warpctrl` API is organized around nouns that map to stable user-facing entities. Command names are intentionally not a dump of every internal `WorkspaceAction`, `TerminalAction`, keybinding, or command-palette binding. Internal actions inform the catalog, but a command is added only when it has a stable user-facing behavior, typed parameters, deterministic target resolution, and explicit authorization requirements. +Catalog support status is part of the public API contract. An action reported as `implemented` by `warpctrl action list --implemented-only`, `warpctrl capability list --implemented-only`, or app discovery metadata must be reachable through the wrapper-backed `warpctrl ...` parser route, represented in generated help/completions/docs, and backed by an app-side bridge handler in the selected app build. Planned actions without that complete path must be reported as stubs or planned entries, even if an internal app handler already exists. +### Direct action requirements +Every public action must declare the policy inputs that the broker and app bridge actually enforce: stable action identity, implementation status, authenticated-user requirement, allowed invocation contexts, target scope, typed parameters, and typed result. Credentials authorize one exact action; authority for one action never implies authority for another action, even when the actions have similar effects. +Sensitive actions carry stronger requirements directly. Actions that expose terminal output or other user content may require authenticated-user access or explicit approval. Actions that execute commands, mutate or share Warp Drive objects, change persistent settings, or cause external side effects require the identity, invocation context, target restrictions, approval, and audit coverage specified for that exact action. Opening or focusing Warp UI must never imply authority to execute commands or mutate user data. +### Targeting flags +The full product should converge on shared selector flags for every command that addresses a running app target. The current foundation branch is not required to expose that complete CLI grammar yet: it supports instance selection with `--instance` and `--pid` for the implemented commands, while the shared window/tab/pane/session/block selector flags are deferred to the later target-selector branch that implements those target families. When the shared grammar ships, generic `--window`, `--tab`, `--pane`, `--session`, and `--block` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: +- `--instance ` selects a running Warp process from `warpctrl instance list`. +- `--pid ` is a convenience instance selector and conflicts with `--instance`. +- `--window |index:|title:>` selects a window inside the instance. +- `--window-id <id>`, `--window-index <n>`, and `--window-title <title>` are exact aliases for the corresponding `--window ...` forms. +- `--tab <active|id:<id>|index:<n>|title:<title>>` selects a tab inside the resolved window. +- `--tab-id <id>`, `--tab-index <n>`, and `--tab-title <title>` are exact aliases for the corresponding `--tab ...` forms. +- `--pane <active|id:<id>|index:<n>>` selects a pane inside the resolved tab or pane-group context. +- `--pane-id <id>` and `--pane-index <n>` are exact aliases for the corresponding `--pane ...` forms. +- `--session <active|id:<id>|index:<n>>` selects a terminal or agent session inside the resolved pane when the command is session-scoped. +- `--session-id <id>` and `--session-index <n>` are exact aliases for the corresponding `--session ...` forms. +- `--block <active|id:<id>|index:<n>>` selects a terminal block inside the resolved terminal session when the command is block-scoped. +- `--block-id <id>` and `--block-index <n>` are exact aliases for the corresponding `--block ...` forms. +- File commands use path arguments or `--path <path>` where the path is the selected file entity; `--line <n>` and `--column <n>` refine the location when supported. +- Drive commands use object ID arguments or `--drive-id <id>` where the ID is the selected Warp Drive entity; name/path lookup must be type-scoped when supported. +- `--output-format <pretty|json|ndjson|text>` controls output shape and remains globally available. +Within a selector family, specifying more than one form is invalid. For example, `--tab-id` conflicts with `--tab-index`, `--tab-title`, and `--tab`. Omitted lower-level selectors use active defaults only when the resolved target is unambiguous; window-scoped mutations may use the sole existing window when no active window is reported. Explicit IDs must resolve exactly or fail with `stale_target`; index/title/name/path selectors that match zero targets fail with `missing_target`, and selectors that match multiple targets fail with `ambiguous_target`. +### Read-only command set +The read-only v2 follow-up branches should implement the following commands before mutating catalog expansion begins. `zach/warp-cli-v2/readonly-capability-targets` owns structural metadata and targeting, while `zach/warp-cli-v2/appstate-file-drive-views` owns approved underlying-data reads and app/file/Drive view surfaces. Read-only actions remain independently authorized: a credential for structural metadata does not authorize terminal output, input buffers, history, Drive content, or any other action. +Metadata and capability reads: +- `warpctrl instance list` +- `warpctrl instance inspect [--instance <id>|--pid <pid>]` +- `warpctrl app ping [selectors]` +- `warpctrl app version [selectors]` +- `warpctrl app active [selectors]` +- `warpctrl capability list [selectors]` +- `warpctrl capability inspect <action> [selectors]` +Window, tab, pane, and session reads: +- `warpctrl window list [selectors]` +- `warpctrl window inspect [--window <selector>] [selectors]` +- `warpctrl tab list [--window <selector>] [selectors]` +- `warpctrl tab inspect [--tab <selector>] [selectors]` +- `warpctrl pane list [--tab <selector>] [selectors]` +- `warpctrl pane inspect [--pane <selector>] [selectors]` +- `warpctrl session list [--pane <selector>] [selectors]` +- `warpctrl session inspect [--session <selector>] [selectors]` +Content-bearing reads, each gated by its own exact-action grant: +- `warpctrl block list [--session <selector>|--pane <selector>] [--limit <n>] [selectors]` +- `warpctrl block inspect --block <selector> [selectors]` +- `warpctrl block output --block <selector> [--plain|--ansi|--json] [selectors]` +- `warpctrl input get [--session <selector>] [selectors]` +- `warpctrl history list [--session <selector>] [--limit <n>] [selectors]` +Appearance, settings, and command-surface reads: +- `warpctrl theme list [selectors]` +- `warpctrl theme get [selectors]` +- `warpctrl appearance get [selectors]` +- `warpctrl setting list [--namespace <namespace>] [selectors]` +- `warpctrl setting get <key> [selectors]` +- `warpctrl keybinding list [selectors]` +- `warpctrl keybinding get <binding_name> [selectors]` +- `warpctrl action list [selectors]` +- `warpctrl action inspect <action> [selectors]` +Local file reads that expose only app/editor state, not arbitrary filesystem traversal: +- `warpctrl file list [selectors]` +Authenticated read-only Warp Drive metadata and data reads, enabled only when the selected app has a logged-in Warp user and the grant allows authenticated reads. Listing is metadata; inspecting object content is an underlying data read: +- `warpctrl drive list --type <workflow|notebook|env-var-collection|prompt|folder|ai-fact|mcp-server|space|trash> [selectors]` +- `warpctrl drive inspect <id> [selectors]` +### Authenticated scripting command set +Authenticated actions in the selected public contract are available only to verified Warp-terminal invocations. `warpctrl` presents the app-issued terminal proof described in `TECH.md` and may receive authenticated-user grants only when the selected app is logged into Warp and Settings > Scripting allows authenticated actions from verified Warp terminals. External API-key authenticated scripting and `auth.api_key.*` commands are not allowlisted; adding them requires a separate product/security review and catalog change. +Recommended CLI surface for app-backed authenticated status: +- `warpctrl auth status [selectors]` reports local-control auth state, selected app login state, and verified Warp-terminal authenticated grant availability. +- `warpctrl auth login [selectors]` focuses the selected Warp app's sign-in UI for interactive app-login flows. +### Mutating command set +The mutating v2 follow-up branches should build on the shared contract, auth/security, read-only, and targeting layers. `zach/warp-cli-v2/metadata-config-mutations` owns metadata/configuration mutations, `zach/warp-cli-v2/drive-data-mutations` owns Warp Drive underlying-data mutations, and `zach/warp-cli-v2/execution-underlying` owns terminal command execution and other approved execution-underlying actions. Approved app-state mutations and views land in the earliest v2 branch that owns their required targeting and direct policy prerequisites. Every mutating command requires its own exact-action grant. Commands that mutate user data, execute code, or cause external side effects additionally require authenticated scripting identity, explicit approval or configured action policy, deterministic targets, and audit coverage. +App-state mutations for app, window, and surfaces: +- `warpctrl app focus [selectors]` +- `warpctrl window create [--shell <name>] [selectors]` +- `warpctrl window focus --window <selector> [selectors]` +- `warpctrl window close --window <selector> [selectors]` +- `warpctrl surface settings open [--page <page>] [--query <query>] [selectors]` +- `warpctrl surface command-palette open [--query <query>] [selectors]` +- `warpctrl surface command-search open [--query <query>] [selectors]` +- `warpctrl surface warp-drive open [selectors]` +- `warpctrl surface warp-drive toggle [selectors]` +- `warpctrl surface resource-center toggle [selectors]` +- `warpctrl surface ai-assistant toggle [selectors]` +- `warpctrl surface code-review toggle [selectors]` +- `warpctrl surface left-panel toggle [selectors]` +- `warpctrl surface right-panel toggle [selectors]` +- `warpctrl surface vertical-tabs toggle [selectors]` +App-state mutations for tabs: +- `warpctrl tab create [--type terminal|agent|cloud-agent|default] [--shell <name>] [selectors]` +- `warpctrl tab activate --tab <selector> [selectors]` +- `warpctrl tab activate --previous [selectors]` +- `warpctrl tab activate --next [selectors]` +- `warpctrl tab activate --last [selectors]` +- `warpctrl tab move --tab <selector> --direction <left|right> [selectors]` +- `warpctrl tab close --tab <selector> [selectors]` +- `warpctrl tab close --active [selectors]` +- `warpctrl tab close --others --tab <selector> [selectors]` +- `warpctrl tab close --right-of --tab <selector> [selectors]` +Metadata mutations for tabs: +- `warpctrl tab rename --tab <selector> <title> [selectors]` +- `warpctrl tab reset-name --tab <selector> [selectors]` +- `warpctrl tab color set --tab <selector> <color> [selectors]` +- `warpctrl tab color clear --tab <selector> [selectors]` +App-state mutations for panes: +- `warpctrl pane split --direction <left|right|up|down> [--shell <name>] [selectors]` +- `warpctrl pane focus --pane <selector> [selectors]` +- `warpctrl pane navigate --direction <left|right|up|down|previous|next> [selectors]` +- `warpctrl pane resize --direction <left|right|up|down> [--amount <cells>] [selectors]` +- `warpctrl pane maximize [--pane <selector>] [selectors]` +- `warpctrl pane unmaximize [selectors]` +- `warpctrl pane close --pane <selector> [selectors]` +Metadata mutations for panes: +- `warpctrl pane rename --pane <selector> <title> [selectors]` +- `warpctrl pane reset-name --pane <selector> [selectors]` +App-state mutations for sessions and input buffers: +- `warpctrl session activate --session <selector> [selectors]` +- `warpctrl session previous [selectors]` +- `warpctrl session next [selectors]` +- `warpctrl session reopen-closed [selectors]` +- `warpctrl input insert <text> [--session <selector>] [selectors]` +- `warpctrl input replace <text> [--session <selector>] [selectors]` +- `warpctrl input clear [--session <selector>] [selectors]` +- `warpctrl input mode set <terminal|agent> [--session <selector>] [selectors]` +These input-buffer commands only stage or edit text and must not submit the buffer. The separate `input run` command belongs only to the execution-underlying branch and requires authenticated scripting identity, an exact `input.run` grant, explicit target resolution, approval or configured policy, and audit coverage. Accepted-command submission and agent-prompt submission remain excluded until separately reviewed. +Metadata/configuration mutations for appearance and settings: +- `warpctrl theme set <theme_name> [selectors]` +- `warpctrl theme system set <true|false> [selectors]` +- `warpctrl theme light set <theme_name> [selectors]` +- `warpctrl theme dark set <theme_name> [selectors]` +- `warpctrl appearance font-size increase [selectors]` +- `warpctrl appearance font-size decrease [selectors]` +- `warpctrl appearance font-size reset [selectors]` +- `warpctrl appearance zoom increase [selectors]` +- `warpctrl appearance zoom decrease [selectors]` +- `warpctrl appearance zoom reset [selectors]` +- `warpctrl setting set <key> <value> [selectors]` +- `warpctrl setting toggle <key> [selectors]` +App-state mutations for files and Warp Drive views: +- `warpctrl file open <path> [--line <line>] [--column <column>] [--new-tab] [selectors]` +- `warpctrl drive open <id> [selectors]` +- `warpctrl drive notebook open <id> [selectors]` +- `warpctrl drive env-var-collection open <id> [selectors]` +- `warpctrl drive object share open <id> [selectors]` +Underlying data mutations for authenticated Warp Drive objects: +- `warpctrl drive object create --type <workflow|notebook|env-var-collection|prompt|folder> [--content <text>|--content-file <path>] [selectors]` +- `warpctrl drive object update <id> [--content <text>|--content-file <path>] [selectors]` +- `warpctrl drive object delete <id> [selectors]` +- `warpctrl drive object insert <id> [--target <selector>] [selectors]` +- `warpctrl drive object share-to-team <id> [selectors]` +- `warpctrl drive workflow run <id> [--arg <name=value>...] [selectors]` +Execution-underlying actions: +- `warpctrl input run <command> [--session <selector>] [selectors]` +These are underlying-data mutations because they can modify user data, execute code, trigger external side effects, share cloud-backed content, or run user-authored content. They require authenticated scripting identity, exact-action grants, deterministic target resolution, approval or configured policy, audit records, and explicit tests proving credentials for other actions cannot run them. `drive object share-to-team` is the only direct sharing mutation in the v0 product scope: it may make a personal Warp Drive object available to the user's current team using the app's standard team-sharing semantics. Arbitrary ACL editing, sharing with specific users, sharing with external guests, public-link creation, accepted-command submission, and agent-prompt submission remain excluded until separately reviewed. +### Excluded from the public command surface +The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. +## Branch stacking and delivery model +The Warp Control CLI work should ship as the active raw-git v2 branch stack so the shared contract, security enforcement, read-only expansion, mutating expansion, and final integration remain reviewable independently: +- `zach/warp-cli-v2/contract-spec-sync` is the bottom review branch and targets `master`. It exclusively owns the product, technical, security, and operator specs plus the shared contract/foundation and minimum first-slice smoke path. +- `zach/warp-cli-v2/auth-security` stacks on the contract branch and owns authentication and security enforcement shared across command families. +- `zach/warp-cli-v2/readonly-capability-targets` stacks on auth/security and owns structural metadata, capability/action metadata, selectors, opaque IDs, and read-only target resolution. +- `zach/warp-cli-v2/appstate-file-drive-views` stacks on read-only targeting and owns approved app-state, file-view, Drive-view, and underlying-data-read surfaces without adding local filesystem content operations. +- `zach/warp-cli-v2/metadata-config-mutations` stacks on the approved view/read surfaces and owns allowlisted metadata and configuration mutations. +- `zach/warp-cli-v2/drive-data-mutations` stacks on metadata/configuration mutations and owns authenticated Warp Drive underlying-data mutations. +- `zach/warp-cli-v2/execution-underlying` stacks on Drive mutations and owns authenticated execution-underlying actions. +- `zach/warp-cli-v2/cli-catalog-docs` stacks on the action-family branches and owns final CLI, catalog, completion, documentation, and action-review consistency. +- `zach/warp-cli-v2/fanin-finalize` is the final integration branch used for end-to-end validation and review handoff. +Older pre-recovery branch names are historical source material only and must not be used as the active PR stack. New spec changes originate on `zach/warp-cli-v2/contract-spec-sync` and are propagated upward through raw-git rebases so all higher v2 branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. +## Built-in Warp Agent skill +Warp should include a built-in Agent skill for `warpctrl`, analogous to the bundled `oz-platform` skill. The skill should teach Warp Agent when to use `warpctrl`, how to discover and target running instances, how to prefer read-only commands before mutating commands, how to request explicit user approval for underlying data mutations, and how to interpret structured errors. The skill should document the stable command hierarchy above, include concise recipes for common automation tasks, and avoid instructing agents to bypass the CLI by calling local-control HTTP endpoints directly. +## CLI implementation and documentation conventions +`warpctrl` should feel consistent with the Oz CLI from a developer's perspective and use the same CLI libraries and conventions: +- Argument parsing, subcommand structure, help text, and shell-completion generation should use the same `clap`/`clap_complete` patterns used by the Oz CLI. +- JSON serialization and machine-readable output should use the same `serde`/`serde_json` conventions and the same output-format vocabulary used by the Oz CLI. +- Human-readable help, examples, errors, and generated completions should follow Oz CLI conventions unless Warp Control has a documented product reason to differ. +CLI documentation should be generated from the command catalog instead of maintained by hand in multiple places: +- The typed action catalog is the source of truth for command names, selector flags, parameters, output formats, authenticated-user requirement, allowed invocation contexts, target scope, support status, and examples. +- `warpctrl help`, shell completions, markdown reference docs, the built-in Warp Agent skill, and the operator README should be generated or checked from that catalog so they cannot drift silently. +- A later branch should add native Warp completions for `warpctrl` in addition to shell completions so Warp can suggest commands, flags, selectors, and action names directly in the input editor. +- Generated documentation must distinguish implemented commands from planned catalog entries. A command may appear in specs as planned, but public operator docs must not imply it is usable until the selected app build advertises support for it. +- CI or presubmit checks should fail when CLI parser/help output, generated reference docs, completions, or the built-in skill are stale relative to the command catalog. +## Exact-action authorization model +Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. Every credential therefore authorizes one exact typed action, and the app bridge verifies that exact action before selector resolution or handler dispatch. Similar actions do not inherit authority from one another. +Every action definition must include: +- a stable action name and namespace; +- whether a true logged-in Warp user is required; +- whether the action may run from external clients, verified Warp-terminal clients, or both; +- whether inside-Warp and outside-Warp scripting settings can enable the action; +- any target-scope restrictions; +- typed parameter and result contracts; +- any action-specific approval, audit, or policy requirements. +By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. Actions that expose sensitive content, mutate durable data, execute code, or cause external effects require stronger conditions attached directly to the action. +### Authenticated scripting model +Authenticated scripting is required for any command that acts on a true Warp user identity or performs underlying-data mutation. Local-control credentials prove that a process may talk to the selected app; authenticated scripting credentials prove which logged-in Warp user is allowed to request user-backed or high-risk actions. +Inside Warp, authenticated scripting uses the verified terminal proof flow: the selected app is already logged in, the terminal proof binds the CLI to a live Warp-managed session, and the broker may mint an authenticated-user grant for that app user when Settings > Scripting allows it. +Outside-Warp invocations are limited to actions explicitly classified as logged-out-safe. External authenticated scripting is not part of the selected public contract. +### Authenticated-user requirement +An authenticated user means a true logged-in Warp user in the selected Warp app, not merely the local OS user or a `warpctrl` process authenticated to localhost. +The allowlist must clearly indicate `requires_authenticated_user` for every action: +- `false` only for logged-out-safe actions that operate on local app structure, local appearance metadata, or local-only settings that do not expose user-sensitive data. +- `true` for actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, user identity data, or any cloud-backed Warp state. +- `true` for actions that execute user-authored Warp Drive content, even if the execution target is a local terminal session. +If an authenticated-user action is invoked while the selected app has no logged-in user, the CLI reports a structured authenticated-user error. It must not silently return partial logged-out data as success. +### Warp Control authenticated scripting protocol +Authenticated scripting relies on the logged-in user in the selected Warp app and verified terminal proof. The CLI should expose app-backed auth/status flows: +- `warpctrl auth status [selectors]` reports whether the selected Warp app is logged in and returns a stable, non-secret user subject/identity summary when the caller has the required local-control grant. +- `warpctrl auth login [selectors]` does not collect credentials in the CLI or mint a separate CLI account session. It focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with instructions, until the user completes sign-in in that app. +- After app login completes, the app-side credential broker may mint an app-user grant only for the same user subject that is currently logged in to the selected app and a verified Warp-terminal invocation. +- Authenticated credentials are bound to the selected app instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses auth state, or the grant's subject no longer matches a grant that requires the selected app's logged-in subject, authenticated actions fail with a structured authenticated-user error rather than using stale authority. +- Raw Firebase, server, OAuth, and cloud API tokens are never exported to `warpctrl` output, shell scripts, generated docs, logs, discovery records, or JSON responses. +This authenticated scripting protocol applies only to actions whose allowlist entry requires a true logged-in Warp user or underlying-data-mutation authority. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login. +### Execution context policy +`warpctrl` should eventually distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. The current foundation branch implements the setting shape for both contexts, supports external invocation only when the user explicitly enables the broadest mode, and must reject verified Warp-terminal claims until the proof broker is implemented. +- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. This is allowed when the user selects **Enabled within Warp** or the broadest mode after the proof broker exists; the default disabled mode blocks it. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the selected mode allows the context and the action's catalog policy allows that grant. +- **External invocation:** a `warpctrl` process started outside Warp's terminal, such as from another terminal app, launch agent, IDE, or background script. This is allowed only by **Enabled everywhere, including outside Warp**. When disabled for the selected mode, external invocations receive no local-control credentials, including logged-out-safe metadata credentials. +- The app must not trust a caller-declared label. Environment variables may help discover the context, but the broker must verify a session-bound capability or equivalent proof before issuing in-Warp-only grants. +### Settings surface +Warp should add a new top-level Settings pane page named **Scripting**. This page should own settings for local scripting and automation surfaces, including Warp control. Warp control should be represented as a single private, local-only mode setting with three choices: +- **Disabled:** default. No local-control invocation context can receive credentials. +- **Enabled within Warp:** allows only verified Warp-managed terminal invocations once the proof broker exists. In the current foundation branch, inside-Warp proof verification is not implemented yet, so requests in this mode are rejected rather than silently treated as external. +- **Enabled everywhere, including outside Warp:** allows verified Warp-managed terminal invocations and external local clients such as other terminals, scripts, IDEs, launch agents, and same-user automation to request local-control credentials. +The Scripting page should explain that the default mode blocks local-control credentials, the within-Warp mode is reserved for verified Warp-managed terminals once proof support lands, and the broadest mode allows other local apps and scripts to talk to Warp's control plane. Changing the mode should invalidate or prevent credentials for invocation contexts no longer allowed by the selected mode. +### Local-control action policy +The Scripting settings page should not expose separate risk-group toggles in the foundation stack. The single mode setting defines which invocation contexts may request credentials. For every request, the broker and app bridge still enforce the exact granted action, authenticated-user requirement, execution-context requirement, target restrictions, and any action-specific approval or audit policy. Enabling the broadest mode must not imply permission to run a different action or bypass authenticated scripting identity, logged-in user state, or future review. +### Agent Profile permissions +Agent Profiles should expose a dedicated **Warp control** permission group for agents that can invoke `warpctrl`. Profile policy should evaluate the requested exact action using the same autonomy vocabulary used by other Agent Profile permissions: allow, ask, let the agent decide based on confidence and risk, or deny. Profiles may offer curated UI groupings for usability, but those groupings must not become credential scopes or allow one approved action to authorize another. +Agent Profile permissions and global Scripting settings both apply. Settings > Scripting defines which invocation contexts may request local-control credentials. The selected Agent Profile determines whether that agent may request the specific action within that maximum. If either layer denies the action, authenticated-user requirement, or execution context, the request fails with a structured permission error instead of falling back to a weaker action or a raw `warpctrl` shell command. +The profile-level permission group should preserve the native-tools-first boundary. Agents should prefer native tools for code editing, file reads/writes, shell command execution, web/MCP calls, and attached conversation or block context when those tools are available. Agents should prefer `warpctrl` when the task requires operating Warp product surfaces, preserving visible UI context for the user, using Warp Drive as a first-class app surface, or applying the app's own permissioned control plane. +### Exact-action credentials +The local discovery record must not expose a reusable full-access credential. `warpctrl` requests a short-lived credential for the one typed action it is about to invoke from an app-owned broker or equivalent trusted path. +Exact-action credentials include: +- the selected Warp instance; +- the granted `ActionKind`; +- verified execution context; +- whether authenticated-user access is granted and for which logged-in user subject; +- optional target scopes; +- issuance and expiry metadata; +- revocation/audit identity. +The bridge, not the CLI frontend, enforces these grants. If a request presents a credential for a different action or otherwise exceeds its authority, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. +### Future entity extensibility: files, blocks, and Warp Drive objects +The selector and action model should accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **file/path intents**, and **Warp Drive objects**. Broad Drive mutation and command execution are not in scope for the foundation branch, but they are in scope for later authenticated branches in the expanded stack. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are intentionally out of scope for the public `warpctrl` catalog because native agent file tools are the preferred surface for file content operations. Agent-prompt submission remains excluded until separately reviewed. +**Terminal blocks.** Blocks are first-class targetable terminal entities, not just data hanging off a session. Block selectors should support the same addressing primitives as terminal sessions where meaningful: active/current block, opaque block ID, and block index scoped to the resolved session. Block reads can expose command text, output, status, timing, exit code, and metadata, so block content reads are underlying-data reads while block listing that returns only IDs/status/timestamps may be metadata reads. Stale, missing, or ambiguous block selectors must fail rather than selecting a neighboring block. +**Files.** Warp already supports file opening via deep links and the built-in editor. The `file` namespace is limited to app-state and metadata behaviors that operate Warp's visible UI: +- `warpctrl file open <path>` — app-state mutation that opens a file in a Warp editor tab, equivalent to clicking a file link. +- `warpctrl file open <path> --line <n>` — app-state mutation that opens at a specific line. +- `warpctrl file list` — metadata read that lists files currently open in editor tabs across the instance. +File selectors use filesystem paths (absolute or relative to the working directory of the target pane/session when the command defines that behavior). Unlike window/tab/pane selectors, file selectors are not opaque IDs — they are user-visible paths. The protocol should support a `file` field in the target selector that accepts a path string, distinct from the opaque ID selectors used for windows, tabs, and panes. `warpctrl` must not expose file content reads or filesystem-content mutations; agents and scripts should use native file tools for those operations. +**Warp Drive objects.** Warp Drive stores typed objects that users can reference, execute, edit, and share. The object taxonomy should include, at minimum, spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries where trash operations are exposed. A future `drive` namespace could support: +- `warpctrl drive list --type workflow` — authenticated metadata read that lists Warp Drive objects by type. +- `warpctrl drive inspect <id>` — authenticated underlying data read when it returns object content. +- `warpctrl drive workflow run <workflow-id>` — authenticated underlying data mutation that executes a typed workflow in a target session, implemented only in the execution-underlying branch with authenticated scripting identity and audit coverage. +- `warpctrl drive object create|update|trash|restore <id>` — authenticated underlying data mutations that change cloud-backed user content. +- `warpctrl drive object share open <id>` — app-state mutation that opens the sharing dialog for user review without changing sharing state. +- `warpctrl drive object share-to-team <id>` — authenticated underlying data mutation that makes a personal object available to the user's current team using the app's standard team-sharing behavior. This is the only direct sharing mutation in the v0 product scope. +- `warpctrl drive notebook open <notebook-id>` — app-state mutation that opens a view of an existing notebook without modifying it. +Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var_collection`, `prompt`, `folder`, `ai_fact`, `mcp_server`, etc.) acts as a namespace filter. Drive actions that execute content in a terminal session, such as running a workflow, require an exact grant for that execution action and are implemented only in the execution-underlying branch after authenticated scripting identity, approval policy, and audit coverage are in place. +**Design constraints for these future entity families:** +- File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). +- The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. +- Drive actions require authenticated-user grants by default. Listing, reading content, opening a view, executing, sharing, and changing a Drive object are separate exact actions; a grant for any one of them does not authorize the others. Executing, sharing, or changing a Drive object additionally requires its action-specific approval and audit policy. +### Settings: protocol-first +Settings reads and writes should go through the local-control protocol like other actions, not bypass it via direct file manipulation. +- `warpctrl setting get <key>`, `warpctrl setting set <key> <value>`, and `warpctrl setting toggle <key>` send requests to the running Warp instance through the standard authenticated control endpoint. +- The app bridge validates the key against the allowlist and the value against the expected type before applying the change. +- This keeps authorization enforcement consistent: the exact requested setting action, execution context, authenticated-user requirement, and action-specific policy are checked like any other action, rather than creating a second unguarded path through the filesystem. +- The app owns the write to the settings file and any side effects (e.g., theme reload, layout reflow) as a single atomic operation, avoiding races between a direct settings-file edit and the app's file watcher. +- If a future need arises for offline settings manipulation (no running Warp process), a separate file-based path can be added later with its own validation, but it should not be the default. +- Settings reads and writes are separate exact actions. A credential for opening or focusing Warp UI must not authorize any settings write. diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md new file mode 100644 index 0000000000..d8269a29d5 --- /dev/null +++ b/specs/warp-control-cli/README.md @@ -0,0 +1,116 @@ +# warpctrl operator README +`warpctrl` is the provisional CLI entrypoint for controlling an already-running local Warp app instance. It is intended for scripts, demos, agent workflows, and developer automation that need to perform allowlisted Warp UI actions through the installed channel-specific Warp binary without launching the GUI. +The first implementation slice is intentionally narrow: +- discover compatible running Warp instances; +- select one instance implicitly when unambiguous or explicitly with `--instance`; +- request brokered scoped local-control credentials for the selected instance; +- check app health and get protocol and build identity metadata with `warpctrl app ping` and `warpctrl app version`; +- create a new terminal tab with `warpctrl tab create`. +The local-control protocol and catalog are broader than this slice. Protocol requests for actions outside the implemented capability set fail with structured unsupported-action errors until their handlers land; command names that do not have a shipped CLI parser route use Clap's normal parser error behavior. +## Packaging model +`warpctrl` should be packaged as an Oz-style wrapper script rather than a standalone Rust binary. The wrapper should resolve the installed channel-specific Warp executable and invoke it with the hidden `--warpctrl` control-mode flag: +- `crates/local_control` owns discovery records, local authentication material, client transport, protocol envelopes, action names, and error types. +- `crates/warp_cli` owns command parsing conventions for local-control subcommands. +- the channel-specific app binary owns the hidden `--warpctrl` dispatch path and exits before normal GUI startup. +- the app-side bridge owns the per-process loopback listener and dispatches supported actions onto the live Warp UI context. +The control-mode path should initialize only the work needed for CLI parsing, instance discovery, local authentication loading, request serialization, HTTP transport, and output formatting. It should not initialize GUI state, terminal models, rendering, workspaces, or main-app startup paths. +During the provisional naming period, release artifacts and helper names may be channelized, but operator docs and examples should use `warpctrl` unless an integration branch explicitly documents a channel-specific alias. +This branch wires the core hidden dispatch contract through the existing Warp binary. Platform packaging should create wrapper scripts that call the channel binary with `--warpctrl` instead of producing or selecting a separate `warpctrl` binary. +## Install and invocation guidance +### macOS +For local development checks, build the local Warp binary and invoke it with the hidden control-mode flag: +```bash +cargo run -p warp --bin warp -- --warpctrl instance list +``` +For distributable checks, use the installed `warpctrl` wrapper. The wrapper execs the app bundle's channel-specific executable with `--warpctrl`. +### Linux +For local development checks, build the local Warp binary and invoke it with the hidden control-mode flag: +```bash +cargo run -p warp --bin warp -- --warpctrl instance list +``` +For distributable checks, use the packaged `warpctrl` wrapper. The wrapper execs the packaged channel-specific Warp executable with `--warpctrl`. +The standalone `script/linux/bundle --artifact warpctrl` validation artifact includes that wrapper and compiles the forwarded channel binary with `warp_control_cli`. Installing the wrapper into the normal Linux app package remains a separate packaging follow-up. +Run `warpctrl --version` after installation to confirm the shell is resolving the expected build. +### Windows +Until the wrapper installer lands, build the local Warp binary and invoke it with the hidden control-mode flag for development checks: +```powershell +cargo run -p warp --bin warp -- --warpctrl instance list +``` +Installer helper creation and release-artifact wiring still need a later packaging change before docs can promise an installer-provided `warpctrl` command. +## End-to-end local test flow +Use matching app and CLI bits from the same branch or release artifact so the protocol version and action catalog agree. +1. Start Warp and leave at least one window open. +2. Open **Settings > Scripting**. Local control is disabled by default. To run `warpctrl` from an external terminal or script in the current foundation slice, select **Enabled everywhere, including outside Warp**. Enabling scripting allows for programmatic and agentic control of Warp; refer to the docs for more info. +3. Confirm that the local-control server registered the running process: + ```bash + warpctrl instance list + ``` +4. Confirm app health and inspect protocol and build identity metadata: + ```bash + warpctrl app ping + warpctrl app version + ``` +5. If exactly one compatible instance is listed, create a new terminal tab: + ```bash + warpctrl tab create + ``` +6. If multiple compatible instances are listed, copy the desired `instance_id` and target it explicitly: + ```bash + warpctrl app ping --instance <instance_id> + warpctrl app version --instance <instance_id> + warpctrl tab create --instance <instance_id> + ``` +7. Verify the running app receives focus for the selected instance and a new terminal tab appears according to Warp's normal new-tab placement behavior. The success response includes the created tab's opaque ID. +8. In a future slice that implements `tab list`, inspect state before and after the mutation: + ```bash + warpctrl tab list --instance <instance_id> + ``` +Expected failures: +- `warpctrl instance list` with no running compatible app: exits zero with an empty list; +- a command that needs a selected app when no compatible app is running: exits non-zero with a no-instance error; +- multiple ambiguous instances: exits non-zero and asks for `--instance`; +- unsupported app build or stale discovery record: exits non-zero with a protocol, stale-target, or transport error; +- `tab.create` not yet implemented by the running app bridge: exits non-zero with an unsupported-action error. +## Security model +The local-control protocol is designed for same-user scripting, not cross-user or network access. The trust boundary is the local user account. +- **Loopback-only listener.** Each Warp process binds its control server to `127.0.0.1` on an ephemeral port. The listener is not reachable from the network. +- **Brokered scoped credentials.** Discovery records contain instance metadata, loopback control-endpoint information, and an instance-bound Unix-domain-socket broker reference only when the selected Scripting mode allows outside-Warp control. The broker authenticates the connecting OS user with kernel peer credentials before decoding the credential request or issuing an action-scoped credential. Records do not contain bearer tokens or reusable full-access credentials. +- **Short-lived grants.** `warpctrl` requests an action-scoped credential over the owner-authenticated broker socket for the selected instance and invocation context, then presents that credential to `/v1/control`. Grants are instance-bound, expired entries are pruned, and the in-memory grant set is capped. Missing, invalid, expired, revoked, or wrong-instance credentials are rejected before request decoding. After decoding identifies the requested action, insufficient-scope credentials are rejected before selector resolution or handler dispatch. +- **Protected local state.** The authoritative Scripting mode uses platform secure storage, never imports a value from ordinary or private preferences, and defaults to disabled when no valid protected value is available. On POSIX platforms, discovery records and broker sockets use owner-only permissions. On Windows, outside-Warp publication remains disabled until equivalent ACL and broker protections are implemented. +- **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically. Candidates are also health-probed and accepted only when the live app reports the expected instance identity. +- **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The credential requirement provides a second layer since browsers cannot read the brokered credential material. +```mermaid +sequenceDiagram + participant CLI as warpctrl + participant FS as ~/.warp/local-control/ + participant Broker as Credential broker + participant HTTP as Warp loopback server<br/>(127.0.0.1:ephemeral) + participant Bridge as App bridge + + CLI->>FS: Read discovery records (user-only permissions / ACL) + FS-->>CLI: instance_id, loopback endpoint, broker socket reference + CLI->>CLI: Prune stale PIDs, select instance + CLI->>Broker: Connect to Unix socket<br/>action + context + instance + Broker->>Broker: Authenticate peer OS user before decode;<br/>check Settings > Scripting mode, context, scopes + alt Disabled, invalid, or insufficient scope + Broker-->>CLI: Structured denial + else Grant allowed + Broker-->>CLI: Short-lived scoped credential + CLI->>HTTP: POST /v1/control<br/>Authorization: Bearer <scoped credential> + HTTP->>HTTP: Verify credential expiry + instance binding before decode + HTTP->>HTTP: Decode typed request; verify action scope + HTTP->>Bridge: Dispatch action to app context + Bridge-->>HTTP: Structured result or error + HTTP-->>CLI: JSON response envelope + end +``` +**Known limitations and future hardening:** +- Windows outside-Warp local-control publication is disabled until discovery-record ACL creation and validation are implemented. +- The current low-risk first slice permits reuse of an unexpired scoped grant. A replay policy is required before broader or higher-risk command families ship. +- Same-user malicious software can still invoke trusted wrappers or automate the desktop, so brokered credentials are least-privilege guardrails rather than a complete hostile same-user sandbox. +- Once higher-risk handlers land, the same-user boundary becomes more sensitive. Consider per-request nonces, stricter platform secure-storage constraints, and stronger approval or policy gates. +## Documentation review notes +- Treat `warpctrl` as provisional executable naming until packaging signs off on final artifact aliases. +- Keep examples scoped to discovery, app health, protocol and build identity metadata, and `tab create` until additional app-side handlers are implemented. +- Do not document catalog commands as usable just because they exist in protocol enums or parser scaffolding; operator docs should distinguish implemented commands from planned allowlist entries. +- Windows packaging may initially follow the existing helper-wrapper pattern. Update this README when that decision is final. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md new file mode 100644 index 0000000000..40f8aa6f39 --- /dev/null +++ b/specs/warp-control-cli/SECURITY.md @@ -0,0 +1,515 @@ +# warpctrl security architecture +`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file/path app-state intents, Warp Drive operations, and explicitly authenticated execution-underlying actions. Accepted-command submission and agent-prompt submission remain future high-risk capabilities that require separate product/security review. Local file content operations are intentionally excluded from the public `warpctrl` catalog because native agent file tools are the preferred surface for file content reads and writes. +The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, protected storage safeguards the authoritative mode and any future long-lived proof secrets, broker-issued credentials grant one exact action, the running Warp app's local-control bridge verifies that action before dispatch, and target resolution never silently retargets a request. +Exact-action credentials and action-specific approval policy are primarily safety and intent mechanisms, not a hard security boundary against malicious same-user software. They let a user, script, or agent request only the specific operation it intends to perform so authority for a harmless UI action cannot accidentally be reused to expose sensitive content, mutate durable data, or execute commands. They should not be described as strong access control against a process that can already run arbitrary commands as the user. +`warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the logged-in Warp user that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require a verified Warp-terminal grant tied to the selected app's logged-in user. +## Current foundation status +The current foundation implementation stores a single local-control mode with three choices: disabled by default, enabled within Warp, and enabled everywhere including outside Warp. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and external requests must receive credentials only when the user selects the broadest mode. On Unix, the broker authenticates the connecting OS user through kernel peer credentials before decoding credential requests, then mints short-lived scoped credentials in memory without a stored or bootstrap local-control secret. The current broker therefore trusts the owning OS user rather than authenticating Warp-signed client code. Windows outside-Warp publication remains disabled until discovery-record ACL enforcement and an equivalent authenticated broker transport land. +## Security goals +- Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. +- Prevent unauthenticated localhost clients from invoking read or mutating control actions. +- Prevent browser-origin JavaScript from becoming an ambient localhost control client. +- Support multiple running Warp processes without a shared global mutating port or global credential. +- Separate discovery metadata from control authority so enumerating an instance does not automatically grant full control. +- Require explicit in-app user enablement before local control scripting from outside Warp can issue credentials or accept control requests. +- Allow local control scripting from verified Warp-managed terminal sessions once proof verification exists and the user selects a mode that permits that context, subject to action policy. +- Store the authoritative local-control mode in protected local storage so external apps cannot enable outside-Warp control by editing ordinary settings. +- Keep credentials out of plaintext discovery records, mint the current short-lived local-control credentials in memory, and protect any future long-lived proof or bootstrap secrets with platform secure storage where available. +- Distinguish verified `warpctrl` invocations that originate from a Warp-managed terminal session from external same-user invocations. +- When the broadest mode enables outside-Warp control, allow external invocations only for the action set explicitly allowed by the action catalog and granted credential. +- Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control mode and action policy permit that grant. +- Support least-privilege exact-action grants for automation and interactive use without relying on an unenforceable identity label. +- Authorize every action by its exact typed identity in the local app bridge, not in the CLI frontend. +- Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as logged-out-safe and therefore eligible for external use. +- Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. +- Require authenticated scripting identity, an exact execution-action grant, explicit approval or configured policy, deterministic target resolution, and audit records before terminal command execution or typed workflow execution can ship; accepted-command submission and agent-prompt submission remain prohibited until separately reviewed. +- Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file/path intent, or Warp Drive object. +- Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. +- Make high-risk operations auditable and configurable without logging sensitive terminal contents or credentials. +## Meaningful security boundaries +The most important security boundary is preventing control from places that should have no ambient authority over the user's Warp instance: +- arbitrary web apps running in a browser; +- other OS users on the same machine; +- unauthenticated clients that discover or guess the localhost control port; +- stale discovery records from exited Warp processes; +- malformed or unallowlisted direct protocol calls. +The local-control design can provide meaningful protection for those cases by binding only to loopback, avoiding permissive CORS, requiring local credentials, keeping credentials out of browser-readable and world-readable locations, pruning stale records, and validating every request in the local Warp app process. +The boundary is much weaker for a different local app running as the same OS user. Same-user local apps may already have access to user-owned files such as logs, may be able to observe the screen or UI through OS permissions such as Accessibility or Screen Recording, and can often invoke user-installed command-line tools. `warpctrl` should not imply strong isolation from such software. +For same-user local apps, the realistic goal is narrower: +- do not leave a raw bearer token in plaintext discovery records; +- prevent ambient direct HTTP calls to the localhost control listener by requiring a just-in-time broker-issued scoped credential; +- use platform secure storage, such as macOS Keychain, for future long-lived proof or bootstrap secrets so they are accessible only to Warp-owned signed code where practical; +- make high-risk operations go through `warpctrl` or a Warp-owned helper where user approval, configured policy, and exact-action grants can be applied; +- avoid giving `warpctrl` ambient non-interactive full-control authority. +In other words, the security model can make ambient direct localhost protocol calls fail, and future protected secrets can make direct credential theft harder. The current Unix broker still allows any process running as the owning OS user to request eligible scoped credentials. It cannot make a same-user malicious app safe if that app can invoke `warpctrl`, connect to the broker, automate the user's desktop, read other local state, or wait for the user to approve prompts. +## Comparison with other local scripting models +Other developer tools expose local automation through a few recurring patterns. The `warpctrl` design should borrow the parts that match Warp's needs while avoiding designs that assume localhost or same-user access is enough by itself. +### VS Code +VS Code's `code` command is primarily a launch and routing CLI: it opens files, folders, diffs, merge views, chat sessions, extension-management commands, and remote/tunnel workflows. It is not a general unauthenticated localhost API for arbitrary UI control of an already-running desktop app. +VS Code's richer local automation runs through extension APIs and extension hosts. Extensions are installed into a trusted editor environment and run with broad access to the workspace or UI side depending on extension kind. Workspace Trust and remote extension placement help users reason about whether code should run locally, remotely, or in a browser sandbox, but they do not create a fine-grained same-user security boundary against arbitrary local software. +Lessons for `warpctrl`: +- a narrow, typed CLI command surface is safer to reason about than exposing arbitrary internal app commands; +- agent and script workflows should request exact actions instead of inheriting ambient full-control authority; +- local UI control should remain distinct from remote/tunnel control because remote transports need stronger identity, approval, and network-security semantics. +### Chrome DevTools Protocol +Chrome DevTools Protocol is a powerful debugging and automation API. When Chrome is launched with remote debugging enabled, clients can discover targets over local HTTP endpoints and then control the browser over WebSocket. That protocol is intentionally high-power: it can inspect pages, navigate, execute JavaScript, observe network state, and interact with browser storage. +Chrome's security history is a useful warning for `warpctrl`: a local debugging port is dangerous if it becomes reachable by unexpected clients. Recent Chrome versions restrict remote debugging against the default user data directory and recommend isolated user data directories for automation, because debugging a real browser profile can expose sensitive cookies and credentials. Chrome also distinguishes command-line remote debugging from user-confirmed debugging flows. +Lessons for `warpctrl`: +- loopback binding is necessary but not sufficient; +- unauthenticated localhost endpoints should not expose powerful state or mutation; +- browser-origin protections matter because web pages can attempt localhost requests; +- high-power automation should prefer explicit, isolated, user-approved, or short-lived authority over a reusable full-profile control channel. +### Ghostty and macOS AppleScript +Ghostty exposes platform-native scripting on macOS through AppleScript. That model relies on macOS Automation/TCC prompts to decide whether one app may control another app, and Ghostty can disable AppleScript entirely with configuration. This is a good fit for macOS-native scripting, but it is platform-specific and inherits the limits of OS automation permission: once an app is allowed to automate another app, the boundary is not a per-action capability system. +Ghostty also supports terminal-oriented features such as shell integration and command-line window creation flows. Those are useful local automation conveniences, but they are not a general cross-platform authenticated control protocol with scoped credentials. +Lessons for `warpctrl`: +- use platform security mechanisms where they exist, such as macOS Keychain and Automation prompts; +- keep a user-visible kill switch or policy path for scripting/control surfaces; +- do not rely only on platform automation permission if Warp needs cross-platform, exact-action grants. +### iTerm2 Python API +iTerm2's Python API is a close comparison for terminal automation. The API is disabled by default. When enabled, iTerm2 listens on a Unix domain socket and requires authentication by default. Scripts launched by iTerm2 receive a random cookie in the environment, while external programs can request a cookie through AppleScript so macOS Automation permission mediates access. iTerm2 also documents an administrator-gated escape hatch to allow unauthenticated local apps. +This model directly acknowledges that terminal contents are sensitive and that any local automation API can affect local and remote hosts connected through terminal sessions. +Lessons for `warpctrl`: +- default-off or policy-controlled high-power automation is reasonable for sensitive capabilities; +- random local credentials are useful, but the path that grants or unwraps them is just as important as the token itself; +- underlying data reads and input/command execution should be treated as higher-risk than structural metadata reads; +- macOS Automation can be part of the approval path, but Warp still needs local app-side enforcement because direct protocol clients can bypass the official CLI. +### tmux +tmux is a useful lower-level comparison because its clients and server communicate through local sockets. The default socket lives in a per-user directory under `/tmp`, and that directory must not be world readable, writable, or executable. tmux control mode then exposes a text protocol where clients can issue normal tmux commands and receive asynchronous pane/session notifications. Newer tmux versions also have explicit server-access controls for sharing across users. +tmux's model is mostly an OS-user and socket-permission model. Once a client can access the socket with write authority, it can generally control the session. Read-only modes are useful operational guardrails but are not a reason to trust untrusted users or processes with the socket. +Lessons for `warpctrl`: +- per-user discovery directories and sockets protect meaningfully against other OS users; +- structured control protocols are scriptable and durable, but broad socket access quickly becomes broad control access; +- read-only and low-risk modes are valuable “do not accidentally interfere” controls, not a complete hostile-client sandbox. +### Overall direction for `warpctrl` +Compared with these systems, `warpctrl` should combine: +- tmux-style local filesystem/socket hygiene for protecting against other OS users; +- Chrome's lesson that local debugging/control endpoints need authentication and browser-origin hardening; +- iTerm2's use of explicit local credentials and macOS Automation-style approval for external control; +- Ghostty's use of platform-native scripting controls where available; +- VS Code's preference for typed public commands and separate treatment of remote control. +The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow exact-action grants, and routing high-risk operations through local Warp app validation and user/policy approval. +## Authoritative enablement model +Warp control has one top-level mode setting based on invocation context: +- **Disabled:** default. No local-control invocation context can receive credentials. +- **Enabled within Warp:** controls `warpctrl` invocations from verified Warp-managed terminal sessions once proof verification exists. +- **Enabled everywhere, including outside Warp:** controls verified Warp-managed terminal invocations and external terminals, scripts, launch agents, IDEs, or other same-user processes. +The mode should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. +The visible UI setting is not enough by itself. The authoritative mode must be stored in the most secure local storage provider available for the platform, with read/write access limited to the Warp application or Warp-owned trusted helper code where the platform supports that restriction. On macOS this means Keychain or an equivalent protected store constrained to Warp-signed code, not ordinary UserDefaults; on Windows this means Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store; on Linux this means the platform secret service where available, with any owner-only file fallback explicitly documented as weaker. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. +Current foundation implementation note: the mode is represented in the typed `LocalControlSettings` group and reads only from Warp's secure storage provider, never ordinary private preferences, `settings.toml`, SQLite, or a synced cloud preference. The implemented setting uses `SyncToCloud::Never` and remains absent from user-visible settings files, generated schemas, Settings Sync, Warp Drive, local-control settings read/write commands, and user-editable or server-backed settings surfaces. It does not migrate or fall back to an earlier private-preferences value; when no valid protected value is available, it fails closed to disabled. This is a tamper-resistant platform storage preference, not a claim that arbitrary same-user compromise is impossible. +Enablement requirements: +- The mode is local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. +- The implemented foundation setting must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. +- Only the running Warp app, through the Settings > Scripting UI, should be able to change the authoritative mode. +- `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable or widen the mode. +- The enabled-within-Warp mode may allow verified Warp-terminal invocations once proof verification exists, but turning the mode to disabled should prevent verified Warp-terminal invocations from receiving local-control grants. +- Outside-Warp control requires an intentional user gesture to select the broadest mode; the UI should explain that it allows scripts and automation from other apps to control Warp. +- The mode should be easy to change from the same UI, and narrowing the mode should revoke or invalidate active local-control credentials for invocation contexts no longer allowed. +- If enterprise or managed-device policy is added later, policy may force-disable the mode or force a narrower default, but policy should be separate from user-editable local settings. +Local-control actions that open, focus, or view cloud-backed objects must not create unexpected cloud-synced durable side effects merely because the object was displayed through automation. If an action intentionally mutates synced state, that exact action must require authenticated-user authority plus user or policy approval where applicable. +Disabled-state behavior: +- Warp should not mint scoped local-control credentials for a request whose invocation context is disabled. +- The control listener should reject requests from disabled contexts with a structured disabled-state error before authentication, selector resolution, or handler dispatch. +- Discovery records should avoid publishing actionable endpoint or credential-reference metadata for disabled outside-Warp control. If a minimal record is needed for UX, it should expose only non-sensitive status such as `outside_warp_control_enabled: false`. +- `warpctrl` may detect a disabled context and print instructions to enable it in Settings > Scripting, but it must not offer a command that flips the setting. +- Previously issued credentials must become unusable when their invocation context is no longer allowed, even if their original expiry has not elapsed. +These enablement gates do not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The outside-Warp gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. +### Exact-action grants and direct policy +The foundation stack should not expose separate per-risk toggles under Settings > Scripting. Once the selected mode allows a request context, the broker issues a short-lived credential for the one typed action requested, and the app bridge verifies that exact action. A credential for one action never authorizes another action, even if both actions read similar data or produce similar mutations. +Sensitive requirements attach directly to actions. Actions that expose terminal output or user content may require authenticated-user access or approval. Actions that execute code, mutate or share Warp Drive objects, change persistent configuration, or cause external effects require authenticated scripting identity, deterministic targets, action-specific approval or configured policy, and audit coverage as specified for that action. Opening or focusing Warp UI must never imply authority to execute commands or mutate user data. +## Trust boundaries +`warpctrl` has several distinct trust boundaries. +### Operating-system user boundary +The baseline local trust boundary is the OS user account. Discovery records and local credential material must be readable only by the owning user. This protects against other local users and network peers, but it does not protect against an already-compromised same-user process. +### Invocation boundary +Same-user does not mean same authority. Interactive use and unattended automation may both run commands under the same user account, but they should be able to intentionally request only the action they need. The protocol needs exact-action credentials that encode the granted action, target scopes, and lifetimes rather than an abstract caller type that the bridge cannot reliably verify. +These scoped credentials are guardrails for well-behaved clients. They prevent accidental overreach and make user intent explicit, but they are not a defense against malicious same-user code that can automate the CLI, inspect the user's environment, or wait for user approvals. +### Warp-terminal execution context boundary +`warpctrl` should be able to receive special grants when it is invoked from within a Warp-managed terminal session, but the bridge must not trust a caller-supplied string such as `caller_class=warp_terminal`. The app should issue a session-bound execution-context proof to Warp-managed terminal sessions and have the broker verify that proof before minting in-Warp-only grants. +Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. +Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the selected local-control mode, exact-action check, target scopes, or logged-in-user requirements. +### Authenticated scripting boundary +Actions that touch user-authenticated Warp data or perform high-risk underlying-data mutations require authenticated scripting authority. This includes Warp Drive object contents, object mutation, the v0 personal-to-team sharing path, AI conversation traces, cloud-backed user settings, team/account data, typed workflow execution, terminal command execution, and any other surface whose normal app access depends on the user's Warp account or can cause external side effects. +Authenticated scripting uses verified Warp-terminal mode: `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting mode plus action policy permit authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. External API-key authenticated scripting is not part of the selected public contract and requires a separate product/security review before it can be allowlisted. +For app-backed authenticated actions, the app bridge should execute on behalf of the selected app's logged-in user through existing app auth state. If the selected app logs out, switches users, or no longer matches a grant that requires app-user identity, authenticated actions fail with structured errors rather than falling back to logged-out behavior. +Logged-out users may still use the smaller local-only action set explicitly marked as not requiring authenticated scripting authority. +### Authenticated scripting protocol +`warpctrl` should provide auth/status flows for interactive app login. The CLI must not collect Warp passwords. +Requirements: +- `warpctrl auth status [selectors]` reports whether the selected app instance is logged in and whether verified Warp-terminal authenticated grants are available. It may return stable, non-secret subject/scope metadata when the caller has the required grant. +- `warpctrl auth login [selectors]` focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with actionable instructions, until the user signs in through Warp itself. +- The credential broker may mint an app-user authenticated grant only after confirming the selected app has a true logged-in Warp user and the selected mode plus action policy allow the verified invocation context. +- Authenticated credentials are bound to the selected instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses authenticated state, or the presented credential subject no longer matches a grant that requires app-user identity, authenticated actions fail with `authenticated_user_mismatch` or another structured authenticated-user error. +- Raw Firebase, server, OAuth, and cloud API tokens must not be exported to `warpctrl` output, shell completions, generated docs, logs, discovery records, or local-control JSON responses. +Logged-out-safe actions continue to use local-control credentials without requiring authenticated scripting identity. +### Application identity boundary +On platforms with secure credential storage, especially macOS, future long-lived proof or bootstrap secrets should be readable only by Warp-owned, correctly signed code. On macOS this means storing that material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” +This future boundary protects stored secrets from direct theft and can prevent arbitrary apps from using those secrets to make authenticated raw HTTP requests to the local-control listener. It also lets the authoritative mode be stored somewhere harder to modify than ordinary user preferences. The current Unix foundation does not implement this application-identity boundary for local-control credential issuance: it verifies the broker peer's OS user and mints short-lived credentials in memory. Neither model proves that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, exact-action credential issuance, action-specific policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. +### Action boundary +Every credential grants one exact typed action. The bridge must compare the requested action to that granted action before selector resolution or handler dispatch. Actions with stronger identity, context, target, approval, or audit requirements declare and enforce those requirements directly. +### Target boundary +A valid credential for one instance or target must not imply authority over another. Credentials should be bound to the issuing Warp instance and may be further scoped to target families such as terminal sessions, files, or Warp Drive objects when those surfaces are exposed. +## Threat model +### In scope +- Other local OS users attempting to control a Warp instance owned by the current user. +- Browser-origin JavaScript attempting to call localhost control endpoints. +- Same-user automation attempting an action without a credential for that exact action. +- Same-user processes attempting to extract plaintext credentials from local state. +- Same-user processes invoking `warpctrl` as a confused deputy for actions the process does not hold exact-action authority for directly. +- External same-user processes attempting authenticated-user actions that should be limited to verified Warp-terminal invocations. +- Logged-out requests attempting actions that require a true logged-in Warp user. +- Stale discovery records from exited Warp processes. +- Multiple running Warp instances where ambiguous selection could target the wrong process. +- Malformed clients attempting unknown, unsupported, unallowlisted, or invalid action payloads. +- Valid clients attempting actions other than the exact action granted by their credential. +- Explicit target IDs that become stale between discovery and execution. +- Future handlers that expose terminal data, settings writes, input mutation, command execution, file intents, or Warp Drive object operations. +### Out of scope +- A malicious process that already has arbitrary same-user filesystem and process access, except that scoped credentials should still reduce accidental over-granting to ordinary automation. +- Kernel, hypervisor, or administrator-level compromise. +- Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. +## Architecture overview +The full security model has eight layers. The current foundation branch implements the single mode gate with disabled as the default, allows outside-Warp credentials only in the broadest mode, and keeps the inside-Warp execution-context layer as a rejected future protocol concept until proof verification exists. +The security model has eight layers: +1. **Protected enablement:** Use protected local storage for the single local-control mode, with all contexts disabled by default, inside-Warp allowed only when the user selects the within-Warp or broadest mode after proof support lands, and outside-Warp off unless the broadest mode is selected. +2. **Discovery:** Find compatible live Warp instances without granting broad authority. +3. **Secret handling:** Mint the current short-lived local-control credentials in memory, keep all secrets outside plaintext discovery records, and restrict future stored proof or bootstrap secrets to trusted Warp-owned code where the platform supports it. +4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. +5. **Credential issuance:** Issue exact-action credentials with explicit lifetimes only when the selected mode allows the request's invocation context and policy allows the requested action. +6. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. +7. **Safety and user-auth policy:** Enforce the exact granted action, target scopes, execution-context requirements, authenticated-user requirements, and direct action policy locally in the app bridge. +8. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. +```mermaid +sequenceDiagram + participant Invoker as User / Automation + participant CLI as warpctrl + participant Registry as Per-user discovery registry + participant Enablement as Protected enablement state + participant Context as Execution context proof + participant Broker as Credential broker + participant Auth as App auth state + participant HTTP as Warp control listener + participant Bridge as App bridge + safety policy + participant UI as Warp app state + + Invoker->>CLI: Invoke allowlisted command + CLI->>Registry: Read instance metadata + Registry-->>CLI: instance_id, endpoint, protocol version, broker reference + CLI->>Enablement: Check inside/outside context enablement + Enablement-->>CLI: Enabled or disabled + alt Disabled + CLI-->>Invoker: context disabled; enable in Settings > Scripting + else Enabled + CLI->>Broker: Request scoped credential for action + Broker->>Enablement: Verify protected enablement state + Broker->>Context: Verify external vs Warp-terminal context + opt Authenticated-user action + Broker->>Auth: Verify logged-in Warp user + setting + Auth-->>Broker: User subject or unavailable + end + Broker->>Broker: Mint short-lived scoped credential in memory + Broker-->>CLI: Scoped credential with grants, context, user scope, expiry + CLI->>HTTP: Authenticated typed request + HTTP->>Bridge: Verify credential and protocol envelope + Bridge->>Bridge: Check exact action + context + authenticated-user + target scope + alt Denied + Bridge-->>CLI: structured safety-policy error + else Allowed + Bridge->>UI: Resolve target exactly and run allowlisted handler + UI-->>Bridge: typed result or structured target error + Bridge-->>CLI: response envelope + end + end +``` +## Current foundation discovery and request flow +The current Unix foundation deliberately uses three different mechanisms for three different jobs: +1. **Private filesystem discovery finds candidate instances.** `crates/local_control/src/discovery.rs` defines the shared registry format and validation rules. Each enabled Warp process publishes an owner-only JSON record that tells clients which instance exists, which actions it implements, its exact loopback HTTP endpoint, and the filename of its instance-specific credential-broker socket. The record contains routing metadata, not a bearer token or other control authority. +2. **The Unix-domain socket authenticates the OS user and issues authority.** The broker socket is the protected bootstrap path from discovery metadata to a short-lived exact-action credential. The Warp app obtains the connecting process's UID from kernel peer credentials before decoding its request, then evaluates current policy and, if allowed, returns an in-memory credential for the one requested action. +3. **The loopback HTTP endpoint carries the typed action.** The client presents the broker-issued credential to the selected instance's `/v1/control` endpoint. The app validates the endpoint headers and credential, then hands the typed request to the app bridge for current-policy, exact-action, target, and handler validation. + +The filesystem record and Unix socket are therefore complementary, not alternative discovery mechanisms. The JSON record is how a client learns that an instance and broker exist. The socket path in that record is how the client asks that selected instance for temporary authority. HTTP is how the client uses that authority. A client does not discover instances by enumerating or querying the Unix socket, and it cannot control an instance merely by reading its JSON record. + +### Server publication lifecycle +`app/src/local_control/mod.rs` owns the running app side of all three mechanisms. When the feature, platform support, and protected Settings > Scripting mode allow outside-Warp control, the app: +1. binds an ephemeral TCP port on exactly `127.0.0.1`; +2. creates an `InstanceRecord` containing that endpoint and an instance-derived broker socket filename; +3. publishes the record through `RegisteredInstance::register`; +4. binds the referenced Unix-domain socket inside the same owner-only discovery directory; +5. starts the credential broker on the Unix socket and the typed control handler on `/v1/control`. + +When outside-Warp control is disabled or the server stops, the app drops the registration and runtime. Graceful drop removes the JSON record and broker socket. Discovery scans provide the crash-recovery path by rejecting and pruning records whose PID is no longer alive. + +The default discovery directory is `~/.warp/local-control/`. `WARP_LOCAL_CONTROL_DISCOVERY_DIR` overrides it, and `$XDG_RUNTIME_DIR/warp/local-control` is preferred when `XDG_RUNTIME_DIR` is present. On Unix, the directory is restricted to `0700`, while discovery records and broker sockets are restricted to `0600`. An enabled instance publishes files shaped like `inst_<id>.json` and `inst_<id>.broker.sock`. These permissions meaningfully protect the registry and broker from other OS users, but they do not distinguish among processes running as the owning user. + +### Client discovery and invocation +A client invocation follows this sequence: +1. Read JSON records from the per-user discovery directory. +2. Parse compatible records and reject records with a mismatched protocol version, malformed authority, or dead PID. +3. Require the HTTP host to be exactly `127.0.0.1` and the broker reference to be the filename derived from the selected `instance_id`. This prevents a record from redirecting the client to an arbitrary network host or arbitrary socket path. +4. Probe a candidate by connecting to its broker, requesting an exact-action `app.ping` credential, making an authenticated HTTP ping, and verifying the returned `instance_id`. This removes records that name an unresponsive or inconsistent instance. +5. Select one compatible instance. If selection is ambiguous, require the user to identify the intended instance rather than silently targeting one. +6. Connect to the selected instance's Unix broker socket and request a credential for the exact action about to be invoked. +7. Present that credential only to the exact loopback endpoint from the validated record and send the typed action request. + +The probe is intentionally authenticated. Merely binding the stale record's old TCP port is insufficient to impersonate a live Warp instance because a port squatter cannot issue a credential through the instance-derived broker socket or satisfy the selected instance's in-memory credential lookup. + +### What the Unix broker contributes +The key security property of the Unix-domain socket is kernel-authenticated peer identity. Before the broker reads or decodes a credential request, it calls the platform peer-credential API and verifies that the connecting process's UID equals Warp's effective UID. The caller cannot forge this kernel-reported UID through request data, environment variables, a claimed PID, or a username string. + +The broker also provides a protected just-in-time credential bootstrap path: +- bearer credentials are never written into discovery records; +- there is no reusable bootstrap token for a client to read from disk and send to a stale or squatted TCP endpoint; +- the running Warp app can evaluate the protected Scripting mode and direct action policy at issuance time; +- every issued credential is bound to the selected instance, one exact action, an invocation context, and a short expiry; +- issued credentials exist only in the running app's process-local credential map and the requesting client's memory. + +The instance-derived socket filename and owner-only discovery directory bind the broker reference to the selected record and make arbitrary socket-path injection fail validation. Socket permissions provide an additional owner-only filesystem check, while peer credentials provide the authoritative same-UID check after connection. + +### What the HTTP and app bridge contribute +The HTTP listener is a transport for the final typed action, not the discovery or credential-issuance mechanism. Knowing or guessing its loopback port is insufficient. Before dispatch, the app: +- rejects requests carrying a browser-style `Origin` header; +- requires the `Host` header to exactly match the selected `127.0.0.1:<port>` endpoint; +- requires a bearer credential present in the selected instance's process-local credential map; +- rejects missing, malformed, expired, revoked, or wrong-instance credentials; +- decodes the typed request only after transport authentication; +- has the app bridge re-check current Scripting mode, invocation context, exact granted action, authenticated-user requirements, target restrictions, and allowlisted handler dispatch. + +These checks defend against browser-origin clients, network clients, unauthenticated clients that discover or guess the TCP port, stale or malformed records, wrong-instance credentials, and accidental action overreach. Loopback binding and header checks harden the endpoint, but the broker-issued credential and app-side checks remain the authority. + +### Current boundary and limitation +The current broker authenticates the **OS account**, not the identity of the application running under that account. It does not prove that the caller is the official `warpctrl` binary, Warp-signed code, a process launched from a Warp terminal, or a human-approved invocation. Once the user enables outside-Warp control, any process running as that OS user can connect to the broker and request credentials for actions that current policy allows from the external invocation context. + +The current architecture therefore provides a meaningful hard boundary against other OS users, browsers, network peers, and unauthenticated direct HTTP clients. For same-user software, protected enablement, short expiry, exact-action grants, app-side revalidation, deterministic targeting, and future approval policy are least-privilege and intent guardrails rather than strong isolation. A stronger same-user boundary would require an additional application-identity or user-intent mechanism, such as platform code-signature validation, verified Warp-terminal session proof, or per-action user approval. +## Discovery registry +Each participating Warp process writes a discovery record in a secure per-user local-control directory. Discovery records are metadata, not a full control-authority model. +A discovery record should contain: +- opaque `instance_id`; +- PID and process start timestamp; +- channel and build metadata; +- protocol version and supported capability summary; +- loopback endpoint for the instance-local control listener; +- credential broker reference that can mint a just-in-time scoped credential for a requested action, not a bearer token or reusable control credential. +Discovery rules: +- Records must be readable only by the owning user. +- POSIX records must use owner-only permissions such as `0600` for files and a non-world-readable directory. +- Windows records must live under the current user's app data directory with ACLs limited to the current user, Administrators, and SYSTEM. +- When outside-Warp control is disabled, records must not publish actionable control endpoints or credential references for external clients. A minimal disabled-status record is acceptable only if it contains no authority. +- The CLI must prune or ignore stale records whose PID is gone or whose health/protocol check fails. +- If multiple compatible instances are ambiguous, the CLI must require explicit `--instance` selection. +- Discovery metadata must not expose terminal contents, environment variables, auth tokens for cloud services, raw local-control credentials, or mutating capability grants. +- Discovery must not publish actionable endpoints or credential broker references for an invocation context unless the protected mode currently enables that context. Future UI should support temporary or session-scoped enablement and a quick path back to disabled so one-off control use does not leave an unexpectedly durable passive discovery surface. +## Credential model +The full `warpctrl` catalog requires scoped credentials. A single shared full-power bearer token is not sufficient once automation, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations are supported. +### Credential properties +Current foundation implementation note: `warpctrl` discovers a loopback control endpoint and an instance-bound Unix-domain-socket broker reference, then requests a short-lived credential over that socket for the specific action it is about to invoke. The broker authenticates the connecting peer's OS user before decoding the request. The discovery record does not contain bearer tokens, raw credential material, or a stored credential that the CLI unwraps and sends to the discovered port. +A control credential should encode or reference: +- issuing Warp instance; +- protocol version or accepted version range; +- the one granted `ActionKind`; +- verified execution context, such as external client or Warp-managed terminal session; +- whether the credential may act on behalf of an authenticated Warp user; +- authenticated Warp user subject or stable user reference when an authenticated-user grant is present; +- optional target restrictions, such as one session, one workspace, one file path, or one Warp Drive object type; +- issued-at time; +- expiry time or process-lifetime binding; +- unique credential ID for revocation and auditing; +- integrity protection so callers cannot forge or widen grants. +### Credential issuance +Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides whether to issue a credential based on the requested exact action, target scope, user configuration, execution context, authenticated-user requirements, and any explicit user approval. +Recommended defaults: +- Credential issuance is unavailable unless the protected enablement state allows the request's invocation context. +- Commands request only the exact action they are about to invoke. +- External same-user invocations are limited to the smaller logged-out-safe local action set and cannot receive authenticated-user authority. +- Verified Warp-terminal invocations may request broader sets of actions over time, but each credential remains scoped to one action. +- App-user authenticated grants are available only when the selected Warp app has a true logged-in Warp user and a verified Warp-terminal execution context allowed by local-control settings. +- Actions that expose sensitive content, mutate durable data, execute code, or cause external effects require the direct identity, approval, target, and audit conditions specified for that action. +Callers do not manage low-level scope strings. They request a typed action, and the app-owned broker evaluates that action's configured policy, execution context, target restrictions, authenticated-user requirements, and any approval or consent prompt. If the action is denied, the broker or bridge returns a structured error. The broker must not issue broader authority merely because the request came from the signed `warpctrl` binary. The CLI must not mint its own authority, and the app bridge remains the enforcement point because direct protocol clients can bypass the CLI. +### Exact-action grants, not strong access control +Exact-action credentials prevent an honest client from accidentally reusing authority for a different operation and give Warp a structured point to prompt, deny, or audit sensitive actions. They do not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. +### Credential storage +The current Unix foundation stores no bootstrap or long-lived local-control secret; it mints short-lived scoped credentials in memory. Future credential and proof storage should be platform-appropriate: +- Local discovery may store a credential reference rather than the credential itself. +- The authoritative local-control mode should use the same class of protected local storage as raw credential material, but it should be accessible to the Warp app for the Settings > Scripting UI and not writable by `warpctrl` or arbitrary external apps. +- Raw long-lived credentials should prefer platform-secure storage such as macOS Keychain or Windows Credential Manager when practical. +- On macOS, raw control secrets should be stored in Keychain and restricted to trusted Warp-signed code using a designated requirement, Keychain access group, trusted-application ACL, or equivalent code-signing based mechanism. Restricting by filesystem path alone is insufficient because paths can be replaced or wrapped. +- Keychain item access should include the Warp app, the signed `warpctrl` binary, and any signed Warp-owned local broker/helper that needs to unwrap raw secrets. It should exclude arbitrary same-user applications. +- Short-lived credentials may be stored in owner-only local state if their lifetime and scope are narrow. +- Credentials must never be printed in human-readable output, JSON output, logs, errors, or shell completion data. +### Confused-deputy mitigation +When future secrets use application-identity-constrained secure storage, it can prevent arbitrary apps from reading the token; it does not prevent arbitrary apps from asking trusted Warp code to use the token on their behalf. The current owner-authenticated Unix broker provides no Warp-signed-code boundary against same-user clients. +For example, if `warpctrl` can silently unwrap a full-power credential and execute any action, another same-user process can invoke `warpctrl input run ...` without reading the credential directly. That makes `warpctrl` a confused deputy. +Mitigations: +- Do not give `warpctrl` ambient non-interactive access to an unrestricted full-control credential. +- Prefer action-scoped or session-scoped credentials minted just in time by the broker. +- Require explicit user approval or preconfigured policy for underlying data mutations and other sensitive grants. +- Distinguish user-approved credential requests from ambient unattended invocations through explicit approval prompts, configured policy, terminal/session context, or narrow credential request flows. +- Bind issued credentials to the requested instance, exact action, optional target scope, and short expiry. +- Prune expired grants and cap the process-local active-grant set. The low-risk foundation slice may reuse an unexpired scoped grant, but a replay policy is required before broader or higher-risk action families ship. +- Let `warpctrl` preflight and request credentials, but require the local app bridge to enforce scopes because direct protocol clients can bypass the CLI. +- Make denials structured and non-fatal for automation so callers can request narrower or user-approved grants rather than falling back to unsafe behavior. +These mitigations are about routing high-risk operations through intentional `warpctrl` flows rather than exposing a reusable localhost token to any process. They should not be documented as a guarantee that arbitrary same-user applications cannot cause Warp-visible actions. +## Transport authentication +The default transport is an instance-local loopback listener bound to `127.0.0.1` on an ephemeral per-process port. +The current just-in-time credential broker avoids the specific stale-record bearer-token phishing failure mode where `warpctrl` unwraps a long-lived Warp-held credential and sends it to a port squatter. It uses an instance-bound Unix-domain socket inside the owner-only discovery directory and checks the peer OS user before reading the credential request. If future designs add stored bootstrap credentials, server-held secrets, or reusable credential references that must be presented to the discovered endpoint, the client must verify the server's identity before sending that material. +Transport requirements: +- Bind only to loopback for local control. +- Do not set permissive CORS headers. +- Reject any request carrying an `Origin` header. +- Reject any request whose `Host` header is not exactly `127.0.0.1:<selected-port>` for the selected discovery record. +- Reject discovery records unless the published control endpoint uses exactly `127.0.0.1` and the broker socket reference is the selected instance's expected filename inside the owner-only discovery directory. +- Reject control requests when their inside-Warp or outside-Warp invocation context is disabled, even if the request presents an otherwise valid credential. +- Authenticate every control request locally in the selected Warp app process before selector resolution or action dispatch. +- Reject missing, malformed, expired, revoked, or invalid credentials with structured authentication errors. +- Keep unauthenticated health metadata minimal and non-sensitive. +- Preserve structured error envelopes so the CLI does not collapse security failures into generic transport errors. +Remote URL support is a separate future transport mode. It should not reuse the local same-user credential model without additional identity, encryption, replay protection, and remote approval/policy design. +## Logged-in user requirements +Local-control validation always begins with local protocol state: discovery records, secure local credential references, exact-action grants, execution-context proof, protocol version, request shape, allowlisted actions, typed parameters, and deterministic target selectors. +Some actions additionally require authenticated scripting authority from a true logged-in Warp user in the selected app and a verified Warp-terminal invocation. The action allowlist must declare this explicitly with a `requires_authenticated_user` or equivalent authenticated-scripting requirement field. +Default rule for new actions: +- New actions require an authenticated Warp user unless the implementer deliberately classifies them as logged-out-safe. +- The logged-out-safe set should remain meaningfully smaller and limited to local app structure, local appearance metadata, and other surfaces that do not depend on the user's cloud-backed Warp identity. +- Actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, or other user-authenticated state must require an authenticated user. +- Actions that execute user-authored cloud-backed content, such as running typed Warp Drive workflows, require authenticated scripting authority plus the direct approval, targeting, and audit requirements for that exact action. Agent-prompt submission remains excluded until separately reviewed. +When an authenticated-user or authenticated-scripting action is requested: +- app-user mode requires the selected app to have an active logged-in Warp user; +- the presented local-control credential must include an authenticated grant for that user; +- the selected mode, action policy, and authenticated-scripting policy must allow authenticated actions for the verified Warp-terminal execution context; +- the app bridge should execute app-user actions through the app's existing authenticated state rather than exporting raw cloud auth credentials to `warpctrl`. +If these conditions are not met, the app returns a structured error. It must not fall back to logged-out behavior or silently omit user-authenticated data from a result that claims success. +## Exact-action policy model +Exact-action grants are enforced in the app bridge after transport authentication and before target resolution or handler dispatch. This provides consistent “do not accidentally do more than requested” behavior for honest clients, not a sandbox for hostile same-user code. +The bridge path must: +1. Authenticate the transport credential before decoding the typed request envelope. +2. Parse the typed request envelope. +3. Verify protocol version compatibility. +4. Determine the exact granted action, execution context, target scopes, and authenticated-user grant. +5. Compare the requested action to the granted action and load that action's direct policy requirements. +6. Check optional target-family restrictions, authenticated-user requirements, and action-specific approval or audit prerequisites. +7. Reject a request for any different action with `insufficient_permissions`. +8. Reject authenticated-user actions without the required app-user login or authenticated grant with a structured authenticated-user error. +9. Only then resolve selectors and invoke the allowlisted handler. +The CLI frontend may provide helpful preflight errors, but those checks are advisory. Local app-side bridge enforcement is mandatory because other tools can bypass the official CLI and speak the protocol directly. +## Direct action requirements +Action authorization is defined per typed action rather than by assigning actions to permission buckets. Similar actions remain independently authorized. +- Structural metadata actions such as `instance.list`, `window.list`, or `theme.list` each require their own grant and must not expose terminal content or other user data. +- Content-bearing reads such as block output, input buffers, command history, Warp Drive content, or AI conversations each require their own grant and any direct authenticated-user or approval policy specified for that action. +- UI actions such as creating tabs, focusing panes, opening files, or staging input each require their own grant and must not imply authority to execute commands, change persistent settings, or mutate user data. +- Persistent settings and metadata changes each require their own grant and allowlist validation. +- Actions that execute code, mutate or share Warp Drive objects, mutate AI content, or cause external effects each require their own grant plus authenticated scripting identity, explicit approval or configured policy, deterministic targeting, and audit coverage where specified. +Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed, regardless of what other actions a credential grants. +## Target scoping and deterministic resolution +Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. +Rules: +- Instance selection happens before request dispatch and must be explicit when ambiguous. +- `active` selectors may be ergonomic defaults only when the resolved target is unambiguous. For window-scoped mutations, the resolver first uses the active window and may fall back to the sole existing window when exactly one window exists. +- If no active target exists for a mutating request and no action-specific deterministic fallback applies, return `missing_target` or `invalid_selector`; if multiple fallback candidates exist, return `ambiguous_target`. +- Explicit opaque IDs must resolve exactly or return `stale_target`. +- Index selectors must resolve to concrete IDs before execution and must not race into a different target silently. +- Session-scoped requests against non-terminal panes return `target_state_conflict`. +- File selectors use paths and must remain distinct from opaque UI IDs. +- Warp Drive selectors must include object type and resolve by opaque ID for automation stability, with name/path lookup only as an interactive convenience. +Target restrictions in credentials should be checked before invoking handlers. For example, a credential scoped to one session must not read another session's output even if the CLI can discover that session ID. +## Allowlisted handlers +The protocol must not expose arbitrary internal app actions by string. +Each supported command requires: +- a typed protocol action; +- typed parameters; +- validation rules; +- documented authenticated-user, invocation-context, target, approval, and audit requirements; +- a documented `requires_authenticated_user` value; +- a documented allowed execution context, including whether external clients can run it or whether it is limited to verified Warp-terminal invocations; +- local app-side exact-action grant checks; +- deterministic target resolution; +- a handler that reuses existing user-visible app behavior where possible; +- typed success and error responses. +Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, declare whether it requires an authenticated user, declare its allowed execution contexts and direct policy requirements, add a handler, and add tests for authentication, exact-action denial, authenticated-user denial, selector failure, and success behavior. +## Browser and localhost protections +Loopback is not sufficient by itself because browsers can send requests to localhost. +This section is not a browser-only defense and must not rely on CORS as the primary control. Non-browser local clients can also send HTTP requests, so the local app must enforce credentials, invocation-context gating, app-side authorization, and endpoint hardening for every request. +Required protections: +- No permissive CORS on control endpoints. +- Reject any request that includes an `Origin` header. +- Reject any request whose `Host` header is not exactly the selected `127.0.0.1:<port>` endpoint. +- No JSONP or browser-readable fallback formats. +- Valid scoped credentials required for all sensitive endpoints. +- Credentials stored outside browser-readable locations. +- Preflight and error responses must not reveal credentials or sensitive target state. +- The protocol should avoid GET endpoints for mutating actions. +The control plane should assume a malicious webpage can guess common localhost ports and send blind requests. It should not be able to read discovery records or obtain credentials. +## Auditing and logging +High-risk action support should include auditability without leaking sensitive data. +Recommended audit fields: +- timestamp; +- instance ID; +- credential ID or grant profile; +- action name and applicable direct policy requirements; +- target type and opaque target ID when safe; +- success or structured error code. +Avoid logging: +- bearer tokens or scoped credentials; +- terminal output; +- command text for command execution unless explicitly approved by policy in a future version that supports execution; +- agent prompt text; +- input buffer contents; +- Warp Drive object contents; +- environment variable values. +Error-level logs should be used only for conditions that need developer attention, not normal denied requests or user-caused selector failures. +## Security- and safety-relevant errors +Structured errors are part of the security contract. +Important errors include: +- `local_control_disabled` when the relevant inside-Warp or outside-Warp scripting context is disabled in Settings > Scripting or has been disabled after credentials were issued; +- `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; +- `insufficient_permissions` for valid credentials that grant a different action or do not include the requested target scope; +- `authenticated_user_required` when an action requires authenticated scripting authority but the credential lacks an authenticated-user grant; +- `authenticated_user_unavailable` when the selected Warp app has no logged-in Warp user or cannot access the required authenticated user state; +- `authenticated_user_mismatch` when an authenticated-user credential is bound to a different user subject than the user currently logged in to the selected Warp app; +- `execution_context_not_allowed` when the action or requested grant is not allowed from the verified invocation context, such as an external client attempting an in-Warp-only authenticated-user action; +- `ambiguous_instance` when multiple compatible instances cannot be resolved safely; +- `invalid_selector` for malformed or unsupported selector syntax; +- `missing_target` when an active/default target does not exist and no deterministic fallback target exists; +- `stale_target` when an explicit target ID no longer exists; +- `unsupported_action` for actions not implemented by the selected instance; +- `not_allowlisted` for actions intentionally excluded from the public control surface; +- `invalid_params` for malformed parameters; +- `target_state_conflict` when the target exists but cannot support the requested action. +The app must not downgrade these failures into broader default actions, and the CLI must preserve structured server errors in both human-readable and JSON output. +## Required controls before full catalog expansion +Before shipping each action family, verify that these controls are implemented for that family: +- Local control scripting must be enabled for the request's invocation context before the action family can run; disabled mode blocks all contexts, the within-Warp mode allows inside-Warp only once proof verification exists, and outside-Warp control requires the broadest mode. +- The authoritative mode lives under Settings > Scripting, is protected from external writes, and is local-only rather than synced. +- The action has documented authenticated-user, invocation-context, target, approval, and audit requirements. +- The action has a documented `requires_authenticated_user` value. New actions default to `true` unless explicitly reviewed as logged-out-safe. +- The action documents allowed execution contexts and whether external clients may run it. +- The bridge verifies the credential grants that exact action locally in the selected Warp app process. +- The credential model grants the exact requested action. +- The credential model can express authenticated-user grants and verified execution context requirements when needed. +- The handler checks optional target restrictions where relevant. +- Requests with invalid credentials or credentials for a different action fail before selector resolution or mutation. +- Requests that require authenticated-user access fail unless the selected app has a true logged-in Warp user and the credential includes an authenticated-user grant. +- Ambiguous, missing, and stale targets return structured errors. +- Tests cover the allowed path, use of a different action credential, and denied credential paths. +- Logs and errors do not expose credentials, terminal contents, command text, or sensitive settings. +- Operator docs distinguish available commands from planned catalog entries. +- Initial public action-family docs and tests prove terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission are not allowlisted; input-buffer staging never submits the buffer. +- Initial public action-family docs and tests prove local file content reads, writes, appends, deletes, and filesystem-content mutations are not allowlisted; file/path support is limited to opening visible Warp UI surfaces and listing files already open in Warp. +## Platform requirements +### macOS and Linux +Discovery files must be stored in a per-user directory with owner-only permissions. +On macOS, the authoritative local-control mode and any future long-lived proof or bootstrap secrets should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The mode should be writable by the Warp app's Settings > Scripting flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when the selected mode allows the relevant invocation context. +On Linux, the authoritative mode and any future long-lived proof or bootstrap secrets should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If the mode falls back to owner-only local state, the weaker same-user protection should be documented. +The current Unix foundation uses an instance-bound Unix-domain-socket credential broker with peer credential checks before request decoding. +### Windows +Discovery records and credential material must live under the current user's app data directory with ACLs restricted to the current user, Administrators, and SYSTEM. +The authoritative mode should use Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store rather than normal registry settings that arbitrary same-user processes can write. +Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs and protected mode behavior. +## Remote control is separate +The local architecture intentionally assumes same-machine, same-user control over a loopback listener. Future remote URLs must use a different security design that includes: +- transport encryption; +- remote identity and authentication; +- replay protection; +- explicit user or admin approval/policy; +- network exposure review; +- separate credential issuance from local discovery; +- remote-safe auditing and revocation. +Remote support should not be enabled by simply allowing `warpctrl` to point the existing local credential at an arbitrary URL. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md new file mode 100644 index 0000000000..38f8680cf2 --- /dev/null +++ b/specs/warp-control-cli/TECH.md @@ -0,0 +1,592 @@ +# Context +`PRODUCT.md` defines a local Warp control CLI command, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. The public command should be exposed through an Oz-style wrapper script that invokes the existing channel-specific Warp binary in control mode, not through a separate standalone control binary. +`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for the top-level Settings > Scripting surface, protected mode storage, discovery metadata, credential storage, exact-action grants, verified execution context, authenticated-user requirements, localhost/browser protections, deterministic target resolution, and local app-side validation. The long-term architecture includes separate verified inside-Warp and outside-Warp invocation contexts, but the current foundation implementation supports outside-Warp requests only in the broadest mode and must reject `InvocationContext::InsideWarp` until the verified Warp-terminal proof broker lands. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. +The existing app already has three relevant building blocks: +- `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. +- `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. +- `crates/app-installation-detection/src/lib.rs (15-60)` and `app/src/profiling.rs (208-242)` show the current local HTTP routes. They are narrow endpoints, not a general control plane. +Warp also already has the app-side behaviors the control API should reuse rather than reimplement: +- `app/src/terminal/view/action.rs (193-196)` defines split-pane terminal actions. +- `app/src/pane_group/mod.rs (4266-4360, 5377-5414)` shows pane creation/splitting semantics and how split events mutate pane layout. +- `app/src/workspace/action.rs (153-156)` defines the existing tab creation actions, including default and terminal-tab variants. +- `app/src/workspace/view.rs (21203-21244)` shows how user-visible default and terminal-tab actions are dispatched. +- `app/src/settings/theme.rs (9-82)` defines persisted theme settings. +- `app/src/themes/theme_chooser.rs (416-458)` shows persisted theme selection behavior. +- `app/src/workspace/action.rs (95-776)` is the largest existing inventory of user-visible workspace actions and informs the allowlist catalog. +- `app/src/workspace/util.rs (12-18)` defines `PaneViewLocator`, and `app/src/pane_group/pane/mod.rs (84-177)` defines serializable pane identifiers, both useful reference points for selector resolution. +- `app/src/uri/mod.rs (822-1093, 1166-1364)` demonstrates external intents being resolved into active windows/workspaces and dispatched into running app state. +The current Oz CLI build/distribution model is also directly relevant because the control CLI should follow the same wrapper-script approach rather than introducing a separate bundled binary: +- `crates/warp_cli/src/lib.rs (88-188, 316-418)` defines the existing CLI/parser conventions and channel-specific command naming support. +- `app/src/lib.rs (631-746)` routes CLI invocations into CLI execution rather than GUI launch, and already avoids GUI startup for commands such as `dump-debug-info`. +- `script/macos/bundle (542-567)` writes the bundled Oz wrapper script into `Resources/bin` and uses `exec -a "$0"` to call the channel binary in `Contents/MacOS`. +- `script/linux/bundle (157-198)` shows the existing channelized binary naming and the current standalone artifact code that should be removed from the `warpctrl` path rather than extended. +- `script/windows/windows-installer.iss (235-263)` shows the current Windows helper-wrapper pattern for CLI access. +The most important constraint surfaced by this code is that the current fixed-port local HTTP server cannot be the entire solution for a multi-process control API. If multiple local Warp processes attempt to expose mutating routes through the same fixed port, only one can own it. The control design therefore needs explicit per-process discovery and addressing. +## Proposed changes +### 0. Security architecture dependency +Before implementing any local-control listener, CLI command, credential path, or action handler, the implementation must be checked against `SECURITY.md`. +Required security gates: +- Local control scripting has a single mode setting: disabled by default, enabled within Warp, and enabled everywhere including outside Warp. Inside-Warp control for verified Warp-managed terminal sessions can work only after the app-issued proof broker exists; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes requires the broadest mode. +- In the current foundation slice, the mode setting is implemented, outside-Warp credential requests are allowed only in the broadest mode, and inside-Warp credential requests must be rejected until proof verification exists. +- The control lives under a new top-level Settings pane page named **Scripting**. +- The authoritative mode is local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. +- The current foundation branch must mark the implemented local-control mode as `private: true` and `sync_to_cloud: SyncToCloud::Never`. It must not appear in the user-visible `settings.toml` file, generated settings schema, Settings Sync, Warp Drive, server-backed preferences, or any future `warpctrl settings` surface. +- `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, defaults writes, and server-backed preferences must not be able to enable or widen the mode. +- Discovery records do not publish actionable endpoints or credential references for disabled outside-Warp control. +- Credential issuance is unavailable when the request's invocation context is disabled. +- The current foundation keeps credentials out of plaintext discovery records and mints short-lived local-control credentials in memory without a stored or bootstrap secret; any future long-lived proof or bootstrap secrets use platform secure storage where available. +- The broker distinguishes verified Warp-terminal invocations from external invocations using an app-issued execution-context proof, not a caller-declared label. Until that broker exists, `InsideWarp` is a reserved protocol concept that must not receive credentials. +- External invocations are limited to a smaller logged-out-safe action set that does not touch user-authenticated data and cannot receive authenticated-user authority. +- Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control mode plus action policy allow authenticated-user actions from Warp terminals. +- The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. +- Every credential grants one exact action, and the app bridge verifies that action locally before selector resolution or handler dispatch. +- Every action has a documented `requires_authenticated_user` value and allowed execution contexts. New actions default to requiring an authenticated user unless explicitly reviewed as logged-out-safe. +- The Settings > Scripting mode gates invocation contexts; exact-action credentials, Agent/Profile policy, authenticated-scripting identity, target restrictions, and action-specific approval or audit requirements gate each request. +- Exact-action grants and approval policy are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. +- Remote control remains out of scope for the local same-machine credential model. +The first implementation slice should include the protected enablement gate, exact-action credential issuance, and app-side exact-action enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. +### 1. Protocol crate and stable envelope +Create a small shared protocol crate or equivalent shared module used by both the app server and the `warpctrl` command-mode client. It should define: +- A request protocol version used as a defensive schema guard for stale copied JSON, stale wrappers, and future external clients, not as a normal compatibility-negotiation mechanism between separately versioned CLI and GUI binaries. +- Discovery/health response types. +- Execution-context proof/request types for verified Warp-terminal invocations versus external invocations. +- Action metadata describing implementation status, `requires_authenticated_user`, allowed execution contexts, target families, and typed parameter/result contracts. +- Selector types: + - `InstanceSelector` + - `WindowSelector` + - `TabSelector` + - `PaneSelector` + - `SessionSelector` + - `BlockSelector` + - `FileSelector` + - `DriveObjectSelector` +- Opaque protocol-facing ID newtypes for instance/window/tab/pane/session identifiers. +- Allowlisted `ControlAction` variants and typed parameter payloads. +- Success/error envelopes with stable machine-readable error codes. +The protocol should treat target IDs as opaque. The app may encode existing runtime identifiers internally, but the public wire contract should not require callers to understand `EntityId`, `PaneId`, or other implementation types. +Recommended selector variants: +- `InstanceSelector`: `Active`, `Id(InstanceId)`, `Pid(u32)`. +- `WindowSelector`: `Active`, `Id(WindowId)`, `Index(u32)`, `Title(String)`. +- `TabSelector`: `Active`, `Id(TabId)`, `Index(u32)`, `Title(String)`. +- `PaneSelector`: `Active`, `Id(PaneId)`, `Index(u32)`. +- `SessionSelector`: `Active`, `Id(SessionId)`, `Index(u32)`. +- `BlockSelector`: `Id(BlockId)`. +- `FileSelector`: `Path { path, line, column }`. +- `DriveObjectSelector`: `Id(DriveObjectId)` or `Lookup { object_type, name_or_path }`. +Index selectors are resolved only within their parent selector context, so tab index resolution requires a resolved window and pane/session index resolution requires a resolved tab or pane. Title and name/path lookup selectors are ergonomic helpers for interactive use and must fail on ambiguity rather than choosing the first match. +Recommended top-level request shape for `tab.create` matches the shared `RequestEnvelope` and `Action` serde contract. The action kind and action-specific parameters are nested together under `action` so the allowlisted action name and parameter payload travel as one typed protocol field: +```json +{ + "protocol_version": 1, + "request_id": "client-generated-id", + "target": { + "window": "active" + }, + "action": { + "kind": "tab.create", + "params": {} + } +} +``` +Recommended success response shape matches `ResponseEnvelope` and the tagged `ControlResponse::Ok` variant: +```json +{ + "protocol_version": 1, + "request_id": "client-generated-id", + "response": { + "status": "ok", + "data": {} + } +} +``` +Recommended request-scoped error response shape matches `ResponseEnvelope` and the tagged `ControlResponse::Error` variant: +```json +{ + "protocol_version": 1, + "request_id": "client-generated-id", + "response": { + "status": "error", + "error": { + "code": "missing_target", + "message": "No active window is available", + "details": null + } + } +} +``` +Recommended decode-level error response shape for malformed requests that cannot be decoded into a full request envelope: +```json +{ + "protocol_version": 1, + "error": { + "code": "invalid_request", + "message": "Request body could not be decoded", + "details": null + } +} +``` +Error payloads should include stable codes defined in `SECURITY.md`, including `local_control_disabled`, `unauthorized_local_client`, `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, `execution_context_not_allowed`, `ambiguous_instance`, `ambiguous_target`, `stale_target`, `invalid_request`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, `missing_target`, and `no_instance`. Decode-level malformed JSON uses `invalid_request`; decoded actions with invalid action-specific parameters use `invalid_params`. +### 2. Per-process discovery instead of fixed-port-only routing +Keep the existing fixed-port HTTP behavior intact for installation detection/profiling compatibility. Add a separate local-control listener that follows the same native Axum/Tokio pattern but supports multiple local Warp app processes. +Recommended design: +- Each participating Warp process creates a random opaque `instance_id` at startup. +- Each process binds a loopback control listener on an ephemeral port or an app-managed available port. +- Each process writes a discovery record into a secure per-user Warp state directory. The record should contain: + - `instance_id` + - PID + - channel/build metadata + - control-listener endpoint + - protocol version + - start timestamp + - an instance-bound owner-authenticated broker-socket reference only when the selected mode allows outside-Warp control +- The CLI loads discovery records, rejects records unless the control endpoint is exactly `127.0.0.1` and the broker socket is the selected instance's expected filename inside the owner-only discovery directory, removes or ignores stale records after health and instance-identity checks, and chooses an instance using the product selector rules. +- `warpctrl instance list` is a CLI-first projection of this discovery registry plus health responses. +When outside-Warp control is disabled, discovery must follow `SECURITY.md`: either publish no actionable local-control record for external clients or publish only a minimal disabled-status record with no endpoint authority or credential reference. +This design preserves the current `9277` behavior while avoiding cross-process port contention for the new control API. +### 3. Local authentication, enablement, and safety boundary +Mutating localhost routes should not copy the permissive CORS posture of `/install_detection`. +Recommended local trust model: +- No browser-readable CORS allowance on control endpoints. +- The relevant Scripting mode must allow the request context before credentials are minted or sensitive control requests are accepted. In the current foundation branch that means outside-Warp only when the broadest mode is selected; future inside-Warp support must also verify the terminal proof. +- The authoritative mode must live in protected local storage and must not be writable by `warpctrl` or ordinary same-user preference/config edits. +- Per-instance raw credential material must be kept out of plaintext discovery records. The current foundation broker mints short-lived scoped credentials in memory only after authenticating the connecting Unix-socket peer. +- The CLI may load or request scoped credentials through an app-owned broker/helper, but it must not mint authority itself. +- The broker verifies whether the invocation originated from a Warp-managed terminal session before issuing in-Warp-only grants. +- The broker issues authenticated-user grants only when the selected app has a true logged-in Warp user and the selected mode plus action policy allow the grant. +- The app rejects disabled-state, missing, malformed, invalid, expired, or revoked credentials before selector resolution or mutation. +- The app rejects credentials issued for any action other than the requested action before selector resolution or mutation. +- The app maps every action to a `requires_authenticated_user` value and allowed execution contexts, rejecting mismatches before selector resolution or mutation. +- Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities, credentials, or sensitive target state. +This keeps the protocol local and scriptable without creating an ambient browser-to-localhost control surface. +Do not ship the first slice as a plaintext discovery bearer token, even for same-user human CLI use. The first slice is the foundation for sensitive reads, UI mutations, persistent configuration changes, and actions that mutate user data or execute code, so it must establish the protected enablement, credential storage, exact-action grant, and app-side enforcement model from `SECURITY.md`. +### 4. Future verified Warp-terminal invocation context +The current foundation branch does not implement verified inside-Warp invocation. `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` may remain in the shared protocol as reserved future concepts, but the credential broker must reject them until the proof broker described here exists. +Minimum implementable design: +- When Warp creates or Warpifies a terminal session, the app creates a high-entropy per-session capability and records verifier state in an app-owned terminal-session registry. +- The registry entry is bound to the selected app instance, terminal/session identifier, issuing process generation, expiry, and revocation state. +- The shell receives only proof material needed by `warpctrl`, such as an opaque handle plus a short-lived token or challenge-response input. Plain environment variables may carry handles or hints, but a caller-set variable must not be sufficient authority. +- `warpctrl` invoked from that terminal sends `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` to the owner-authenticated credential broker when it has proof material. Without proof material it must use `OutsideWarp`. +- The broker verifies the proof against the app-owned registry, including app instance, session liveness, expiry, revocation, and nonce or challenge binding before minting any inside-Warp scoped credential. +- The broker then checks Settings > Scripting mode and action-specific policy for the requested action. A valid proof raises the maximum eligible authority; it does not bypass user settings, exact-action enforcement, authenticated-user requirements, target scopes, or bridge enforcement. +- The minted credential records `invocation_context: InsideWarp`, the granted action, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. +Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, adding a Windows named-pipe equivalent to the current Unix-domain-socket peer-credential check, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. +### 5. Authenticated scripting identity +The full control catalog includes Warp Drive data mutation and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the logged-in Warp user allowed to request user-backed or high-risk actions. +#### Verified Warp-terminal authenticated scripting +For `warpctrl` launched inside a Warp-managed terminal, use the verified terminal proof broker from the previous section. When the proof is valid and the selected app is logged into Warp, the broker may mint an authenticated-user grant bound to the app's current user subject. The grant is available only if the selected Settings > Scripting mode and action policy allow authenticated-user actions for verified Warp terminals. +The CLI must not receive raw Firebase, OAuth, server, or session tokens. The app bridge executes authenticated actions through the selected app's existing auth state and rejects the grant if the app logs out, switches users, or the grant subject no longer matches the app user. +External invocations remain limited to logged-out-safe actions. External API-key authenticated scripting and `auth.api_key.*` commands are not part of the selected public contract; adding them requires a separate product/security review and catalog change. +#### Auth command surface +Add CLI and broker support for: +- `warpctrl auth status [selectors]` to report selected app login state and verified Warp-terminal authenticated grant availability without exposing secrets. +- `warpctrl auth login [selectors]` to focus the selected app's normal sign-in UI for interactive app login. +### 6. App-side request bridge onto the UI/application context +The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. +#### Thread model +- **Tokio runtime thread (HTTP handler):** Owns the Axum router, receives HTTP requests, validates context-specific enablement plus the transport credential's existence, expiry, and instance binding before deserializing the `RequestEnvelope`, then hands the decoded request and grant to the bridge. Cannot touch `AppContext`, views, or models. +- **Main app thread:** Owns all WarpUI entities (`App`, `AppContext`, views, models). All UI state reads and mutations must happen here. +- **Bridge:** Transfers a typed closure from the Tokio thread to the main thread, executes it with `&mut ModelContext`, and sends the return value back. +#### Implementation: `ModelSpawner` +The bridge uses WarpUI's `ModelSpawner<T>` mechanism, which is the standard way for background threads to schedule work on a model's main-thread context: +1. During app initialization, a `LocalControlBridge` singleton model is created. The model's `ModelContext::spawner()` method returns a `ModelSpawner<LocalControlBridge>` — a cloneable, `Send` handle that can enqueue closures from any thread. +2. The `ModelSpawner` is stored in the Axum router's shared state (`ControlServerState`), making it available to every HTTP handler. +3. When an HTTP request arrives, the handler calls `spawner.spawn(|bridge, ctx| { ... }).await`: + - `spawn` sends a boxed `FnOnce(&mut LocalControlBridge, &mut ModelContext<LocalControlBridge>) -> R` closure through an `async_channel` to the main thread's task-callback loop. + - The main thread dequeues the closure, constructs a fresh `ModelContext` for the bridge model, and calls the closure. + - Inside the closure, the bridge has full access to `ModelContext`, which derefs to `AppContext`. This means it can call `ctx.windows()`, `ctx.views_of_type::<Workspace>(window_id)`, `workspace.update(ctx, ...)`, and any other main-thread API. + - The closure returns a typed result (e.g., `ResponseEnvelope`), which is sent back to the Tokio thread via a `oneshot` channel. +4. The HTTP handler awaits the oneshot result and serializes it as the HTTP response. +#### Concrete flow for `tab.create` +``` +HTTP handler (Tokio thread) + │ + ├─ verify inside-Warp or outside-Warp context is enabled + ├─ verify credential existence, expiry, and instance binding + ├─ deserialize RequestEnvelope after transport credential lookup + ├─ call bridge_spawner.spawn(move |bridge, ctx| { + │ bridge.handle_request(request, ctx) // runs on main thread + │ }).await + │ + └─ serialize ResponseEnvelope as JSON + +LocalControlBridge::handle_request (main thread) + │ + ├─ verify protected local-control mode still allows the context + ├─ verify the presented credential grants the exact requested action + ├─ map action to authenticated-user and execution-context requirements + ├─ verify target restrictions, execution context, and authenticated-user access + ├─ match request.action.kind + │ └─ ActionKind::TabCreate + │ ├─ validate_tab_create_target(&request.target) + │ ├─ ctx.windows().active_window() + │ │ └─ if none: resolve the sole window, or return missing_target / ambiguous_target + │ ├─ ctx.views_of_type::<Workspace>(window_id) + │ └─ workspace.update(ctx, |workspace, ctx| { + │ workspace.handle_action( + │ &WorkspaceAction::AddTerminalTab { hide_homepage: false }, + │ ctx, + │ ) + │ }) + │ + └─ return ResponseEnvelope::ok(request_id, json!({ ... })) +``` +#### Why this pattern +- **Thread safety.** WarpUI's entity/view system is not `Send` or `Sync`. The only safe way to interact with it from a background thread is through `ModelSpawner`, which serializes access through the main event loop. +- **Synchronous result.** Unlike fire-and-forget patterns (e.g., URI intent dispatch in `app/src/uri/mod.rs`), the `spawn` call returns a concrete `Result<R, ModelDropped>`, so the HTTP handler can produce a structured success or error response. +- **Reuses existing infrastructure.** `ModelSpawner` is already used throughout the codebase for background-to-main-thread communication (e.g., async file I/O results, network responses). No new concurrency primitive is needed. +- **Action dispatch reuses existing app behavior.** The bridge calls `workspace.handle_action(&WorkspaceAction::AddTerminalTab { ... }, ctx)` — the exact same method the UI keybinding system uses. This ensures the control CLI produces identical behavior to the corresponding user action, including side effects like tab count updates, focus changes, and event emissions. +- **Deterministic targeting.** The bridge may use an active/default window selector for mutating actions only when the target is deterministic: first use the active Warp window, then fall back to the sole window if exactly one window exists. If no window exists, return `missing_target`; if more than one window exists and none is active, return `ambiguous_target`. If future command forms allow explicit window IDs, resolve the explicit ID exactly or return `stale_target`. +#### Adding new action handlers +To add a new action to the bridge: +1. Add an entry to the macro-backed `ActionKind` catalog in `crates/local_control/src/catalog.rs`. +2. Document its `SECURITY.md` authenticated-user requirement, allowed execution contexts, target restrictions, and any action-specific approval or audit policy. +3. Add a match arm in `LocalControlBridge::handle_request` in `app/src/local_control/mod.rs`. +4. Before selector resolution or dispatch, verify local control is enabled and the presented credential grants the exact action, target family, execution context, and authenticated-user access if required. +5. Inside the match arm, use `ctx` (which is a `&mut ModelContext<LocalControlBridge>` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. +6. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. +The bridge closure has access to the full `AppContext` API surface, including `ctx.windows()`, `ctx.window_ids()`, `ctx.views_of_type::<T>(window_id)`, `handle.update(ctx, ...)`, and `handle.read(ctx, ...)`. This makes it straightforward to wire new actions to existing UI behavior without introducing new concurrency concerns. +### 7. Target resolution model +Implement target resolution as a reusable component rather than scattering lookup logic across handlers. +Recommended resolution order: +1. Select instance in the CLI/discovery layer. +2. Resolve window inside the target process. +3. Resolve tab within the window. +4. Resolve pane within the tab/pane-group context. +5. Resolve session only for session-scoped commands. +6. Resolve block/file/Drive selectors only for commands whose action metadata declares that target family. +Selector behavior: +- `active` resolves from current app focus/selection state. For window-scoped mutations in the first slice, a missing active window may resolve to the sole existing window because that target is still unambiguous; zero matching windows return `missing_target`, and multiple windows without an active window return `ambiguous_target`. +- Explicit opaque IDs must resolve exactly or return `stale_target`. +- Index selectors are allowed only for user-visible indexed concepts and should resolve to a concrete opaque ID before execution. +- Title, name, and path selectors are convenience selectors. They must be exact by default, document any future fuzzy behavior explicitly, and return `ambiguous_target` when more than one target matches. +- A session-scoped request against a non-terminal pane returns `target_state_conflict`. +Target resolution must happen after protected enablement, authentication, and exact-action grant checks. This prevents denied requests from learning more target state than necessary and keeps enforcement centralized. +Implementation references: +- Window-level active selection already exists inside the app through `WindowManager`. +- Pane scoping can build on the conceptual model of `PaneViewLocator` in `app/src/workspace/util.rs (12-18)`. +- Existing URI intent routing in `app/src/uri/mod.rs (895-1093)` shows how to locate workspaces/windows and avoid silently acting in the wrong place. +#### CLI selector grammar +The current foundation branch only needs the instance selector flags that are implemented by the first-slice CLI: +- Instance selectors: `--instance <instance_id>` and `--pid <pid>`, with clap conflicts. +The shared target selector CLI group for windows, tabs, panes, sessions, blocks, files, and Drive objects is deferred to the `zach/warp-cli-v2/readonly-capability-targets` branch, where those target families are first exposed through read-only metadata commands. That later branch should add: +- Window selectors: `--window <active|id:<id>|index:<n>|title:<title>>`, `--window-id <id>`, `--window-index <n>`, and `--window-title <title>`, with one form allowed. +- Tab selectors: `--tab <active|id:<id>|index:<n>|title:<title>>`, `--tab-id <id>`, `--tab-index <n>`, and `--tab-title <title>`, with one form allowed. +- Pane selectors: `--pane <active|id:<id>|index:<n>>`, `--pane-id <id>`, and `--pane-index <n>`, with one form allowed. +- Session selectors: `--session <active|id:<id>|index:<n>>`, `--session-id <id>`, and `--session-index <n>`, with one form allowed. +- Block/file/Drive selectors only on commands that need them: `--block-id <id>`, path arguments or `--path <path>` plus `--line`/`--column`, and Drive object ID arguments or `--drive-id <id>`. +As each selector family is added, the CLI converts those flags into the protocol `TargetSelector` before sending the request. It must not rely on positional entity IDs for commands like `window close 1`; target entities are selected through shared selector flags so command arguments remain reserved for action parameters. +### 8. Allowlisted handler families +Use one handler module per action family. The protocol layer owns parsing/validation; handler modules own target resolution and delegation to existing app logic. +Recommended modules/families: +- Discovery/state: + - instances, version, active chain, windows/tabs/panes/sessions listings. +- Window/tab: + - new, focus, close, activate, move, rename, color, close variants. +- Pane: + - split, focus, navigate, close, maximize, resize. +- Input/session: + - insert, replace, clear, run command, cycle session, mode switch where supported. +- Appearance/settings: + - theme list/set, system-theme controls, font/zoom actions, allowlisted settings reads/writes/toggles. +- Panels/surfaces: + - settings/page/search, palettes, left/right panels, Drive, resource center, code review, vertical tabs, AI assistant. +- Files: + - app-state-only path opening and metadata reads for files already open in Warp. File content reads and filesystem-content mutations are intentionally excluded from the public `warpctrl` catalog. +- Warp Drive: + - object listing/inspection/opening, object creation/update/delete/insert, opening the share dialog, the v0 personal-to-team share mutation, and typed workflow execution where supported. +Do not use a generic “dispatch action by string” endpoint. Every handler should be reachable only through an explicit `ControlAction` variant. +#### Future WarpCtrlBehavior review gate +The public `ControlAction` catalog remains the source of truth for the wire protocol, CLI parser, authorization metadata, generated documentation, and app bridge handlers. Internal app actions such as `WorkspaceAction`, `TerminalAction`, `PaneGroupAction`, settings actions, and future user-visible action enums must not become the public protocol directly because they can contain transient view locators, indices, debug-only variants, implementation-specific payloads, and unstable internal semantics. +The exhaustive `WarpCtrlBehavior` review mapping is not a current foundation-branch requirement. It should land in a later action-review or `zach/warp-cli-v2/cli-catalog-docs` branch after the public catalog and generated docs surface are mature enough to enforce it consistently. Once added, the mapping is a code-level forcing function, not an automatic exposure mechanism. It answers whether each internal app action is: +- `Exposed` through a specific public `ControlAction` kind. +- `CoveredBy` an existing public `ControlAction` kind because several internal actions map to one stable CLI behavior. +- `Excluded` with an explicit reason such as debug-only, unsafe/privileged, internal implementation detail, not user-visible, no deterministic targeting model, no stable public semantics, or prohibited in the initial public version. +- `Deferred` with an explicit reason and tracking issue when the action might belong in `warpctrl` later but needs additional product, security, selector, or protocol design. +Future `WarpCtrlBehavior` implementations must use exhaustive matches without wildcard arms. Adding a new variant to a reviewed action enum should fail compilation until the developer or agent deliberately classifies its relationship to `warpctrl`. This mirrors the existing exhaustive-action-review style used by app-state saving decisions and makes “should this exist in Warp Control?” part of the ordinary code path for adding new user-visible actions. +Recommended shape: +- Define a shared `WarpCtrlBehavior` trait in the local-control integration layer or another app-visible module that does not force the core `warpui::Action` blanket implementation to change. +- Define review enums such as `WarpCtrlActionReview`, `WarpCtrlExclusionReason`, and `WarpCtrlDeferredReason`. +- Implement `WarpCtrlBehavior` for the major user-visible action enums, starting with `WorkspaceAction` and `TerminalAction`. +- Keep the mapping one-way from internal behavior to public catalog metadata. `WarpCtrlBehavior::Exposed(ControlActionKind::TabCreate)` means the action is represented by the public `tab.create` command; it does not mean raw `WorkspaceAction::AddTerminalTab` is serializable or dispatchable over the protocol. +- Add tests that collect reviewed action kinds and verify every `ControlActionKind` has protocol metadata, authorization metadata, CLI parser coverage, generated-doc coverage, and an app-side handler before it can be advertised as supported. +The `warpui::Action` trait should not be extended for this purpose because it currently has a blanket implementation for any `Any + Debug + Send + Sync` type. The enforcement point is the concrete user-visible action enums and binding/action registration surfaces, where exhaustive review can be required without weakening the allowlisted protocol boundary. +### 9. First slice: prove discovery and `tab.create` +The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: +- Shared protocol types and error envelopes. +- `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. +- New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes one local-control mode with disabled as the default, enabled within Warp as a reserved mode that rejects requests until proof support exists, and enabled everywhere as the only mode that allows outside-Warp credential requests. +- Protected local-only mode storage where outside-Warp control defaults off unless the broadest mode is selected. +- The local-control mode lives in the typed `LocalControlSettings` group as a private setting with `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`. This keeps it out of the user-visible settings file and generated settings schema. It is persisted only through Warp's secure-storage provider, never imports a value from ordinary or private preferences, and fails closed to disabled when no valid protected value is available. +- Discovery registry and CLI instance selection. +- A `warpctrl` wrapper entrypoint that invokes the existing channel-specific Warp binary with a hidden `--warpctrl` control-mode flag and runs control commands without starting the GUI app runtime. +- Per-process authenticated local-control server that refuses sensitive work when outside-Warp control is disabled and rejects inside-Warp credential requests until verified terminal proof support is implemented. +- Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. +- App-side request bridge and selector resolver. +- Exact-action credential issuance and app-side exact-action enforcement. +- Action metadata for `tab.create` that deliberately marks it logged-out-safe and outside-Warp-only in the foundation slice. +- Read-only `ping/version` plus `warpctrl instance list` or equivalent minimal discovery command. +- End-to-end `warpctrl tab create` for the selected instance, reusing the same app behavior as the user-visible new-terminal-tab action. +Why `tab.create` first: +- It proves a UI/layout action can be targeted and executed against live app state. +- It exercises process discovery, local authentication, request bridging, selector defaults, app-context dispatch, and structured success/error output without introducing higher-risk terminal input execution. +- It exercises the protected enablement and scoped-grant model before higher-risk action families depend on it. +- It gives operators a concise end-to-end smoke test: discover a running instance, create a tab, and confirm the live app changed. +The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight control-mode startup path inside the existing Warp binary that dispatches before GUI startup. +### 10. Follow-up slices: fill out the remaining protocol in parallel +After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: +- Extend the macro-backed action catalog. +- Once the later review gate lands, update the relevant `WarpCtrlBehavior` mappings for the internal app actions that implement, overlap with, exclude, or defer the behavior. +- Add typed params/results. +- Add a handler. +- Add validation/tests. +- Add CLI surface/tests. +### 11. CLI parsing and output libraries +The `warpctrl` CLI must use the same argument parsing and output libraries as the existing Oz CLI so that conventions, derive patterns, and shell-completion generation remain consistent across both command surfaces. +- **clap** (with the `derive` feature) for argument parsing, subcommand trees, and help generation. Oz and `warpctrl` share the `warp_cli` crate, so parser types defined there are reused directly. +- **serde** / **serde_json** for JSON request/response serialization and for `--output-format json` output. +- **clap_complete** for shell completion generation, reusing the same infrastructure the Oz CLI uses. +- The `OutputFormat` enum (`Pretty`, `Json`, `Ndjson`, `Text`) is shared from `warp_cli::agent::OutputFormat` so human-readable vs. machine-readable output follows the same conventions. +- New subcommand types for `warpctrl` live in `warp_cli::local_control` and follow the same `#[derive(Parser)]` / `#[derive(Subcommand)]` / `#[derive(Args)]` patterns used by the Oz CLI's top-level `Args` and `CliCommand` types. +Do not introduce alternative parsing libraries (e.g., `structopt`, `argh`) or alternative serialization approaches. Keeping one set of libraries across both command surfaces reduces dependency weight, ensures consistent `--help` formatting, and lets contributors move between the two surfaces without learning a different stack. +### 12. CLI packaging and release shape +The shipped product shape should be a bundled `warpctrl` wrapper script or helper that calls the existing channel-specific Warp binary with a hidden `--warpctrl` flag. It should match the Oz app-bundle model: users invoke `warpctrl ...`, while the wrapper delegates to the real Warp executable that already carries channel identity, embedded resources, signing, and release metadata. +- macOS: + - Add a `Resources/bin/warpctrl` wrapper next to the existing Oz wrapper script in the app bundle. + - The wrapper should use the same pattern as Oz: compute its script directory, `exec -a "$0"` the channel binary in `Contents/MacOS`, and append the hidden `--warpctrl` flag before forwarding user arguments. + - Keep channelized naming consistent with the final product name decision; if non-stable channels need aliases, the aliases should still point at the same channel app binary. +- Linux: + - Prefer installing a small `warpctrl` wrapper or symlink/helper in the same package as the Warp app, routed to the packaged channel binary with `--warpctrl`. + - Do not build a separate standalone Rust binary for `--artifact warpctrl`; the standalone validation artifact emits a wrapper plus the channel binary it forwards to and compiles that binary with `warp_control_cli`. + - Installing the wrapper into the normal Linux app package remains a follow-up separate from the standalone validation artifact. +- Windows: + - Mirror the existing installer-generated helper-wrapper pattern first. + - If Windows cannot cheaply use a shell-script-style wrapper, generate the smallest possible helper that forwards to the installed channel binary with `--warpctrl` and preserves stdout/stderr behavior for scripts. +Startup and dependency expectations: +- `app/src/lib.rs` should recognize `--warpctrl` before app launch and route into `warp_cli::local_control` just as current CLI commands route before GUI launch. +- The control-mode path should initialize only command parsing, discovery, authentication material loading, protocol serialization, HTTP transport, output formatting, and any minimal shared state needed for channel/version/feature evaluation. +- The control-mode path should not initialize GUI state, rendering, terminal session models, app workspaces, or other main-app-only subsystems. +- Startup cost should be treated as part of the product contract because control commands are expected to compose naturally in scripts and repeated interactive shell usage. Add a focused startup-path regression test or smoke check so the wrapper path stays close to Oz command latency. +Naming decision: +- Product examples use provisional `warpctrl ...` command lines for the local-control wrapper. +- Final wrapper names, channelized aliases, and installer exposure should be chosen before broad rollout to avoid churn in bundle scripts, docs, shell completions, and release workflow files. +## Implementation Plan +### Branch stack +Use raw git for the stack; do not use Graphite for these branches. +The active durable review stack is the recovered `zach/warp-cli-v2/*` stack. This stack is the review architecture for the current implementation because it preserves the fan-in work while slicing it into branch-sized review boundaries. The older branch names in the pre-recovery plan are historical source material only and should not be used as the active PR stack. +Spec ownership is part of the branch architecture. The only v2 branch that may intentionally change `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, or `README.md` is `zach/warp-cli-v2/contract-spec-sync`. After a spec change lands there, propagate it upward through every higher v2 branch with raw git rebases so those files remain byte-identical across the stack. Higher implementation branches must not make independent spec edits except when resolving a propagation conflict in a way that preserves the bottom-branch content. +The intended v2 stack is: +1. `zach/warp-cli-v2/contract-spec-sync` — create from `origin/master`. It owns the product, technical, security, and README specs plus the shared contract/foundation: protocol crate shape, discovery/auth scaffolding, initial instance selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, `warpctrl` wrapper/control-mode entrypoint, packaging hooks, module split, and the minimum first-slice smoke path needed to prove the end-to-end architecture. +2. `zach/warp-cli-v2/auth-security` — create from `zach/warp-cli-v2/contract-spec-sync`. It owns authentication and security enforcement that applies across command families: scoped grants, execution-context policy, authenticated-user plumbing, denial paths, Settings > Scripting security controls, and tests proving high-risk actions cannot run without the required identity and exact-action grant. +3. `zach/warp-cli-v2/readonly-capability-targets` — create from `zach/warp-cli-v2/auth-security`. It owns structural metadata reads, capability/action metadata, target selector parsing and resolution, opaque IDs, active window/tab/pane/session targeting, exact-action checks for structural metadata reads, and read-only CLI output for these surfaces. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. +4. `zach/warp-cli-v2/appstate-file-drive-views` — create from `zach/warp-cli-v2/readonly-capability-targets`. It owns read-only app-state, file view, Drive view, block/input/history-style underlying-data read surfaces that are approved for the public catalog, along with the exact-action checks for content-bearing reads. It must keep local filesystem content reads/writes outside the v0 public catalog unless the specs are updated first. +5. `zach/warp-cli-v2/metadata-config-mutations` — create from `zach/warp-cli-v2/appstate-file-drive-views`. It owns metadata/configuration mutations: allowlisted settings changes, labels/titles/appearance/configuration updates, settings or surface-opening commands that are metadata/configuration rather than underlying-data mutations, and tests proving unallowlisted or private settings are rejected. +6. `zach/warp-cli-v2/drive-data-mutations` — create from `zach/warp-cli-v2/metadata-config-mutations`. It owns authenticated underlying-data mutations for Warp Drive objects, including typed object create/update/delete/insert and the approved v0 personal-to-team sharing path. It must use disposable resources in tests and must not implement local file content reads, writes, appends, deletes, or other filesystem-content mutations. +7. `zach/warp-cli-v2/execution-underlying` — create from `zach/warp-cli-v2/drive-data-mutations`. It owns authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require an exact execution-action grant plus authenticated scripting identity, deterministic target resolution, audit records, and tests proving accepted-command submission and agent-prompt submission remain unavailable. +8. `zach/warp-cli-v2/cli-catalog-docs` — create from `zach/warp-cli-v2/execution-underlying`. It owns the final CLI/catalog/docs integration pass: generated or curated command catalog output, help/completion polish, user-facing docs, Agent skill updates, command-family documentation, future `WarpCtrlBehavior` action-review scaffolding if it has not landed earlier, and consistency checks that every advertised action has protocol metadata, authorization metadata, parser coverage, handler coverage, and tests. +9. `zach/warp-cli-v2/fanin-finalize` — create from `zach/warp-cli-v2/cli-catalog-docs`. It owns fan-in cleanup only: conflict-resolution preservation, naming/format consistency, final test fixes, validation matrix updates, and integration fixes required for the recovered stack to compile and pass tests. It should not introduce broad new command families. +Recommended raw-git setup for a clean local reconstruction: +```bash +git fetch origin +git checkout -b zach/warp-cli-v2/contract-spec-sync origin/master +git checkout -b zach/warp-cli-v2/auth-security +git checkout -b zach/warp-cli-v2/readonly-capability-targets +git checkout -b zach/warp-cli-v2/appstate-file-drive-views +git checkout -b zach/warp-cli-v2/metadata-config-mutations +git checkout -b zach/warp-cli-v2/drive-data-mutations +git checkout -b zach/warp-cli-v2/execution-underlying +git checkout -b zach/warp-cli-v2/cli-catalog-docs +git checkout -b zach/warp-cli-v2/fanin-finalize +``` +If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch in stack order. Resolve conflicts by preserving the lower branch's shared contracts, exact-action authorization model, and spec files while also keeping the higher branch's owned behavior. +The previous `zach/warp-cli-integration-fanin` branch and its backup are preservation/history refs for the integrated implementation work. They are not review branches. Earlier non-v2 proposed branch names and the older broad stacks are migration-source/history material only unless the stack is deliberately renamed in a future explicit reslicing. +### Feature flag and rollout gate +The entire feature should be gated behind a Warp feature flag, proposed as `FeatureFlag::WarpControlCli` with Cargo feature `warp_control_cli`. +Implementation should follow the existing runtime feature-flag conventions: +- Add `warp_control_cli = []` under `[features]` in `app/Cargo.toml`, not under the default feature set until launch. +- Add `WarpControlCli` to the `FeatureFlag` enum in `crates/warp_features/src/lib.rs`. +- Add the `#[cfg(feature = "warp_control_cli")] FeatureFlag::WarpControlCli` entry in `app/src/features.rs` so the compile-time feature initializes the runtime flag. +- Enable the flag for dogfood or preview by adding it to `DOGFOOD_FLAGS` or `PREVIEW_FLAGS` only when the rollout plan calls for that exposure. +- Prefer runtime checks with `FeatureFlag::WarpControlCli.is_enabled()` over broad `#[cfg]` gates except where code cannot compile without the Cargo feature. +When `FeatureFlag::WarpControlCli` is disabled in the Warp app: +- the Scripting settings page should not expose Warp control settings; +- `LocalControlSettings` should not register user-visible controls for Warp control; +- the app should not create `LocalControlBridge` or `LocalControlServer`; +- no local-control discovery record should be written; +- no `/v1/control` local server endpoint or credential-broker socket should be exposed; +- command-palette/keybinding entries related specifically to installing, configuring, or using `warpctrl` should be hidden; +- tests should cover both enabled and disabled flag states with the repo's normal feature-flag override helpers. +The `warpctrl` wrapper may still be installed in a build where the app feature is disabled, but the hidden control-mode entrypoint should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. +### Merge and review strategy +Keep PR boundaries aligned with the v2 stack: +- PR1: `zach/warp-cli-v2/contract-spec-sync` into `master` for specs, shared contracts, protocol, CLI skeleton, settings, bridge, module scaffolding, and first-slice smoke behavior. +- PR2: `zach/warp-cli-v2/auth-security` into `zach/warp-cli-v2/contract-spec-sync` or its merged successor for auth/security enforcement, execution-context policy, scoped grants, and Settings > Scripting security controls. +- PR3: `zach/warp-cli-v2/readonly-capability-targets` into `zach/warp-cli-v2/auth-security` or its merged successor for metadata reads, capabilities, target selectors, and read-only structural command output. +- PR4: `zach/warp-cli-v2/appstate-file-drive-views` into `zach/warp-cli-v2/readonly-capability-targets` or its merged successor for approved content-bearing read surfaces, app-state/file/Drive views, and exact-action denial tests. +- PR5: `zach/warp-cli-v2/metadata-config-mutations` into `zach/warp-cli-v2/appstate-file-drive-views` or its merged successor for metadata/configuration mutations, allowlisted settings changes, and configuration-denial tests. +- PR6: `zach/warp-cli-v2/drive-data-mutations` into `zach/warp-cli-v2/metadata-config-mutations` or its merged successor for authenticated Warp Drive underlying-data mutations. +- PR7: `zach/warp-cli-v2/execution-underlying` into `zach/warp-cli-v2/drive-data-mutations` or its merged successor for authenticated execution-underlying actions. +- PR8: `zach/warp-cli-v2/cli-catalog-docs` into `zach/warp-cli-v2/execution-underlying` or its merged successor for catalog, docs, help/completion, Agent skill, and advertised-action consistency work. +- PR9: `zach/warp-cli-v2/fanin-finalize` into `zach/warp-cli-v2/cli-catalog-docs` or its merged successor for final cleanup, preserved fan-in conflict resolutions, and validation fixes. +If a lower PR merges before higher branches are ready, rebase the next branch onto the merged successor of its base and continue upward through the stack. Use raw git for all rebases, conflict resolution, cherry-picks, and pushes. Do not open PRs from `zach/warp-cli-integration-fanin` or the old non-v2 branch names unless a future explicit reslicing replaces the v2 architecture. +## End-to-end flow +```mermaid +sequenceDiagram + participant CLI as Warp control CLI + participant REG as Local discovery registry + participant PROC as Selected Warp process + participant BROKER as Credential broker + participant HTTP as Local control listener + participant BRIDGE as App request bridge + participant RES as Target resolver + participant ACT as Allowlisted action handler + participant UI as Live Warp app state + + CLI->>REG: Read local instance records + CLI->>PROC: Health/protocol check for candidates + PROC-->>CLI: Instance metadata + defensive schema status + CLI->>CLI: Resolve instance selector + CLI->>BROKER: Request scoped credential for action + execution context + BROKER-->>CLI: Grant or structured denial + CLI->>HTTP: Authenticated POST tab.create request + HTTP->>HTTP: Verify context-specific enablement + credential + execution context + HTTP->>BRIDGE: Typed request + response channel + BRIDGE->>BRIDGE: Recheck enablement + exact action + auth-user policy + BRIDGE->>RES: Resolve window/tab/pane/session selectors + RES-->>BRIDGE: Concrete target handles or typed error + BRIDGE->>ACT: Execute allowlisted ControlAction + ACT->>UI: Reuse existing tab creation behavior + UI-->>ACT: Mutation/read result + ACT-->>BRIDGE: Typed result + BRIDGE-->>HTTP: Response envelope + HTTP-->>CLI: JSON success/error response + CLI-->>CLI: Pretty or JSON output +``` +## Testing and validation +Map tests directly to `PRODUCT.md` behavior. +- Catalog/parser implementation-status invariant: + - `ActionImplementationStatus::Implemented` means the action is complete enough for wrapper-script CLI users in the selected build: it has a parseable `warpctrl ...` command route, generated help/completion/docs coverage, a protocol parameter mapping, and an app-side bridge handler. + - Catalog entries that have only an internal app handler, only protocol metadata, or only a planned product command must remain `Stub` until the wrapper-backed CLI route and generated surfaces ship. + - Tests must enumerate the implemented catalog and prove each implemented action has at least one parseable `warpctrl ...` CLI example that maps to the same `ActionKind`. + - Tests must also prove each shipped parser route maps to an allowlisted catalog action and that help/completion generation includes the implemented route. `action list --implemented-only`, `capability list --implemented-only`, discovery metadata, shell completions, generated docs, and app-side bridge support must not drift silently from one another. +- Security architecture: + - Protected mode tests proving outside-Warp control defaults off, disabled and inside-only modes reject outside-Warp credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`, the broadest mode allows outside-Warp credential issuance, and current inside-Warp credential requests are rejected with `execution_context_not_allowed`. + - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. + - Credential-storage tests proving raw credentials are not written into plaintext discovery records. + - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. + - Exact-action enforcement tests proving a valid credential for one action fails with `insufficient_permissions` when used for any other action, before selector resolution or handler dispatch. + - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. + - External-context tests proving authenticated-user actions remain unavailable outside verified Warp-terminal invocations and fail before selector resolution or handler dispatch. + - Settings > Scripting tests proving mode changes invalidate credentials and prevent new grants for invocation contexts no longer allowed. + - Structured-error tests for protocol and runtime local-control failures such as disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed local-control request payloads, ambiguous targets, missing targets, stale targets, and invalid selectors. Clap parser usage errors are allowed to follow the parser's normal CLI error behavior unless a later branch explicitly wraps them. +- Behavior 1-6, 29-31: + - Protocol version/unit tests. + - Discovery-registry tests with zero, one, multiple, stale, and incompatible instance records. + - Local-auth tests for missing, invalid, expired, revoked, and valid credentials. + - Grant-lifecycle tests for instance binding, expired-grant pruning, and the active-grant cap. Define and enforce a replay policy before broader or higher-risk command families ship. +- Behavior 7-13: + - Selector-resolution unit tests for active, explicit ID, index, stale target, ambiguous target, and non-terminal session target. + - Tests that no lower-level selector silently retargets after an explicit stale selector fails. + - In the current foundation branch, CLI selector parsing tests only need to cover the implemented instance selectors `--instance` and `--pid` plus their conflict behavior. + - The `zach/warp-cli-v2/readonly-capability-targets` branch should add CLI selector parsing tests for every generic and explicit alias form it introduces: `--window`, `--window-id`, `--window-index`, `--window-title`, `--tab`, `--tab-id`, `--tab-index`, `--tab-title`, `--pane`, `--pane-id`, `--pane-index`, `--session`, `--session-id`, and `--session-index`. + - As each selector family lands, CLI conflict tests should prove only one selector form per entity family is accepted and that positional target IDs are rejected where the command expects selector flags. +- Behavior 15-28: + - Parser/serde tests for every first-slice `ControlAction` variant. + - Router tests proving unknown/unallowlisted actions are rejected. + - CLI parse/output tests for pretty and JSON rendering. +- Behavior 18 and 33: + - App-side tests for `tab.create` using existing workspace/tab helpers or a narrow extracted helper. + - Manual local verification that `warpctrl tab create` creates a terminal tab in a running app. +- Behavior 30: + - Multi-process integration-style coverage using two synthetic discovery records and mock health responders, plus manual testing with multiple channel builds where practical. +- Packaging: + - Bundle smoke tests or script-level checks for each supported platform path touched by the first slice, proving the `warpctrl` wrapper is installed and forwards to the channel binary with `--warpctrl`. + - Startup-path tests or focused checks confirming `warpctrl` dispatches commands through early control-mode routing without entering GUI-app launch code. + - Shell completions/help output checks once final command naming is selected. +### Computer-use CLI verification +Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build plus its packaged `warpctrl` wrapper from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. +The verifier must launch the built Warp app, enable the Scripting settings and satisfy the direct policy requirements for the command under test, and capture screenshots or screen recordings that prove both the terminal invocation and the user-visible result of each basic command family. Every `warpctrl` invocation executed during verification must have a durable screenshot captured immediately after the command runs, showing the exact command line and full terminal output in either pretty or JSON mode. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, command name, and whether the image shows terminal output or app UI state. +Verification screenshots should make the cause and effect visible in a single image whenever possible. The preferred composition is a staggered two-window layout where the terminal running the `warpctrl` command remains visible and unobscured, while the target Warp window or terminal is also visible enough to prove the UI state before or after the command. For outside-Warp invocations, use one external terminal window for the CLI command and one built Warp app window, staggered so the screenshot shows both the command/output and the Warp UI result. For inside-Warp invocations, use two Warp terminal windows or panes when possible: one Warp terminal running `warpctrl` and a second Warp terminal or Warp window showing the target/result state, staggered so both are visible in the same screenshot. Avoid screenshots that show only the CLI terminal or only the Warp UI when a combined view can be captured. +Before/after screenshots for visible mutations should preserve the same staggered layout so reviewers can compare the command context and UI state directly. If a single combined screenshot is not possible because of window-manager, display-size, or focus limitations, the verifier must capture paired screenshots with the same ordinal: one terminal-output screenshot that fully shows the command and output, and one UI screenshot that shows the resulting Warp state. The manifest entry should explain why the combined composition was not possible. Screenshots should not crop out the command, exit status, selected Warp target, or relevant visible UI effect. +Before every computer-use scenario, the verifier must explicitly ask and answer, "What is the best way to show the impact of this CLI command?" The verifier should then put Warp into a state where the expected effect is clearly visible before running the command. For example, syntax-highlighting changes should start with recognizable text in the input editor that will visibly change; font-size and zoom changes should start with enough terminal text or UI chrome to compare scale; tab or pane rename/color commands should keep the affected tab or pane label visible; app-state mutation commands should make the target workspace, tab, pane, input box, or surface visible; and denial paths should show the relevant Settings > Scripting state or target state that makes the denial meaningful. Each manifest entry for a visible or user-facing command should describe the chosen proof setup, the expected visual effect, and any setup screenshot used to establish the before state. +After each command that has a visible or user-facing result, the verifier must use computer vision on the captured screenshot or screen recording to inspect whether the visible Warp state matches the expected effect. The verifier should record the visual inspection result in the manifest, including unexpected UI changes, missing visual evidence, ambiguous screenshots, focus/onboarding artifacts, or differences between JSON success and the visible app state. JSON success alone is not sufficient for visible-effect validation; if the screenshot does not clearly prove the expected effect, the case should be marked failed or blocked with an explanation, even when the CLI response is successful. +The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected because proof verification does not exist yet, while the default disabled mode blocks all contexts until the user changes it. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and Settings > Scripting mode gates the invocation context. The outside-Warp path must run the packaged `warpctrl` wrapper from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after selecting the broadest mode in Settings > Scripting. +The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: +- read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; +- content-bearing read commands show successful CLI output only with a credential for that exact action, plus terminal screenshots proving another action credential is denied; +- app-state mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the visible Warp UI changed; +- metadata/configuration mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the persisted setting or label changed; +- commands that mutate user data or execute code run only in a disposable test workspace/session with test Warp Drive objects, show terminal screenshots proving a different action credential is denied, then show terminal screenshots and any relevant app/file/Drive state evidence for success with the exact action grant and required approval; +- authenticated-user commands show terminal screenshots for both the logged-out or missing-grant denial path and the enabled authenticated path when a test account/environment is available. +The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required exact action and direct policy requirements, expected result, actual result, terminal screenshot artifact path, UI screenshot artifact path when applicable, and any skipped case with a reason. Missing terminal screenshots for any executed `warpctrl` invocation block review readiness. Missing UI screenshots for visible commands also block review readiness. +## Parallelization +The durable review stack should remain linear, but implementation can still happen in parallel with Oz cloud agents. For the recovered v2 stack, parallel work should feed short-lived shard branches that are merged or cherry-picked into exactly one v2 review branch by the lead integrator. Shard branches should not become long-lived PRs by default. +The completed recovery used fan-in shard work as source material, then sliced it into the v2 stack. Future parallel work should use the same contract-first fan-out pattern: +- Start from the lowest v2 branch that already contains the contracts needed by the shard. +- Give each shard a single owned command family or authorization boundary. +- Keep `specs/warp-control-cli/*` unchanged on shards unless the shard explicitly starts from and targets `zach/warp-cli-v2/contract-spec-sync` for a spec update. +- Have the lead integrator merge or cherry-pick shard work into the appropriate v2 branch, then rebase all higher v2 branches upward. +Suggested shard-to-stack ownership: +- `zach/warp-cli-shard/auth-security` feeds `zach/warp-cli-v2/auth-security`. +- `zach/warp-cli-shard/readonly-capability-targets` feeds `zach/warp-cli-v2/readonly-capability-targets`. +- `zach/warp-cli-shard/appstate-file-drive-views` feeds `zach/warp-cli-v2/appstate-file-drive-views`. +- `zach/warp-cli-shard/metadata-config-mutations` feeds `zach/warp-cli-v2/metadata-config-mutations`. +- `zach/warp-cli-shard/drive-data-mutations` feeds `zach/warp-cli-v2/drive-data-mutations`. +- `zach/warp-cli-shard/execution-underlying` feeds `zach/warp-cli-v2/execution-underlying`. +- `zach/warp-cli-shard/cli-catalog-docs` feeds `zach/warp-cli-v2/cli-catalog-docs`. +Each cloud shard prompt should include: +- The exact base branch and shard branch name. +- The v2 review branch it is intended to feed. +- Owned command families and action-specific policy decisions. +- Owned files/modules. +- Files/modules the shard must not edit without calling out the need for integration. +- Selector resolution requirements. +- Validation commands and expected tests. +- A handoff requirement: report branch name, changed files, implemented commands, permission decisions, validation results, and any conflicts or follow-ups. +The lead integrator owns the final `zach/warp-cli-v2/fanin-finalize` branch, where cross-shard cleanup, conflict-resolution preservation, validation fixes, and stack-wide consistency checks land. +```mermaid +flowchart LR + Contract["zach/warp-cli-v2/contract-spec-sync<br/>specs + contracts + foundation"] --> Auth["zach/warp-cli-v2/auth-security<br/>auth + security gates"] + Auth --> Readonly["zach/warp-cli-v2/readonly-capability-targets<br/>metadata + selectors"] + Readonly --> Views["zach/warp-cli-v2/appstate-file-drive-views<br/>approved read views"] + Views --> MetaMut["zach/warp-cli-v2/metadata-config-mutations<br/>config mutations"] + MetaMut --> DriveMut["zach/warp-cli-v2/drive-data-mutations<br/>Drive data mutations"] + DriveMut --> Exec["zach/warp-cli-v2/execution-underlying<br/>execution actions"] + Exec --> Docs["zach/warp-cli-v2/cli-catalog-docs<br/>CLI catalog + docs"] + Docs --> Final["zach/warp-cli-v2/fanin-finalize<br/>cleanup + validation"] + AuthShard["shard/auth-security"] --> Auth + ReadonlyShard["shard/readonly-capability-targets"] --> Readonly + ViewsShard["shard/appstate-file-drive-views"] --> Views + MetaShard["shard/metadata-config-mutations"] --> MetaMut + DriveShard["shard/drive-data-mutations"] --> DriveMut + ExecShard["shard/execution-underlying"] --> Exec + DocsShard["shard/cli-catalog-docs"] --> Docs +``` +## Risks and mitigations +- Fixed-port server assumptions: + - Mitigation: leave current `9277` endpoints undisturbed and use a per-process control listener plus discovery registry. +- Browser-to-localhost abuse: + - Mitigation: no permissive CORS, protected in-app enablement, explicit local auth, scoped grants, and mutating routes gated before selector resolution. +- External apps silently enabling outside-Warp local control: + - Mitigation: outside-Warp control requires the broadest mode, which lives in protected local storage behind Settings > Scripting, is local-only, is not Settings Sync'd, and is not writable through `warpctrl`, config files, registry/plist preference edits, defaults writes, or server-backed settings. +- External apps obtaining in-Warp authenticated-user grants: + - Mitigation: require an app-issued execution-context proof for Warp-terminal-only grants, do not trust caller-declared labels or plain environment variables as sole authority, and reject authenticated-user grants for external invocations regardless of the selected local-control mode. +- Logged-out requests touching user-authenticated data: + - Mitigation: every action declares `requires_authenticated_user`, new actions default to true, and the bridge returns authenticated-user errors before selector resolution or dispatch. +- Implementation drift from `SECURITY.md`: + - Mitigation: treat `SECURITY.md` as normative for security behavior; update this technical plan before implementation when there is disagreement, and include tests for the security architecture in the first slice. +- Action catalog drift from real UI behavior: + - Mitigation: each control action reuses or factors existing UI action paths rather than duplicating behavior, and user-visible app action enums implement exhaustive `WarpCtrlBehavior` mappings so new internal actions cannot be added without an explicit expose/cover/exclude/defer decision. +- Leaking internal unstable identifiers: + - Mitigation: public protocol exposes opaque IDs and selectors; internal runtime IDs stay implementation details. +- Over-broad settings mutation: + - Mitigation: allowlisted setting keys only, with private/debug/derived settings rejected. +- Command execution risk: + - Mitigation: keep `input.run`/typed workflow execution in the catalog but implement it only in `zach/warp-cli-v2/execution-underlying` after authenticated scripting identity, an exact `input.run` or workflow-execution grant, deterministic target resolution, approval policy, and audit coverage are in place. +- Packaging churn due to provisional wrapper naming: + - Mitigation: document `warpctrl` as provisional and settle final aliases before broad release workflow rollout. +- Heavyweight CLI startup caused by sharing the app binary: + - Mitigation: route `--warpctrl` before GUI launch, keep the control-mode initialization path as narrow as current Oz command dispatch, and add startup-path checks that fail if the wrapper initializes GUI-only subsystems. +## Follow-ups +- Decide the final wrapper filename/channel alias scheme around the provisional `warpctrl ...` public command surface. +- Decide whether Windows should follow the current Oz helper-wrapper pattern indefinitely or gain a different forwarding helper. +- Decide whether a future subscription/watch protocol is useful for scripts that want live state changes, rather than single request/response calls only.