first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(®istered.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
Reference in New Issue
Block a user