Skip to main content

core_api/
external_rpc.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::{AuthRequest, AuthenticatedSession, ServiceCoreInput, ServiceCoreOutput};
5use std::collections::HashMap;
6
7pub const EXTERNAL_CORE_PROTOCOL_VERSION: u32 = 17;
8pub const ARTIFACT_CONTENT_PATH_PREFIX: &str = "/artifact/v1/content/";
9pub const ARTIFACT_UPLOAD_PATH_PREFIX: &str = "/artifact/v1/upload/";
10
11/// Derive a bounded, workdir-specific Windows IPC name from the metadata socket path.
12/// The metadata itself stays in the ordinary runtime directory on every platform.
13pub fn external_core_pipe_name(socket_path: &std::path::Path) -> String {
14    use sha2::{Digest, Sha256};
15    let normalized = socket_path
16        .to_string_lossy()
17        .replace('/', "\\")
18        .to_lowercase();
19    format!(
20        r"\\.\pipe\meowcore-{:x}",
21        Sha256::digest(normalized.as_bytes())
22    )
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ExternalCoreRequest {
27    pub id: String,
28    pub method: ExternalCoreMethod,
29    pub payload: Value,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ExternalCoreResponse {
34    pub id: String,
35    pub ok: bool,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub result: Option<Value>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub error: Option<ExternalCoreError>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ExternalCoreError {
44    pub code: String,
45    pub message: String,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub details: Option<Value>,
48}
49
50impl From<crate::ErrorResponse> for ExternalCoreError {
51    fn from(error: crate::ErrorResponse) -> Self {
52        Self {
53            code: error.error,
54            message: error.message,
55            details: error.details,
56        }
57    }
58}
59
60impl From<ExternalCoreError> for crate::ErrorResponse {
61    fn from(error: ExternalCoreError) -> Self {
62        Self {
63            error: error.code,
64            message: error.message,
65            details: error.details,
66        }
67    }
68}
69
70impl std::fmt::Display for ExternalCoreError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        write!(f, "[{}] {}", self.code, self.message)
73    }
74}
75
76impl std::error::Error for ExternalCoreError {}
77
78/// A completed JSON HTTP handler response, including non-2xx responses.
79/// This is an RPC result, not an RPC error. Hosts must preserve both fields.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct HttpPayloadResponse {
83    pub status_code: u16,
84    pub body: Value,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub enum ExternalCoreMethod {
89    HandleCoreInput,
90    RegisterLocalAppClient,
91    RegisterNodeConnection,
92    CleanupWsState,
93    UpdateCallbackBase,
94    PutArtifact,
95    OpenArtifact,
96    AuthorizeArtifactRead,
97    AuthorizeArtifactUpload,
98    CommitArtifactUpload,
99    DeviceIdentity,
100    CommissionFingerprint,
101    CommissionPublicKeyBase64,
102    ListMdnsRecords,
103    PollEvents,
104    CompleteEvents,
105    CommissionChallengePayload,
106    PairingStartPayload,
107    SetupStartPayload,
108    GeneralWebhookPayload,
109    InvokeWasmPayload,
110    Health,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct HandleCoreInputRequest {
115    pub input: ServiceCoreInput,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct HandleCoreInputResponse {
120    pub output: ServiceCoreOutput,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct RegisterLocalAppClientRequest {
125    pub ws_id: String,
126    pub auth_request: AuthRequest,
127    pub session: AuthenticatedSession,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RegisterNodeConnectionRequest {
132    pub ws_id: String,
133    pub session_id: String,
134    pub node_type: String,
135    pub node_id: String,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub host_name: Option<String>,
138    pub tenant_id: String,
139    pub scope_id: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct CleanupWsStateRequest {
144    pub ws_id: String,
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
148pub struct UpdateCallbackBaseRequest {
149    /// Host observation, never an independently discovered or fallback address.
150    pub network: crate::host::network::HostNetworkSnapshot,
151    /// Actual bound HTTP listener port. Zero means the listener is not ready.
152    pub port: u16,
153}
154
155impl UpdateCallbackBaseRequest {
156    pub fn callback_base(&self, now_ms: i64) -> Option<String> {
157        if self.port == 0 {
158            return None;
159        }
160        self.network
161            .available_ipv4(now_ms)
162            .map(|ip| format!("http://{ip}:{}", self.port))
163    }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct ExternalCoreDeviceIdentity {
169    pub device_id: String,
170    pub device_name: String,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct ListMdnsRecordsRequest {
176    pub port: u16,
177    pub addresses: Vec<String>,
178    pub host_type: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct ExternalCoreMdnsRecord {
184    pub service_type: String,
185    pub instance_name: String,
186    pub port: u16,
187    pub properties: HashMap<String, String>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub struct PollExternalCoreEventsRequest {
193    pub consumer_id: String,
194    pub max_events: u16,
195    pub timeout_ms: u64,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct PollExternalCoreEventsResponse {
201    pub events: Vec<ExternalCoreEvent>,
202    pub timed_out: bool,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct CompleteExternalCoreEventsRequest {
208    pub consumer_id: String,
209    pub completions: Vec<ExternalCoreEventCompletion>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub struct CompleteExternalCoreEventsResponse {
215    pub completed_event_ids: Vec<String>,
216    pub missing_event_ids: Vec<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct ExternalCoreEvent {
222    pub event_id: String,
223    pub kind: ExternalCoreEventKind,
224    pub payload: Value,
225    pub expects_response: bool,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
229#[serde(rename_all = "camelCase")]
230pub enum ExternalCoreEventKind {
231    MdnsRecordsChanged,
232    DeliveryRequested,
233    ConnectionControlRequested,
234    HostRuntimeRequest,
235    ServiceAppFacadeRequest,
236    ScopeOwnedDataPurgeRequested,
237    ArtifactDeliveryProjectionRequested,
238    ArtifactUploadProjectionRequested,
239}
240
241/// Messaging-only Core -> Host request. A successful response means delivery was accepted by
242/// the provider implementation, not merely queued in the Core event pump or read by a user.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase", deny_unknown_fields)]
245pub struct MessagingDeliveryRequest {
246    pub tenant_id: String,
247    pub scope_id: String,
248    pub surface_id: String,
249    pub target: String,
250    pub payload: String,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase", deny_unknown_fields)]
255pub struct MessagingDeliveryResponse {
256    pub outcome: crate::DeliveryOutcome,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase", deny_unknown_fields)]
261pub struct ExternalArtifactRequestContext {
262    pub tenant_id: String,
263    pub scope_id: String,
264    pub actor_user_id: String,
265    pub client_id: String,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[serde(rename_all = "camelCase", deny_unknown_fields)]
270pub struct ExternalPutArtifactRequest {
271    pub context: ExternalArtifactRequestContext,
272    pub artifact: artifact_api::PutArtifactRequest,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase", deny_unknown_fields)]
277pub struct ExternalOpenArtifactRequest {
278    pub context: ExternalArtifactRequestContext,
279    pub uri: String,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase", deny_unknown_fields)]
284pub struct ExternalAuthorizeArtifactReadRequest {
285    pub grant: String,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase", deny_unknown_fields)]
290pub struct ExternalAuthorizeArtifactUploadRequest {
291    pub grant: String,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase", deny_unknown_fields)]
296pub struct ExternalCommitArtifactUploadRequest {
297    pub grant: String,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase", deny_unknown_fields)]
302pub struct ExternalArtifactReadDescriptor {
303    pub path: String,
304    pub kind: artifact_api::ArtifactKind,
305    pub mime_type: String,
306    pub size_bytes: u64,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub width: Option<u32>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub height: Option<u32>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub duration_millis: Option<u64>,
313    pub sha256: String,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase", deny_unknown_fields)]
318pub struct ExternalArtifactUploadDescriptor {
319    pub upload_id: String,
320    pub path: String,
321    pub kind: artifact_api::ArtifactKind,
322    pub mime_type: String,
323    pub size_bytes: u64,
324    pub sha256: String,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub duration_millis: Option<u64>,
327    pub expires_at_unix_ms: u64,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase", deny_unknown_fields)]
332pub struct ArtifactDeliveryProjectionRequest {
333    pub grant: String,
334    pub expires_at_unix_ms: u64,
335    pub client_id: String,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase", deny_unknown_fields)]
340pub struct ArtifactDeliveryProjectionResponse {
341    pub url: String,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase", deny_unknown_fields)]
346pub struct ArtifactUploadProjectionRequest {
347    pub grant: String,
348    pub expires_at_unix_ms: u64,
349    pub client_id: String,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase", deny_unknown_fields)]
354pub struct ArtifactUploadProjectionResponse {
355    pub url: String,
356}
357
358/// An App Facade request delegated by an externally hosted Core to the
359/// application service that owns the target's business logic.
360#[derive(Debug, Clone, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct ExternalServiceAppFacadeRequest {
363    pub target: String,
364    pub tenant_id: String,
365    pub scope_id: String,
366    pub user_id: String,
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub client_id: Option<String>,
369    pub payload: Value,
370}
371
372/// A platform capability request emitted by an externally hosted Core.
373///
374/// Core owns the onboarding workflow. The native host owns LAN discovery and
375/// transport to a discovered candidate, so Android can satisfy this contract
376/// without moving application logic into the mobile shell.
377#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct ExternalHostRuntimeRequest {
380    pub method: ExternalHostRuntimeMethod,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub candidate_id: Option<String>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub action: Option<String>,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub payload: Option<Value>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390#[serde(rename_all = "camelCase")]
391pub enum ExternalHostRuntimeMethod {
392    Discovery,
393    CandidateRequest,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
397#[serde(rename_all = "camelCase")]
398pub struct ScopeOwnedDataPurgeRequest {
399    pub tenant_id: String,
400    pub scope_id: String,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct ExternalCoreEventCompletion {
406    pub event_id: String,
407    pub ok: bool,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub response: Option<Value>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub error: Option<ExternalCoreError>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct HttpPayloadRequest {
416    pub payload: Value,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct EndpointPayloadRequest {
421    pub endpoint_id: String,
422    pub payload: Value,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
426#[serde(rename_all = "camelCase")]
427pub struct ExternalCoreRuntimeMetadata {
428    pub backend: String,
429    pub instance_id: String,
430    pub pid: u32,
431    pub state: String,
432    pub started_at: String,
433    pub protocol_version: u32,
434    pub binary_version: String,
435    pub socket_path: String,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439#[serde(rename_all = "camelCase")]
440pub struct ExternalCoreHealth {
441    pub ok: bool,
442    pub ready: bool,
443    pub backend: String,
444    pub binary_version: String,
445    pub agent_version: String,
446    pub protocol_version: u32,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub instance_id: Option<String>,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub pid: Option<u32>,
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub state: Option<String>,
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub started_at: Option<String>,
455    #[serde(default)]
456    pub capabilities: Vec<String>,
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn callback_address_requires_a_fresh_host_observation_and_bound_port() {
465        let mut request = UpdateCallbackBaseRequest {
466            network: crate::host::network::HostNetworkSnapshot {
467                lan_ipv4: Some("192.168.1.5".parse().unwrap()),
468                observed_at_ms: 100_000,
469            },
470            port: 3210,
471        };
472        assert_eq!(
473            request.callback_base(100_000).as_deref(),
474            Some("http://192.168.1.5:3210")
475        );
476        let roundtrip: UpdateCallbackBaseRequest =
477            serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap();
478        assert_eq!(request, roundtrip);
479        assert_eq!(request.callback_base(145_000), None);
480        request.port = 0;
481        assert_eq!(request.callback_base(100_000), None);
482        assert_eq!(
483            UpdateCallbackBaseRequest::default().callback_base(100_000),
484            None
485        );
486    }
487
488    #[test]
489    fn messaging_delivery_is_a_closed_request_response_contract() {
490        let request = serde_json::json!({"tenantId":"t", "scopeId":"s", "surfaceId":"m",
491            "target":"/chat/event", "payload":"{}"});
492        let decoded: MessagingDeliveryRequest = serde_json::from_value(request.clone()).unwrap();
493        assert_eq!(serde_json::to_value(decoded).unwrap(), request);
494        let mut invalid = request;
495        invalid["unknown"] = serde_json::json!(true);
496        assert!(serde_json::from_value::<MessagingDeliveryRequest>(invalid).is_err());
497        assert!(
498            serde_json::from_value::<MessagingDeliveryResponse>(serde_json::json!({})).is_err()
499        );
500        assert_eq!(
501            serde_json::to_value(ExternalCoreEventKind::DeliveryRequested).unwrap(),
502            "deliveryRequested"
503        );
504    }
505
506    #[test]
507    fn external_events_use_the_current_wire_shape() {
508        let event = ExternalCoreEvent {
509            event_id: "event-1".to_string(),
510            kind: ExternalCoreEventKind::ScopeOwnedDataPurgeRequested,
511            payload: serde_json::to_value(ScopeOwnedDataPurgeRequest {
512                tenant_id: "tenant-1".to_string(),
513                scope_id: "scope-1".to_string(),
514            })
515            .unwrap(),
516            expects_response: true,
517        };
518
519        let value = serde_json::to_value(event).unwrap();
520        assert_eq!(EXTERNAL_CORE_PROTOCOL_VERSION, 17);
521        assert_eq!(value["kind"], "scopeOwnedDataPurgeRequested");
522        assert_eq!(value["payload"]["tenantId"], "tenant-1");
523        assert_eq!(value["payload"]["scopeId"], "scope-1");
524        assert_eq!(value["expectsResponse"], true);
525
526        let facade = ExternalCoreEvent {
527            event_id: "event-2".to_string(),
528            kind: ExternalCoreEventKind::ServiceAppFacadeRequest,
529            payload: serde_json::to_value(ExternalServiceAppFacadeRequest {
530                target: "/app/messaging/provider/list".to_string(),
531                tenant_id: "tenant-1".to_string(),
532                scope_id: "scope-1".to_string(),
533                user_id: "user-1".to_string(),
534                client_id: None,
535                payload: serde_json::json!({"placement": "local"}),
536            })
537            .unwrap(),
538            expects_response: true,
539        };
540        let value = serde_json::to_value(facade).unwrap();
541        assert_eq!(value["kind"], "serviceAppFacadeRequest");
542        assert_eq!(value["payload"]["target"], "/app/messaging/provider/list");
543        assert_eq!(value["payload"]["userId"], "user-1");
544        assert!(value["payload"].get("clientId").is_none());
545    }
546
547    #[test]
548    fn artifact_host_contract_uses_canonical_wire_types() {
549        let descriptor = ExternalArtifactReadDescriptor {
550            path: "/runtime/artifacts/blob".to_string(),
551            kind: artifact_api::ArtifactKind::Audio,
552            mime_type: "audio/ogg".to_string(),
553            size_bytes: 42,
554            width: None,
555            height: None,
556            duration_millis: Some(1_500),
557            sha256: "a".repeat(64),
558        };
559        let value = serde_json::to_value(descriptor).unwrap();
560        assert_eq!(value["kind"], "AUDIO");
561        assert_eq!(value["mimeType"], "audio/ogg");
562        assert_eq!(value["durationMillis"], 1_500);
563        assert!(value.get("width").is_none());
564
565        let request = ArtifactDeliveryProjectionRequest {
566            grant: "payload.signature".to_string(),
567            expires_at_unix_ms: 123,
568            client_id: "L:client".to_string(),
569        };
570        let value = serde_json::to_value(request).unwrap();
571        assert_eq!(value["expiresAtUnixMs"], 123);
572        assert_eq!(value["clientId"], "L:client");
573    }
574
575    #[test]
576    fn playground_uses_only_the_general_webhook_contract() {
577        let general = serde_json::to_value(ExternalCoreMethod::GeneralWebhookPayload).unwrap();
578        assert_eq!(general, serde_json::json!("GeneralWebhookPayload"));
579        assert!(
580            serde_json::from_value::<ExternalCoreMethod>(serde_json::json!(
581                "PlaygroundWebhookPayload"
582            ))
583            .is_err()
584        );
585    }
586}