first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+157
View File
@@ -0,0 +1,157 @@
//! 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};
/// 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<String>) -> 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<Self, ControlError> {
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 exact action.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CredentialRequest {
pub protocol_version: u32,
pub request_id: Uuid,
pub action: ActionKind,
}
impl CredentialRequest {
pub fn new(action: ActionKind) -> Self {
Self {
protocol_version: crate::protocol::PROTOCOL_VERSION,
request_id: Uuid::new_v4(),
action,
}
}
}
/// 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 issued_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl CredentialGrant {
pub fn new(instance_id: InstanceId, action: ActionKind, ttl: Duration) -> Self {
let issued_at = Utc::now();
Self {
credential_id: format!("cred_{}", Uuid::new_v4().simple()),
instance_id,
action,
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()
),
));
}
Ok(())
}
}
#[cfg(test)]
#[path = "auth_tests.rs"]
mod tests;
+99
View File
@@ -0,0 +1,99 @@
use chrono::Duration;
use super::*;
use crate::discovery::InstanceId;
#[test]
fn rejects_missing_authorization_header() {
let token = AuthToken::from_secret("secret");
let error = token
.verify_authorization_header(None)
.expect_err("rejected");
assert_eq!(error.code, ErrorCode::UnauthorizedLocalClient);
}
#[test]
fn rejects_malformed_authorization_header() {
let token = AuthToken::from_secret("secret");
let error = token
.verify_authorization_header(Some("Basic secret"))
.expect_err("rejected");
assert_eq!(error.code, ErrorCode::UnauthorizedLocalClient);
}
#[test]
fn rejects_wrong_bearer_token() {
let token = AuthToken::from_secret("secret");
let error = token
.verify_authorization_header(Some("Bearer wrong"))
.expect_err("rejected");
assert_eq!(error.code, ErrorCode::UnauthorizedLocalClient);
}
#[test]
fn accepts_matching_bearer_token() {
AuthToken::from_secret("secret")
.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,
Duration::minutes(5),
);
grant
.verify_for_action(&grant.instance_id, ActionKind::TabCreate)
.expect("tab.create grant is accepted");
let error = grant
.verify_for_action(&grant.instance_id, ActionKind::WindowCreate)
.expect_err("other actions are rejected");
assert_eq!(error.code, ErrorCode::InsufficientPermissions);
}
#[test]
fn scoped_credential_rejects_different_instance() {
let grant = CredentialGrant::new(
InstanceId("inst_test".to_owned()),
ActionKind::TabCreate,
Duration::minutes(5),
);
let error = grant
.verify_for_action(&InstanceId("inst_other".to_owned()), ActionKind::TabCreate)
.expect_err("other instance is rejected");
assert_eq!(error.code, ErrorCode::UnauthorizedLocalClient);
}
#[test]
fn scoped_credential_rejects_expired_grant() {
let grant = CredentialGrant::new(
InstanceId("inst_test".to_owned()),
ActionKind::TabCreate,
Duration::minutes(-1),
);
let error = grant
.verify_for_action(&grant.instance_id, ActionKind::TabCreate)
.expect_err("expired grant is rejected");
assert_eq!(error.code, ErrorCode::UnauthorizedLocalClient);
}
#[test]
fn scoped_credential_allows_confirmation_required_action_scope() {
let grant = CredentialGrant::new(
InstanceId("inst_test".to_owned()),
ActionKind::WindowClose,
Duration::minutes(5),
);
grant
.verify_for_action(&grant.instance_id, ActionKind::WindowClose)
.expect("exact-action credential is separate from one-shot confirmation");
}
#[test]
fn credential_request_carries_only_action() {
let request = CredentialRequest::new(ActionKind::TabCreate);
assert_eq!(request.action, ActionKind::TabCreate);
assert_eq!(request.protocol_version, crate::protocol::PROTOCOL_VERSION);
}
+296
View File
@@ -0,0 +1,296 @@
//! Action catalog and metadata used for discovery, permissions, and CLI support.
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 1;
/// 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,
Input,
Settings,
Appearance,
Surface,
File,
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,
FileOpen,
Key,
KeyValue,
Namespace,
PageQuery,
Query,
Rename,
Resize,
TabActivate,
TabClose,
TabCreate,
Text,
ThemeName,
}
/// 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,
CapabilityList,
CapabilityMetadata,
InstanceList,
InstanceMetadata,
KeybindingList,
KeybindingMetadata,
SettingList,
SettingValue,
SurfaceList,
TargetList,
TargetMetadata,
ThemeList,
ThemeState,
}
/// Discoverable metadata describing one local-control action.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionMetadata {
pub kind: ActionKind,
pub name: String,
pub implementation_status: ActionImplementationStatus,
pub target_scope: TargetScope,
pub parameter_spec: ActionParameterSpec,
pub result_spec: ActionResultSpec,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
struct ActionSpec {
name: &'static str,
implementation_status: ActionImplementationStatus,
target_scope: TargetScope,
parameter_spec: ActionParameterSpec,
result_spec: ActionResultSpec,
}
macro_rules! define_action_catalog {
($(
$group:ident {
$(
$variant:ident => {
name: $name:literal,
status: $status:ident,
target: $target:ident,
params: $params:ident,
result: $result:ident $(,)?
}
),+ $(,)?
}
)+ $(,)?) => {
/// Stable protocol name for every approved `warpctrl` action.
#[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,
target_scope: spec.target_scope,
parameter_spec: spec.parameter_spec,
result_spec: spec.result_spec,
}
}
pub fn implemented_metadata() -> Vec<ActionMetadata> {
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,
target_scope: TargetScope::$target,
parameter_spec: ActionParameterSpec::$params,
result_spec: ActionResultSpec::$result,
},)+)+
}
}
}
};
}
define_action_catalog! {
instance {
InstanceList => { name: "instance.list", status: Implemented, target: Instance, params: None, result: InstanceList },
InstanceInspect => { name: "instance.inspect", status: Implemented, target: Instance, params: None, result: InstanceMetadata },
}
app {
AppPing => { name: "app.ping", status: Implemented, target: Instance, params: None, result: InstanceMetadata },
AppVersion => { name: "app.version", status: Implemented, target: Instance, params: None, result: InstanceMetadata },
AppActive => { name: "app.active", status: Implemented, target: Instance, params: None, result: ActiveTarget },
AppFocus => { name: "app.focus", status: Implemented, target: Instance, params: None, result: Acknowledgement },
}
capability {
CapabilityList => { name: "capability.list", status: Implemented, target: Capability, params: None, result: CapabilityList },
CapabilityInspect => { name: "capability.inspect", status: Implemented, target: Capability, params: ActionName, result: CapabilityMetadata },
}
window {
WindowList => { name: "window.list", status: Implemented, target: Window, params: None, result: TargetList },
WindowInspect => { name: "window.inspect", status: Implemented, target: Window, params: None, result: TargetMetadata },
WindowCreate => { name: "window.create", status: Implemented, target: Window, params: TabCreate, result: Acknowledgement },
WindowFocus => { name: "window.focus", status: Implemented, target: Window, params: None, result: Acknowledgement },
WindowClose => { name: "window.close", status: Implemented, target: Window, params: None, result: Acknowledgement },
}
tab {
TabList => { name: "tab.list", status: Implemented, target: Tab, params: None, result: TargetList },
TabInspect => { name: "tab.inspect", status: Implemented, target: Tab, params: None, result: TargetMetadata },
TabCreate => { name: "tab.create", status: Implemented, target: Tab, params: TabCreate, result: Acknowledgement },
TabActivate => { name: "tab.activate", status: Implemented, target: Tab, params: TabActivate, result: Acknowledgement },
TabMove => { name: "tab.move", status: Implemented, target: Tab, params: Direction, result: Acknowledgement },
TabClose => { name: "tab.close", status: Implemented, target: Tab, params: TabClose, result: Acknowledgement },
TabRename => { name: "tab.rename", status: Implemented, target: Tab, params: Rename, result: Acknowledgement },
TabResetName => { name: "tab.reset_name", status: Implemented, target: Tab, params: None, result: Acknowledgement },
TabColorSet => { name: "tab.color.set", status: Implemented, target: Tab, params: ColorValue, result: Acknowledgement },
TabColorClear => { name: "tab.color.clear", status: Implemented, target: Tab, params: None, result: Acknowledgement },
}
pane {
PaneList => { name: "pane.list", status: Implemented, target: Pane, params: None, result: TargetList },
PaneInspect => { name: "pane.inspect", status: Implemented, target: Pane, params: None, result: TargetMetadata },
PaneSplit => { name: "pane.split", status: Implemented, target: Pane, params: Direction, result: Acknowledgement },
PaneFocus => { name: "pane.focus", status: Implemented, target: Pane, params: None, result: Acknowledgement },
PaneNavigate => { name: "pane.navigate", status: Implemented, target: Pane, params: Direction, result: Acknowledgement },
PaneResize => { name: "pane.resize", status: Implemented, target: Pane, params: Resize, result: Acknowledgement },
PaneMaximize => { name: "pane.maximize", status: Implemented, target: Pane, params: None, result: Acknowledgement },
PaneUnmaximize => { name: "pane.unmaximize", status: Implemented, target: Pane, params: None, result: Acknowledgement },
PaneClose => { name: "pane.close", status: Implemented, target: Pane, params: None, result: Acknowledgement },
PaneRename => { name: "pane.rename", status: Implemented, target: Pane, params: Rename, result: Acknowledgement },
PaneResetName => { name: "pane.reset_name", status: Implemented, target: Pane, params: None, result: Acknowledgement },
}
session {
SessionList => { name: "session.list", status: Implemented, target: Session, params: None, result: TargetList },
SessionInspect => { name: "session.inspect", status: Implemented, target: Session, params: None, result: TargetMetadata },
SessionActivate => { name: "session.activate", status: Implemented, target: Session, params: None, result: Acknowledgement },
SessionPrevious => { name: "session.previous", status: Implemented, target: Session, params: None, result: Acknowledgement },
SessionNext => { name: "session.next", status: Implemented, target: Session, params: None, result: Acknowledgement },
SessionReopenClosed => { name: "session.reopen_closed", status: Implemented, target: Session, params: None, result: Acknowledgement },
}
input {
InputInsert => { name: "input.insert", status: Implemented, target: Input, params: Text, result: Acknowledgement },
InputReplace => { name: "input.replace", status: Implemented, target: Input, params: Text, result: Acknowledgement },
}
theme {
ThemeList => { name: "theme.list", status: Implemented, target: Appearance, params: None, result: ThemeList },
ThemeGet => { name: "theme.get", status: Implemented, target: Appearance, params: None, result: ThemeState },
ThemeSet => { name: "theme.set", status: Implemented, target: Appearance, params: ThemeName, result: Acknowledgement },
ThemeSystemSet => { name: "theme.system.set", status: Implemented, target: Appearance, params: BooleanValue, result: Acknowledgement },
ThemeLightSet => { name: "theme.light.set", status: Implemented, target: Appearance, params: ThemeName, result: Acknowledgement },
ThemeDarkSet => { name: "theme.dark.set", status: Implemented, target: Appearance, params: ThemeName, result: Acknowledgement },
}
appearance {
AppearanceGet => { name: "appearance.get", status: Implemented, target: Appearance, params: None, result: AppearanceState },
AppearanceFontSizeIncrease => { name: "appearance.font_size.increase", status: Implemented, target: Appearance, params: None, result: Acknowledgement },
AppearanceFontSizeDecrease => { name: "appearance.font_size.decrease", status: Implemented, target: Appearance, params: None, result: Acknowledgement },
AppearanceFontSizeReset => { name: "appearance.font_size.reset", status: Implemented, target: Appearance, params: None, result: Acknowledgement },
AppearanceZoomIncrease => { name: "appearance.zoom.increase", status: Implemented, target: Appearance, params: None, result: Acknowledgement },
AppearanceZoomDecrease => { name: "appearance.zoom.decrease", status: Implemented, target: Appearance, params: None, result: Acknowledgement },
AppearanceZoomReset => { name: "appearance.zoom.reset", status: Implemented, target: Appearance, params: None, result: Acknowledgement },
}
setting {
SettingList => { name: "setting.list", status: Implemented, target: Settings, params: Namespace, result: SettingList },
SettingGet => { name: "setting.get", status: Implemented, target: Settings, params: Key, result: SettingValue },
SettingSet => { name: "setting.set", status: Implemented, target: Settings, params: KeyValue, result: Acknowledgement },
SettingToggle => { name: "setting.toggle", status: Implemented, target: Settings, params: Key, result: Acknowledgement },
}
keybinding {
KeybindingList => { name: "keybinding.list", status: Implemented, target: Keybinding, params: None, result: KeybindingList },
KeybindingGet => { name: "keybinding.get", status: Implemented, target: Keybinding, params: BindingName, result: KeybindingMetadata },
}
action {
ActionList => { name: "action.list", status: Implemented, target: Action, params: None, result: CapabilityList },
ActionInspect => { name: "action.inspect", status: Implemented, target: Action, params: ActionName, result: CapabilityMetadata },
}
surface {
SurfaceList => { name: "surface.list", status: Implemented, target: Instance, params: None, result: SurfaceList },
SurfaceSettingsOpen => { name: "surface.settings.open", status: Implemented, target: Surface, params: PageQuery, result: Acknowledgement },
SurfaceCommandPaletteOpen => { name: "surface.command_palette.open", status: Implemented, target: Surface, params: Query, result: Acknowledgement },
SurfaceCommandSearchOpen => { name: "surface.command_search.open", status: Implemented, target: Surface, params: Query, result: Acknowledgement },
SurfaceThemePickerOpen => { name: "surface.theme_picker.open", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceKeybindingsOpen => { name: "surface.keybindings.open", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceWarpDriveOpen => { name: "surface.warp_drive.open", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceWarpDriveToggle => { name: "surface.warp_drive.toggle", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceResourceCenterToggle => { name: "surface.resource_center.toggle", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceAiAssistantToggle => { name: "surface.ai_assistant.toggle", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceCodeReviewOpen => { name: "surface.code_review.open", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceCodeReviewToggle => { name: "surface.code_review.toggle", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceProjectExplorerOpen => { name: "surface.project_explorer.open", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceGlobalSearchOpen => { name: "surface.global_search.open", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceConversationListOpen => { name: "surface.conversation_list.open", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceLeftPanelToggle => { name: "surface.left_panel.toggle", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceRightPanelToggle => { name: "surface.right_panel.toggle", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceVerticalTabsOpen => { name: "surface.vertical_tabs.open", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceVerticalTabsToggle => { name: "surface.vertical_tabs.toggle", status: Implemented, target: Surface, params: None, result: Acknowledgement },
SurfaceAgentManagementOpen => { name: "surface.agent_management.open", status: Implemented, target: Surface, params: None, result: Acknowledgement },
}
file {
FileOpen => { name: "file.open", status: Implemented, target: File, params: FileOpen, result: Acknowledgement },
}
}
+228
View File
@@ -0,0 +1,228 @@
//! 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 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,
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<ResponseEnvelope, ControlError> {
instance.validate_local_control_authority()?;
let credential = request_credential(instance, request.action.kind)?;
let endpoint = instance.endpoint.as_ref().ok_or_else(|| {
ControlError::new(
ErrorCode::LocalControlDisabled,
"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::<ResponseEnvelope>(&text) {
if let ControlResponse::Error { error } = &envelope.response {
return Err(error.clone());
}
return Ok(envelope);
}
if let Ok(envelope) = serde_json::from_str::<ErrorResponseEnvelope>(&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<ResponseEnvelope, ControlError> {
request_credential(instance, request.action.kind)?;
Err(ControlError::new(
ErrorCode::LocalControlDisabled,
"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<String, ControlError> {
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<String, ControlError> {
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<String, ControlError> {
Err(ControlError::new(
ErrorCode::LocalControlDisabled,
"local control requires an owner-authenticated credential broker",
))
}
/// Requests and decodes a short-lived credential for one exact action.
pub fn request_credential(
instance: &InstanceRecord,
action: crate::protocol::ActionKind,
) -> Result<ScopedCredential, ControlError> {
instance.validate_local_control_authority()?;
let request = CredentialRequest::new(action);
let text = request_credential_over_owner_ipc(instance, &request)?;
if let Ok(credential) = serde_json::from_str::<ScopedCredential>(&text) {
return Ok(credential);
}
if let Ok(envelope) = serde_json::from_str::<ErrorResponseEnvelope>(&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;
+78
View File
@@ -0,0 +1,78 @@
#[cfg(unix)]
use std::io::{Read as _, Write as _};
#[cfg(unix)]
use chrono::Duration;
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,
Duration::minutes(5),
);
let credential = ScopedCredential {
bearer_token: "scoped-token".to_owned(),
grant,
};
let expected_request = CredentialRequest::new(ActionKind::AppPing);
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::<CredentialRequest>(&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::<ScopedCredential>(&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(),
}),
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);
}
+508
View File
@@ -0,0 +1,508 @@
//! 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 Scripting 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::collections::HashSet;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
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";
const TEMP_RECORD_SUFFIX: &str = ".json.tmp";
const ORPHAN_SOCKET_GRACE_PERIOD: Duration = Duration::from_secs(60);
/// 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<String>,
pub started_at: DateTime<Utc>,
pub executable_path: Option<PathBuf>,
pub endpoint: Option<ControlEndpoint>,
pub credential_broker: Option<CredentialBrokerReference>,
pub actions: Vec<ActionMetadata>,
}
impl InstanceRecord {
pub fn for_current_process(
endpoint: Option<ControlEndpoint>,
channel: impl Into<String>,
app_id: impl Into<String>,
app_version: Option<String>,
actions: Vec<ActionMetadata>,
) -> 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(),
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.endpoint, &self.credential_broker) {
(None, None) => Ok(()),
(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<PathBuf, ControlError> {
self.validate_local_control_authority()?;
let credential_broker = self.credential_broker.as_ref().ok_or_else(|| {
ControlError::new(
ErrorCode::LocalControlDisabled,
"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<PathBuf>,
}
impl RegisteredInstance {
/// Publishes a record in the protected per-user registry.
pub fn register(record: InstanceRecord) -> Result<Self, ControlError> {
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(),
)
})?;
let temp_path = path.with_extension("json.tmp");
fs::write(&temp_path, bytes).map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to write temporary local-control discovery record",
err.to_string(),
)
})?;
if let Err(error) = set_private_permissions(&temp_path) {
let _ = fs::remove_file(&temp_path);
return Err(error);
}
fs::rename(&temp_path, path).map_err(|err| {
let _ = fs::remove_file(&temp_path);
ControlError::with_details(
ErrorCode::Internal,
"failed to publish local-control discovery record",
err.to_string(),
)
})?;
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 removes stale
// records, matching broker sockets, and abandoned registry artifacts.
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 from `channel` 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(channel: &str) -> Vec<InstanceRecord> {
let dir = discovery_dir();
list_instances_from_dir(&dir, channel)
.into_iter()
.filter(|record| {
if crate::client::probe_instance(record).is_ok() {
return true;
}
if !is_pid_alive(record.pid) {
remove_instance_artifacts(&dir, &record.instance_id);
}
false
})
.collect()
}
/// Parses structurally valid candidate records from `channel` 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, channel: &str) -> Vec<InstanceRecord> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let mut records = Vec::new();
let mut retained_broker_sockets = HashSet::new();
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if !is_record_path(&path) {
continue;
}
let contents = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => {
remove_malformed_record_artifacts(dir, &path);
continue;
}
};
let record = match serde_json::from_str::<InstanceRecord>(&contents) {
Ok(r) => r,
Err(_) => {
remove_malformed_record_artifacts(dir, &path);
continue;
}
};
if !is_pid_alive(record.pid) {
remove_instance_artifacts(dir, &record.instance_id);
continue;
}
if record.credential_broker.is_some() {
retained_broker_sockets.insert(broker_socket_filename(&record.instance_id));
}
if record.protocol_version != PROTOCOL_VERSION {
continue;
}
if record.channel != channel {
continue;
}
if record.validate_local_control_authority().is_err() {
continue;
}
records.push(record);
}
sweep_orphan_broker_sockets(dir, &retained_broker_sockets, ORPHAN_SOCKET_GRACE_PERIOD);
sweep_abandoned_temp_records(dir, ORPHAN_SOCKET_GRACE_PERIOD);
records.sort_by_key(|record| record.started_at);
records
}
fn is_record_path(path: &Path) -> bool {
path.extension().and_then(|extension| extension.to_str()) == Some("json")
}
fn remove_malformed_record_artifacts(dir: &Path, path: &Path) {
let _ = fs::remove_file(path);
let Some(instance_id) = path.file_stem().and_then(|stem| stem.to_str()) else {
return;
};
let _ = fs::remove_file(dir.join(format!("{instance_id}{BROKER_SOCKET_SUFFIX}")));
}
fn remove_instance_artifacts(dir: &Path, instance_id: &InstanceId) {
let _ = fs::remove_file(record_path(dir, instance_id));
let _ = fs::remove_file(dir.join(broker_socket_filename(instance_id)));
}
fn sweep_orphan_broker_sockets(
dir: &Path,
retained_broker_sockets: &HashSet<PathBuf>,
grace_period: Duration,
) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
let Some(filename) = path.file_name().and_then(|filename| filename.to_str()) else {
continue;
};
if !filename.ends_with(BROKER_SOCKET_SUFFIX)
|| retained_broker_sockets.contains(Path::new(filename))
{
continue;
}
let Ok(age) = entry.metadata().and_then(|metadata| metadata.modified()) else {
continue;
};
let Ok(age) = age.elapsed() else {
continue;
};
if age >= grace_period {
let _ = fs::remove_file(path);
}
}
}
fn sweep_abandoned_temp_records(dir: &Path, grace_period: Duration) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
let Some(filename) = path.file_name().and_then(|filename| filename.to_str()) else {
continue;
};
if !filename.ends_with(TEMP_RECORD_SUFFIX) {
continue;
}
let Ok(age) = entry.metadata().and_then(|metadata| metadata.modified()) else {
continue;
};
let Ok(age) = age.elapsed() else {
continue;
};
if age >= grace_period {
let _ = fs::remove_file(path);
}
}
}
#[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;
+336
View File
@@ -0,0 +1,336 @@
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(), "local");
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(), "local").is_empty());
}
#[test]
fn malformed_record_and_matching_broker_socket_are_pruned() {
let dir = tempfile::tempdir().expect("temp dir");
let record_path = dir.path().join("inst_malformed.json");
let socket_path = dir.path().join("inst_malformed.broker.sock");
fs::write(&record_path, "not json").expect("write malformed record");
fs::write(&socket_path, "").expect("write broker socket");
assert!(list_instances_from_dir(dir.path(), "local").is_empty());
assert!(!record_path.exists());
assert!(!socket_path.exists());
}
#[test]
fn orphan_broker_sockets_are_pruned_after_grace_period() {
let dir = tempfile::tempdir().expect("temp dir");
let orphan_path = dir.path().join("inst_orphan.broker.sock");
let retained_filename = PathBuf::from("inst_retained.broker.sock");
let retained_path = dir.path().join(&retained_filename);
fs::write(&orphan_path, "").expect("write orphan socket");
fs::write(&retained_path, "").expect("write retained socket");
sweep_orphan_broker_sockets(
dir.path(),
&HashSet::from([retained_filename]),
Duration::ZERO,
);
assert!(!orphan_path.exists());
assert!(retained_path.exists());
}
#[test]
fn abandoned_temp_records_are_pruned_after_grace_period() {
let dir = tempfile::tempdir().expect("temp dir");
let temp_path = dir.path().join("inst_abandoned.json.tmp");
fs::write(&temp_path, "").expect("write temporary record");
sweep_abandoned_temp_records(dir.path(), Duration::ZERO);
assert!(!temp_path.exists());
}
#[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 socket_path = dir.path().join(broker_socket_filename(&record.instance_id));
fs::write(&socket_path, "").expect("write broker socket");
let registered =
RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered");
assert!(list_instances_from_dir(dir.path(), "local").is_empty());
assert!(!registered.path.exists());
assert!(!socket_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(), "local")
.into_iter()
.map(|record| record.instance_id)
.collect::<Vec<_>>();
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 records_from_other_channels_are_ignored() {
let dir = tempfile::tempdir().expect("temp dir");
let record = InstanceRecord::for_current_process(
Some(ControlEndpoint::localhost(4000)),
"dev",
"dev.warp.Warp-Dev",
Some("test".to_owned()),
crate::protocol::ActionKind::implemented_metadata(),
);
let socket_path = dir.path().join(broker_socket_filename(&record.instance_id));
fs::write(&socket_path, "").expect("write broker socket");
let _registered =
RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered");
assert!(list_instances_from_dir(dir.path(), "local").is_empty());
assert!(socket_path.exists());
}
#[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_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.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() {
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(&registered.path)
.expect("metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
impl RegisteredInstance {
fn register_in_dir_for_test(record: InstanceRecord, dir: &Path) -> Result<Self, ControlError> {
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,
})
}
}
+24
View File
@@ -0,0 +1,24 @@
//! 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, CredentialGrant, CredentialRequest, ScopedCredential};
pub use catalog::{ActionImplementationStatus, ActionKind, ActionMetadata, TargetScope};
pub use discovery::{
ControlEndpoint, CredentialBrokerReference, InstanceId, InstanceRecord, RegisteredInstance,
discovery_dir,
};
pub use protocol::{
Action, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, PROTOCOL_VERSION,
RequestEnvelope, ResponseEnvelope,
};
pub use selectors::{PaneSelector, SessionSelector, TabSelector, TargetSelector, WindowSelector};
+493
View File
@@ -0,0 +1,493 @@
//! Wire protocol envelopes and error types for Warp local control.
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub use crate::catalog::{
ActionImplementationStatus, ActionKind, ActionMetadata, ActionParameterSpec, ActionResultSpec,
PROTOCOL_VERSION, TargetScope,
};
pub use crate::selectors::{
PaneSelector, PaneTarget, SessionSelector, SessionTarget, TabSelector, TabTarget,
TargetSelector, WindowSelector, WindowTarget,
};
/// 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,
}
/// Tab type accepted by `tab.create` and `window.create`.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TabType {
Terminal,
Agent,
CloudAgent,
Default,
}
/// 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,
}
/// Empty parameters for actions whose catalog parameter spec is `none`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmptyParams {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionNameParams {
pub action: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BindingNameParams {
pub binding_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BooleanValueParams {
pub value: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ColorValueParams {
pub color: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DirectionParams {
pub direction: Direction,
}
/// Parameters for opening a file in Warp's app/editor state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileOpenParams {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<u32>,
#[serde(default)]
pub new_tab: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyParams {
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyValueParams {
pub key: String,
pub value: serde_json::Value,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NamespaceParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PageQueryParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QueryParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RenameParams {
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResizeParams {
pub direction: Direction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TabActivateParams {
pub mode: TabActivationMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TabCloseParams {
pub mode: TabCloseMode,
}
/// Parameters for `tab.create` and `window.create` shell/profile options.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TabCreateParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_type: Option<TabType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TextParams {
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ThemeNameParams {
pub theme_name: String,
}
pub type KeybindingGetParams = BindingNameParams;
pub type KeybindingListParams = EmptyParams;
pub type SettingGetParams = KeyParams;
pub type SettingListParams = NamespaceParams;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActionListResult {
pub actions: Vec<ActionMetadata>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActionInspectResult {
pub action: ActionMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveTargetChain {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThemeSummary {
pub name: String,
pub is_current: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThemeListResult {
pub themes: Vec<ThemeSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThemeStateResult {
pub name: String,
pub follow_system_theme: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub light_theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dark_theme: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppearanceStateResult {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
pub follow_system_theme: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub light_theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dark_theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_zoom_percent: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SettingSummary {
pub key: String,
pub value: serde_json::Value,
pub value_type: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SettingListResult {
pub settings: Vec<SettingSummary>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SettingGetResult {
pub setting: SettingSummary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeybindingSummary {
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keystroke: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub normalized_keystroke: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeybindingListResult {
pub keybindings: Vec<KeybindingSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeybindingGetResult {
pub keybinding: KeybindingSummary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SurfaceSummary {
pub name: String,
pub is_available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unavailable_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SurfaceListResult {
pub surfaces: Vec<SurfaceSummary>,
}
/// 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()),
}
}
pub fn with_params<T: Serialize>(kind: ActionKind, params: T) -> Result<Self, ControlError> {
Ok(Self {
kind,
params: serde_json::to_value(params).map_err(|err| {
ControlError::with_details(
ErrorCode::InvalidParams,
format!("failed to serialize {} parameters", kind.as_str()),
err.to_string(),
)
})?,
})
}
pub fn params_as<T: DeserializeOwned>(&self) -> Result<T, ControlError> {
serde_json::from_value(self.params.clone()).map_err(|err| {
ControlError::with_details(
ErrorCode::InvalidParams,
format!("failed to decode {} parameters", self.kind.as_str()),
err.to_string(),
)
})
}
}
/// 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<String>,
}
impl ControlError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
details: None,
}
}
pub fn with_details(
code: ErrorCode,
message: impl Into<String>,
details: impl Into<String>,
) -> 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,
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;
+221
View File
@@ -0,0 +1,221 @@
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 strict_params_serialize_without_synthetic_discriminators() {
let action = Action::with_params(
ActionKind::SettingList,
SettingListParams {
namespace: Some("editor".to_owned()),
},
)
.expect("setting.list params serialize");
assert_eq!(action.params, serde_json::json!({ "namespace": "editor" }));
let params = action
.params_as::<SettingListParams>()
.expect("setting.list params decode");
assert_eq!(params.namespace.as_deref(), Some("editor"));
let action = Action::with_params(
ActionKind::TabCreate,
TabCreateParams {
tab_type: Some(TabType::Agent),
shell: Some("zsh".to_owned()),
},
)
.expect("tab.create params serialize");
assert_eq!(
action.params,
serde_json::json!({ "tab_type": "agent", "shell": "zsh" })
);
assert!(action.params.get("type").is_none());
}
#[test]
fn strict_params_deny_unknown_fields() {
let action = Action {
kind: ActionKind::InputInsert,
params: serde_json::json!({ "text": "hello", "submit": true }),
};
let error = action
.params_as::<TextParams>()
.expect_err("unknown params are rejected");
assert_eq!(error.code, ErrorCode::InvalidParams);
let action = Action {
kind: ActionKind::WindowFocus,
params: serde_json::json!({ "unexpected": true }),
};
assert!(action.params_as::<EmptyParams>().is_err());
}
#[test]
fn target_selector_roundtrips_exact_session_id() {
let target = TargetSelector {
session: Some(SessionTarget::Id {
id: SessionSelector("session_1".to_owned()),
}),
..TargetSelector::default()
};
let value = serde_json::to_value(&target).expect("target serializes");
assert_eq!(
value["session"],
serde_json::json!({ "type": "id", "id": "session_1" })
);
assert_eq!(
serde_json::from_value::<TargetSelector>(value).expect("target decodes"),
target
);
}
#[test]
fn response_error_serializes_machine_code() {
let response = ResponseEnvelope::error(
Uuid::nil(),
ControlError::new(ErrorCode::InsufficientPermissions, "wrong action"),
);
let value = serde_json::to_value(&response).expect("response serializes");
assert_eq!(value["response"]["status"], "error");
assert_eq!(
value["response"]["error"]["code"],
"insufficient_permissions"
);
}
#[test]
fn surface_list_result_serializes_stable_availability_shape() {
let result = SurfaceListResult {
surfaces: vec![
SurfaceSummary {
name: "theme_picker".to_owned(),
is_available: true,
unavailable_reason: None,
},
SurfaceSummary {
name: "vertical_tabs".to_owned(),
is_available: false,
unavailable_reason: Some("vertical tabs are disabled".to_owned()),
},
],
};
let value = serde_json::to_value(result).expect("surface list result serializes");
assert_eq!(
value,
serde_json::json!({
"surfaces": [
{
"name": "theme_picker",
"is_available": true
},
{
"name": "vertical_tabs",
"is_available": false,
"unavailable_reason": "vertical tabs are disabled"
}
]
})
);
}
#[test]
fn malformed_and_removed_action_names_are_not_deserialized() {
for action in [
"tab.create.extra",
"auth.status",
"auth.login",
"block.list",
"block.inspect",
"block.output",
"history.list",
"file.list",
"input.get",
"input.clear",
"input.mode.set",
"input.run",
"drive.list",
"drive.inspect",
"drive.open",
"drive.notebook.open",
"drive.env_var_collection.open",
"drive.object.share.open",
"drive.object.create",
"drive.object.update",
"drive.object.delete",
"drive.object.insert",
"drive.object.share_to_team",
"drive.workflow.run",
] {
assert!(serde_json::from_value::<ActionKind>(serde_json::json!(action)).is_err());
}
}
#[test]
fn catalog_has_exactly_84_retained_actions() {
assert_eq!(ActionKind::ALL.len(), 84);
}
#[test]
fn direct_surface_actions_have_stable_names() {
assert_eq!(ActionKind::SurfaceList.as_str(), "surface.list");
assert_eq!(
ActionKind::SurfaceThemePickerOpen.as_str(),
"surface.theme_picker.open"
);
assert_eq!(
ActionKind::SurfaceKeybindingsOpen.as_str(),
"surface.keybindings.open"
);
assert_eq!(
ActionKind::SurfaceCodeReviewOpen.as_str(),
"surface.code_review.open"
);
assert_eq!(
ActionKind::SurfaceProjectExplorerOpen.as_str(),
"surface.project_explorer.open"
);
assert_eq!(
ActionKind::SurfaceGlobalSearchOpen.as_str(),
"surface.global_search.open"
);
assert_eq!(
ActionKind::SurfaceConversationListOpen.as_str(),
"surface.conversation_list.open"
);
assert_eq!(
ActionKind::SurfaceVerticalTabsOpen.as_str(),
"surface.vertical_tabs.open"
);
assert_eq!(
ActionKind::SurfaceAgentManagementOpen.as_str(),
"surface.agent_management.open"
);
}
#[test]
fn catalog_actions_share_uniform_authorization() {
for kind in ActionKind::ALL {
let metadata = kind.metadata();
assert_eq!(
metadata.implementation_status,
ActionImplementationStatus::Implemented,
"{} should be implemented",
metadata.name,
);
}
}
#[test]
fn implemented_catalog_contains_all_retained_actions() {
let actions = ActionKind::implemented_metadata()
.into_iter()
.map(|metadata| metadata.kind)
.collect::<Vec<_>>();
assert_eq!(actions, ActionKind::ALL);
}
+58
View File
@@ -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<InstanceRecord, ControlError> {
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<InstanceRecord, ControlError> {
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;
@@ -0,0 +1,44 @@
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(),
}),
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);
}
+73
View File
@@ -0,0 +1,73 @@
//! 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);
/// Opaque session identifier supplied by Warp metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionSelector(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<WindowTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab: Option<TabTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane: Option<PaneTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<SessionTarget>,
}
/// 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 },
}
/// Session-level target selector.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionTarget {
Active,
Id { id: SessionSelector },
}